diff --git a/Core/Net/NetAsyncModulesDownloader.cs b/Core/Net/NetAsyncModulesDownloader.cs index 93a6f7b635..fa28c9682d 100644 --- a/Core/Net/NetAsyncModulesDownloader.cs +++ b/Core/Net/NetAsyncModulesDownloader.cs @@ -42,37 +42,6 @@ public NetAsyncModulesDownloader(IUser user, NetModuleCache cache) this.cache = cache; } - internal static List> GroupByDownloads(IEnumerable modules) - { - // Each module is a vertex, each download URL is an edge - // We want to group the vertices by transitive connectedness - // We can go breadth first or depth first - // Once we encounter a mod, we never have to look at it again - var unsearched = modules.ToHashSet(); - var groups = new List>(); - while (unsearched.Count > 0) - { - // Find one group, remove it from unsearched, add it to groups - var searching = new List { unsearched.First() }; - unsearched.ExceptWith(searching); - var found = searching.ToHashSet(); - // Breadth first search to find all modules any URLs in common, transitively - while (searching.Count > 0) - { - var origin = searching.First(); - searching.Remove(origin); - var neighbors = origin.download - .SelectMany(dlUri => unsearched.Where(other => other.download.Contains(dlUri))) - .ToHashSet(); - unsearched.ExceptWith(neighbors); - searching.AddRange(neighbors); - found.UnionWith(neighbors); - } - groups.Add(found); - } - return groups; - } - internal Net.DownloadTarget TargetFromModuleGroup(HashSet group, string[] preferredHosts) => TargetFromModuleGroup(group, group.OrderBy(m => m.identifier).First(), preferredHosts); @@ -102,7 +71,7 @@ public void DownloadModules(IEnumerable modules) { var activeURLs = this.modules.SelectMany(m => m.download) .ToHashSet(); - var moduleGroups = GroupByDownloads(modules); + var moduleGroups = CkanModule.GroupByDownloads(modules); // Make sure we have enough space to download and cache cache.CheckFreeSpace(moduleGroups.Select(grp => grp.First().download_size) .Sum()); diff --git a/Core/Registry/Registry.cs b/Core/Registry/Registry.cs index 390d8626bf..5d469f70d2 100644 --- a/Core/Registry/Registry.cs +++ b/Core/Registry/Registry.cs @@ -487,10 +487,7 @@ public void SetAllAvailable(IEnumerable newAvail) /// /// True if we have at least one available mod, false otherwise. /// - public bool HasAnyAvailable() - { - return available_modules.Count > 0; - } + public bool HasAnyAvailable() => available_modules.Count > 0; /// /// Mark a given module as available. @@ -643,11 +640,9 @@ public string GetAvailableMetadata(string identifier) /// /// Name of mod to check public GameVersion LatestCompatibleKSP(string identifier) - { - return available_modules.ContainsKey(identifier) - ? available_modules[identifier].LatestCompatibleKSP() + => available_modules.TryGetValue(identifier, out AvailableModule availMod) + ? availMod.LatestCompatibleKSP() : null; - } /// /// Find the minimum and maximum mod versions and compatible game versions diff --git a/Core/Types/CkanModule.cs b/Core/Types/CkanModule.cs index 10da7f12c4..30c975a526 100644 --- a/Core/Types/CkanModule.cs +++ b/Core/Types/CkanModule.cs @@ -6,10 +6,13 @@ using System.Runtime.Serialization; using System.Text; using System.Text.RegularExpressions; + using Autofac; using log4net; using Newtonsoft.Json; + using CKAN.Versioning; +using CKAN.Extensions; using CKAN.Games; namespace CKAN @@ -806,6 +809,44 @@ public static string FmtSize(long bytes) : bytes < K*K*K*K ? $"{bytes /K/K/K :N1} GiB" : $"{bytes /K/K/K/K :N1} TiB"; + public HashSet GetDownloadsGroup(IEnumerable modules) + => OneDownloadGroupingPass(modules.ToHashSet(), this); + + public static List> GroupByDownloads(IEnumerable modules) + { + // Each module is a vertex, each download URL is an edge + // We want to group the vertices by transitive connectedness + // We can go breadth first or depth first + // Once we encounter a mod, we never have to look at it again + var unsearched = modules.ToHashSet(); + var groups = new List>(); + while (unsearched.Count > 0) + { + groups.Add(OneDownloadGroupingPass(unsearched, unsearched.First())); + } + return groups; + } + + private static HashSet OneDownloadGroupingPass(HashSet unsearched, + CkanModule firstModule) + { + var searching = new List { firstModule }; + unsearched.ExceptWith(searching); + var found = searching.ToHashSet(); + // Breadth first search to find all modules with any URLs in common, transitively + while (searching.Count > 0) + { + var origin = searching.First(); + searching.Remove(origin); + var neighbors = origin.download + .SelectMany(dlUri => unsearched.Where(other => other.download.Contains(dlUri))) + .ToHashSet(); + unsearched.ExceptWith(neighbors); + searching.AddRange(neighbors); + found.UnionWith(neighbors); + } + return found; + } } public class InvalidModuleAttributesException : Exception diff --git a/GUI/Controls/Changeset.cs b/GUI/Controls/Changeset.cs index 2fb0d6dfc9..0d1152cad1 100644 --- a/GUI/Controls/Changeset.cs +++ b/GUI/Controls/Changeset.cs @@ -20,29 +20,19 @@ public void LoadChangeset( List AlertLabels, Dictionary conflicts) { - changeset = changes; - alertLabels = AlertLabels; - ChangesListView.Items.Clear(); - if (changes != null) - { - // Changeset sorting is handled upstream in the resolver - ChangesListView.Items.AddRange(changes - .Where(ch => ch.ChangeType != GUIModChangeType.None) - .Select(ch => makeItem(ch, conflicts)) - .ToArray()); - ChangesListView.AutoResizeColumns(ColumnHeaderAutoResizeStyle.ColumnContent); - ChangesListView.AutoResizeColumns(ColumnHeaderAutoResizeStyle.HeaderSize); - } + changeset = changes; + alertLabels = AlertLabels; + this.conflicts = conflicts; ConfirmChangesButton.Enabled = conflicts == null || !conflicts.Any(); } protected override void OnVisibleChanged(EventArgs e) { base.OnVisibleChanged(e); - if (Visible && Platform.IsMono) + if (Visible) { - // Workaround: make sure the ListView headers are drawn - Util.Invoke(ChangesListView, () => ChangesListView.EndUpdate()); + // Update list on each refresh in case caching changed + UpdateList(); } } @@ -54,6 +44,23 @@ public ListView.SelectedListViewItemCollection SelectedItems public event Action> OnConfirmChanges; public event Action OnCancelChanges; + private void UpdateList() + { + ChangesListView.BeginUpdate(); + ChangesListView.Items.Clear(); + if (changeset != null) + { + // Changeset sorting is handled upstream in the resolver + ChangesListView.Items.AddRange(changeset + .Where(ch => ch.ChangeType != GUIModChangeType.None) + .Select(ch => makeItem(ch, conflicts)) + .ToArray()); + ChangesListView.AutoResizeColumns(ColumnHeaderAutoResizeStyle.ColumnContent); + ChangesListView.AutoResizeColumns(ColumnHeaderAutoResizeStyle.HeaderSize); + } + ChangesListView.EndUpdate(); + } + private void ChangesListView_SelectedIndexChanged(object sender, EventArgs e) { OnSelectedItemsChanged?.Invoke(ChangesListView.SelectedItems); @@ -101,7 +108,8 @@ private ListViewItem makeItem(ModChange change, Dictionary c }; } - private List changeset; - private List alertLabels; + private List changeset; + private List alertLabels; + private Dictionary conflicts; } } diff --git a/GUI/Controls/ManageMods.cs b/GUI/Controls/ManageMods.cs index 92fb35d980..64c2863c63 100644 --- a/GUI/Controls/ManageMods.cs +++ b/GUI/Controls/ManageMods.cs @@ -31,7 +31,9 @@ public ManageMods() FilterNotInstalledButton.ToolTipText = Properties.Resources.FilterLinkToolTip; FilterIncompatibleButton.ToolTipText = Properties.Resources.FilterLinkToolTip; - mainModList = new ModList(source => UpdateFilters()); + mainModList = new ModList(); + mainModList.ModFiltersUpdated += UpdateFilters; + UpdateFilters(); FilterToolButton.MouseHover += (sender, args) => FilterToolButton.ShowDropDown(); launchGameToolStripMenuItem.MouseHover += (sender, args) => launchGameToolStripMenuItem.ShowDropDown(); ApplyToolButton.MouseHover += (sender, args) => ApplyToolButton.ShowDropDown(); @@ -929,8 +931,9 @@ public void FocusMod(string key, bool exactMatch, bool showAsFirst = false) }); ModGrid.ClearSelection(); - var rows = ModGrid.Rows.Cast().Where(row => row.Visible); - DataGridViewRow match = rows.FirstOrDefault(does_name_begin_with_key); + DataGridViewRow match = ModGrid.Rows.Cast() + .Where(row => row.Visible) + .FirstOrDefault(does_name_begin_with_key); if (match == null && first_match != null) { // If there were no matches after the first match, cycle over to the beginning. @@ -1031,6 +1034,13 @@ private void reinstallToolStripMenuItem_Click(object sender, EventArgs e) .ToList()); } + public Dictionary AllGUIMods() + => ModGrid.Rows.Cast() + .Select(row => row.Tag as GUIMod) + .Where(guiMod => guiMod != null) + .ToDictionary(guiMod => guiMod.Identifier, + guiMod => guiMod); + private void purgeContentsToolStripMenuItem_Click(object sender, EventArgs e) { // Purge other versions as well since the user is likely to want that @@ -1044,7 +1054,18 @@ private void purgeContentsToolStripMenuItem_Click(object sender, EventArgs e) { Main.Instance.Manager.Cache.Purge(mod); } - selected.UpdateIsCached(); + + // Update all mods that share the same ZIP + var allGuiMods = AllGUIMods(); + foreach (var otherMod in selected.ToModule().GetDownloadsGroup( + allGuiMods.Values.Select(guiMod => guiMod.ToModule()))) + { + allGuiMods[otherMod.identifier].UpdateIsCached(); + } + + // Reapply searches in case is:cached or not:cached is active + UpdateFilters(); + Main.Instance.RefreshModContentsTree(); } } @@ -1067,7 +1088,7 @@ private void EditModSearches_SurrenderFocus() Util.Invoke(this, () => ModGrid.Focus()); } - private void UpdateFilters() + public void UpdateFilters() { Util.Invoke(this, _UpdateFilters); } @@ -1503,15 +1524,10 @@ public void ResetFilterAndSelectModOnList(string key) FocusMod(key, true); } - public GUIMod SelectedModule - { - get - { - return ModGrid.SelectedRows.Count == 0 - ? null - : ModGrid.SelectedRows[0]?.Tag as GUIMod; - } - } + public GUIMod SelectedModule => + ModGrid.SelectedRows.Count == 0 + ? null + : ModGrid.SelectedRows[0]?.Tag as GUIMod; #region Navigation History diff --git a/GUI/Main/MainDownload.cs b/GUI/Main/MainDownload.cs index 261d77356c..16ef6d7922 100644 --- a/GUI/Main/MainDownload.cs +++ b/GUI/Main/MainDownload.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Collections.Generic; using System.ComponentModel; using System.Threading.Tasks; @@ -26,7 +27,7 @@ public void StartDownload(GUIMod module) { // Just pass to the existing worker downloader.DownloadModules(new List { module.ToCkanModule() }); - module.UpdateIsCached(); + UpdateCachedByDownloads(module); }); } else @@ -82,13 +83,30 @@ public void PostModCaching(object sender, RunWorkerCompletedEventArgs e) } } + private void UpdateCachedByDownloads(GUIMod module) + { + // Update all mods that share the same ZIP + var allGuiMods = ManageMods.AllGUIMods(); + foreach (var otherMod in module.ToModule().GetDownloadsGroup( + allGuiMods.Values.Select(guiMod => guiMod.ToModule()))) + { + allGuiMods[otherMod.identifier].UpdateIsCached(); + } + } + private void _PostModCaching(GUIMod module) { - module.UpdateIsCached(); - // Update mod list in case is:cached or not:cached filters are active - RefreshModList(); + UpdateCachedByDownloads(module); + + // Reapply searches in case is:cached or not:cached is active + ManageMods.UpdateFilters(); + // User might have selected another row. Show current in tree. RefreshModContentsTree(); + + // Close progress tab and switch back to mod list + HideWaitDialog(); + EnableMainWindow(); } } } diff --git a/GUI/Model/GUIMod.cs b/GUI/Model/GUIMod.cs index f844a7f841..88c04068a5 100644 --- a/GUI/Model/GUIMod.cs +++ b/GUI/Model/GUIMod.cs @@ -93,7 +93,7 @@ private void OnPropertyChanged([CallerMemberName] string name = null) public bool IsUpgradeChecked { get; private set; } public bool IsReplaceChecked { get; private set; } public bool IsNew { get; set; } - public bool IsCKAN { get; private set; } + public bool IsCKAN => Mod != null; public string Abbrevation { get; private set; } public string SearchableName { get; private set; } @@ -156,7 +156,6 @@ public GUIMod(CkanModule mod, IRegistryQuerier registry, GameVersionCriteria cur : this(mod.identifier, registry, current_game_version, incompatible, hideEpochs, hideV) { Mod = mod; - IsCKAN = mod is CkanModule; Name = mod.name.Trim(); Abstract = mod.@abstract.Trim(); @@ -288,10 +287,7 @@ public CkanModule ToCkanModule() /// Get the CkanModule associated with this GUIMod. /// /// The CkanModule associated with this GUIMod or null if there is none - public CkanModule ToModule() - { - return Mod; - } + public CkanModule ToModule() => Mod; public IEnumerable GetModChanges() { @@ -431,10 +427,7 @@ public void SetAutoInstallChecked(DataGridViewRow row, DataGridViewColumn col, b } } - private bool Equals(GUIMod other) - { - return Equals(Identifier, other.Identifier); - } + private bool Equals(GUIMod other) => Equals(Identifier, other.Identifier); public override bool Equals(object obj) { @@ -444,14 +437,8 @@ public override bool Equals(object obj) return Equals((GUIMod) obj); } - public override int GetHashCode() - { - return Identifier?.GetHashCode() ?? 0; - } + public override int GetHashCode() => Identifier?.GetHashCode() ?? 0; - public override string ToString() - { - return $"{ToModule()}"; - } + public override string ToString() => Mod.ToString(); } } diff --git a/GUI/Model/ModList.cs b/GUI/Model/ModList.cs index ff705db7c0..3819e57d58 100644 --- a/GUI/Model/ModList.cs +++ b/GUI/Model/ModList.cs @@ -36,20 +36,15 @@ public class ModList //identifier, row internal Dictionary full_list_of_mod_rows; - public ModList(Action onModFiltersUpdated) + public ModList() { Modules = new ReadOnlyCollection(new List()); - if (onModFiltersUpdated != null) - { - ModFiltersUpdated += onModFiltersUpdated; - ModFiltersUpdated(this); - } } //TODO Move to relationship resolver and have it use this. public delegate Task HandleTooManyProvides(TooManyModsProvideKraken kraken); - public event Action ModFiltersUpdated; + public event Action ModFiltersUpdated; public ReadOnlyCollection Modules { get; set; } public readonly ModuleLabelList ModuleLabels = ModuleLabelList.Load(ModuleLabelList.DefaultPath) @@ -69,7 +64,7 @@ public void SetSearches(List newSearches) Main.Instance.configuration.DefaultSearches = activeSearches?.Select(s => s?.Combined ?? "").ToList() ?? new List() { "" }; - ModFiltersUpdated?.Invoke(this); + ModFiltersUpdated?.Invoke(); } } diff --git a/Tests/Core/Net/NetAsyncModulesDownloaderTests.cs b/Tests/Core/Net/NetAsyncModulesDownloaderTests.cs index 1586802e65..8676aa7ac9 100644 --- a/Tests/Core/Net/NetAsyncModulesDownloaderTests.cs +++ b/Tests/Core/Net/NetAsyncModulesDownloaderTests.cs @@ -66,106 +66,6 @@ public void TearDown() ksp.Dispose(); } - [Test, - // No mods, nothing to group - TestCase(new string[] { }, - // null is removed as a workaround for limitations of params keyword - null), - // One mod, one group - TestCase(new string[] - { - @"{ - ""identifier"": ""ModA"", - ""version"": ""1.0"", - ""download"": ""https://github.com/"" - }", - }, - new string[] { "ModA" }), - // Two unrelated, two groups - TestCase(new string[] - { - @"{ - ""identifier"": ""ModA"", - ""version"": ""1.0"", - ""download"": ""https://github.com/"" - }", - @"{ - ""identifier"": ""ModB"", - ""version"": ""1.0"", - ""download"": ""https://spacedock.info/"" - }", - }, - new string[] { "ModA" }, - new string[] { "ModB" }), - // Same URL, one group - TestCase(new string[] - { - @"{ - ""identifier"": ""ModA"", - ""version"": ""1.0"", - ""download"": ""https://github.com/"" - }", - @"{ - ""identifier"": ""ModB"", - ""version"": ""1.0"", - ""download"": ""https://github.com/"" - }", - }, - new string[] { "ModA", "ModB" }), - // Transitively shared URLs in one group, unrelated separate - TestCase(new string[] - { - @"{ - ""identifier"": ""ModA"", - ""version"": ""1.0"", - ""download"": [ ""https://github.com/"", ""https://spacedock.info/"" ] - }", - @"{ - ""identifier"": ""ModB"", - ""version"": ""1.0"", - ""download"": [ ""https://curseforge.com/"" ] - }", - @"{ - ""identifier"": ""ModC"", - ""version"": ""1.0"", - ""download"": [ ""https://spacedock.info/"", ""https://archive.org/"" ] - }", - @"{ - ""identifier"": ""ModD"", - ""version"": ""1.0"", - ""download"": [ ""https://drive.google.com/"" ] - }", - @"{ - ""identifier"": ""ModE"", - ""version"": ""1.0"", - ""download"": [ ""https://archive.org/"", ""https://taniwha.org/"" ] - }", - }, - new string[] { "ModA", "ModC", "ModE" }, - new string[] { "ModB" }, - new string[] { "ModD" }), - ] - public void GroupByDownloads_WithModules_GroupsBySharedURLs(string[] moduleJsons, params string[][] correctGroups) - { - // Arrange - var modules = moduleJsons.Select(j => CkanModule.FromJson(j)) - .ToArray(); - // Turn [null] into [] as Workaround for params argument not allowing no values - // (params argument itself is a workaround for TestCase not allowing string[][]) - correctGroups = correctGroups.Where(g => g != null).ToArray(); - - // Act - var result = NetAsyncModulesDownloader.GroupByDownloads(modules); - var groupIdentifiers = result.Select(grp => grp.OrderBy(m => m.identifier) - .Select(m => m.identifier) - .ToArray()) - .OrderBy(grp => grp.First()) - .ToArray(); - - // Assert - Assert.AreEqual(correctGroups, groupIdentifiers); - } - [Test, // No modules, not valid TestCase(new string[] { }, diff --git a/Tests/Core/Types/CkanModuleTests.cs b/Tests/Core/Types/CkanModuleTests.cs index f4dcc369db..bef93da3be 100644 --- a/Tests/Core/Types/CkanModuleTests.cs +++ b/Tests/Core/Types/CkanModuleTests.cs @@ -227,5 +227,104 @@ public void InternetArchiveDownload_NoHash_NullURL() Assert.IsNull(uri); } + [Test, + // No mods, nothing to group + TestCase(new string[] { }, + // null is removed as a workaround for limitations of params keyword + null), + // One mod, one group + TestCase(new string[] + { + @"{ + ""identifier"": ""ModA"", + ""version"": ""1.0"", + ""download"": ""https://github.com/"" + }", + }, + new string[] { "ModA" }), + // Two unrelated, two groups + TestCase(new string[] + { + @"{ + ""identifier"": ""ModA"", + ""version"": ""1.0"", + ""download"": ""https://github.com/"" + }", + @"{ + ""identifier"": ""ModB"", + ""version"": ""1.0"", + ""download"": ""https://spacedock.info/"" + }", + }, + new string[] { "ModA" }, + new string[] { "ModB" }), + // Same URL, one group + TestCase(new string[] + { + @"{ + ""identifier"": ""ModA"", + ""version"": ""1.0"", + ""download"": ""https://github.com/"" + }", + @"{ + ""identifier"": ""ModB"", + ""version"": ""1.0"", + ""download"": ""https://github.com/"" + }", + }, + new string[] { "ModA", "ModB" }), + // Transitively shared URLs in one group, unrelated separate + TestCase(new string[] + { + @"{ + ""identifier"": ""ModA"", + ""version"": ""1.0"", + ""download"": [ ""https://github.com/"", ""https://spacedock.info/"" ] + }", + @"{ + ""identifier"": ""ModB"", + ""version"": ""1.0"", + ""download"": [ ""https://curseforge.com/"" ] + }", + @"{ + ""identifier"": ""ModC"", + ""version"": ""1.0"", + ""download"": [ ""https://spacedock.info/"", ""https://archive.org/"" ] + }", + @"{ + ""identifier"": ""ModD"", + ""version"": ""1.0"", + ""download"": [ ""https://drive.google.com/"" ] + }", + @"{ + ""identifier"": ""ModE"", + ""version"": ""1.0"", + ""download"": [ ""https://archive.org/"", ""https://taniwha.org/"" ] + }", + }, + new string[] { "ModA", "ModC", "ModE" }, + new string[] { "ModB" }, + new string[] { "ModD" }), + ] + public void GroupByDownloads_WithModules_GroupsBySharedURLs(string[] moduleJsons, params string[][] correctGroups) + { + // Arrange + var modules = moduleJsons.Select(j => CkanModule.FromJson(j)) + .ToArray(); + // Turn [null] into [] as Workaround for params argument not allowing no values + // (params argument itself is a workaround for TestCase not allowing string[][]) + correctGroups = correctGroups.Where(g => g != null).ToArray(); + + // Act + var result = CkanModule.GroupByDownloads(modules); + var groupIdentifiers = result.Select(grp => grp.OrderBy(m => m.identifier) + .Select(m => m.identifier) + .ToArray()) + .OrderBy(grp => grp.First()) + .ToArray(); + + // Assert + Assert.AreEqual(correctGroups, groupIdentifiers); + } } } diff --git a/Tests/GUI/Model/ModList.cs b/Tests/GUI/Model/ModList.cs index d93d1846e7..3b092aec6b 100644 --- a/Tests/GUI/Model/ModList.cs +++ b/Tests/GUI/Model/ModList.cs @@ -21,7 +21,7 @@ public class ModListTests [Test] public void ComputeFullChangeSetFromUserChangeSet_WithEmptyList_HasEmptyChangeSet() { - var item = new ModList(delegate { }); + var item = new ModList(); Assert.That(item.ComputeUserChangeSet(null, null), Is.Empty); } @@ -42,7 +42,7 @@ public void IsVisible_WithAllAndNoNameFilter_ReturnsTrueForCompatible() var ckan_mod = TestData.FireSpitterModule(); var registry = Registry.Empty(); registry.AddAvailable(ckan_mod); - var item = new ModList(delegate { }); + var item = new ModList(); Assert.That(item.IsVisible( new GUIMod(ckan_mod, registry, manager.CurrentInstance.VersionCriteria()), manager.CurrentInstance.Name @@ -61,7 +61,7 @@ public static Array GetFilters() [TestCaseSource("GetFilters")] public void CountModsByFilter_EmptyModList_ReturnsZero(GUIModFilter filter) { - var item = new ModList(delegate { }); + var item = new ModList(); Assert.That(item.CountModsByFilter(filter), Is.EqualTo(0)); } @@ -82,7 +82,7 @@ public void ConstructModList_NumberOfRows_IsEqualToNumberOfMods() var registry = Registry.Empty(); registry.AddAvailable(TestData.FireSpitterModule()); registry.AddAvailable(TestData.kOS_014_module()); - var main_mod_list = new ModList(null); + var main_mod_list = new ModList(); var mod_list = main_mod_list.ConstructModList( new List { @@ -142,7 +142,7 @@ public void InstallAndSortByCompat_WithAnyCompat_NoCrash() GameInstanceManager manager = new GameInstanceManager(new NullUser(), config); // A module with a ksp_version of "any" to repro our issue CkanModule anyVersionModule = TestData.DogeCoinFlag_101_module(); - ModList modList = new ModList(null); + ModList modList = new ModList(); DataGridView listGui = new DataGridView(); CKAN.ModuleInstaller installer = new CKAN.ModuleInstaller(instance.KSP, manager.Cache, manager.User); NetAsyncModulesDownloader downloader = new NetAsyncModulesDownloader(manager.User, manager.Cache);