diff --git a/README.md b/README.md index ad393d6..cbf4ced 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Lightweight server picker for CS2 and Deadlock with cross-platform support for * ### [Releases](https://github.com/FN-FAL113/server-picker-x/releases) ## 📷 Screenshot -![ServerPickerX](https://github.com/user-attachments/assets/195b9553-4ca3-48b4-9d21-1aca8990d623) +![ServerPickerX](https://github.com/user-attachments/assets/6feac783-5d18-4900-bf6a-0a07b9d665a9)
Windows Short Demo diff --git a/ServerPickerX/Comparers/NaturalStringComparer.cs b/ServerPickerX/Comparers/NaturalStringComparer.cs new file mode 100644 index 0000000..be68255 --- /dev/null +++ b/ServerPickerX/Comparers/NaturalStringComparer.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; + +namespace ServerPickerX.Comparers +{ + public sealed class NaturalStringComparer : IComparer + { + public static NaturalStringComparer OrdinalIgnoreCase { get; } = new(StringComparison.OrdinalIgnoreCase); + + private readonly StringComparison _stringComparison; + + public NaturalStringComparer(StringComparison stringComparison) + { + _stringComparison = stringComparison; + } + + public int Compare(string? left, string? right) + { + if (ReferenceEquals(left, right)) + { + return 0; + } + + if (left == null) + { + return -1; + } + + if (right == null) + { + return 1; + } + + int leftIndex = 0; + int rightIndex = 0; + + while (leftIndex < left.Length && rightIndex < right.Length) + { + if (char.IsDigit(left[leftIndex]) && char.IsDigit(right[rightIndex])) + { + int leftNumberStart = leftIndex; + int rightNumberStart = rightIndex; + + while (leftIndex < left.Length && char.IsDigit(left[leftIndex])) + { + leftIndex++; + } + + while (rightIndex < right.Length && char.IsDigit(right[rightIndex])) + { + rightIndex++; + } + + ReadOnlySpan leftDigits = left.AsSpan(leftNumberStart, leftIndex - leftNumberStart); + ReadOnlySpan rightDigits = right.AsSpan(rightNumberStart, rightIndex - rightNumberStart); + + int leftTrimmedStart = 0; + while (leftTrimmedStart < leftDigits.Length - 1 && leftDigits[leftTrimmedStart] == '0') + { + leftTrimmedStart++; + } + + int rightTrimmedStart = 0; + while (rightTrimmedStart < rightDigits.Length - 1 && rightDigits[rightTrimmedStart] == '0') + { + rightTrimmedStart++; + } + + ReadOnlySpan leftTrimmedDigits = leftDigits[leftTrimmedStart..]; + ReadOnlySpan rightTrimmedDigits = rightDigits[rightTrimmedStart..]; + + if (leftTrimmedDigits.Length != rightTrimmedDigits.Length) + { + return leftTrimmedDigits.Length.CompareTo(rightTrimmedDigits.Length); + } + + int digitComparison = leftTrimmedDigits.CompareTo(rightTrimmedDigits, StringComparison.Ordinal); + + if (digitComparison != 0) + { + return digitComparison; + } + + if (leftDigits.Length != rightDigits.Length) + { + return leftDigits.Length.CompareTo(rightDigits.Length); + } + + continue; + } + + int characterComparison = string.Compare( + left[leftIndex].ToString(), + right[rightIndex].ToString(), + _stringComparison + ); + + if (characterComparison != 0) + { + return characterComparison; + } + + leftIndex++; + rightIndex++; + } + + return left.Length.CompareTo(right.Length); + } + } +} diff --git a/ServerPickerX/Locales/Locale_de-de.axaml b/ServerPickerX/Locales/Locale_de-de.axaml index 5a980c2..a09e274 100644 --- a/ServerPickerX/Locales/Locale_de-de.axaml +++ b/ServerPickerX/Locales/Locale_de-de.axaml @@ -1,28 +1,50 @@ - de-de Server gruppieren Server entgruppieren Aktualisieren Nach Servern suchen... + Keine Presets Alle blockieren Ausgewählte blockieren Alle freigeben Ausgewählte freigeben + Speichern + Abbrechen Bei Start nach neuen Versionen suchen Brandmauer zurücksetzen Spielmodus auswählen + Preset auswählen Server gruppieren oder entgruppieren Ping aller Server aktualisieren Diese Aktion wird zuerst alle Server freigeben, um Firewall-Konflikte zu vermeiden. Sprache auswählen Firewall-Regeln zurücksetzen Info - Die Serverdaten wurden soeben von Valve aktualisiert! Alle gesperrten Server werden entsperrt, um die neuen Serverdaten zu synchronisieren. + Die Serverdaten wurden gerade von Valve aktualisiert! Alle blockierten Server werden entsperrt, um die neuen Serverdaten zu synchronisieren. Preset-Einträge für Server, die nicht mehr existieren, werden entfernt, und das zuletzt ausgewählte Preset wird erneut angewendet, falls verfügbar. Neue Version verfügbar! Zu den Veröffentlichungen? Hey! Bitte wähle mindestens einen Server zum Blockieren aus. Bitte wählen Sie mindestens einen Server aus, um die Entsperrung aufzuheben. Moment! Ein Vorgang läuft bereits. Bitte warten Sie... Dadurch wird versucht, die Firewall auf den Standardzustand zurückzusetzen. Aktion bestätigen? Firewall erfolgreich zurückgesetzt! + Voreinstellungen + Voreinstellungen verwalten + Hinzufügen + Neues Preset + Neues Preset erstellen + Löschen + Ausgewählte Presets löschen + Anwenden + Ausgewähltes Preset anwenden + Voreinstellungen + Blockierte Server + Blockiert + Voreinstellung + Preset '{0}' löschen? + Die {0} ausgewählten Presets löschen? + Ein Preset mit dem Namen '{0}' existiert für dieses Spiel bereits. Überschreiben? + Das Wechseln zwischen gruppierter und entgruppierter Ansicht löscht die blockierten Einträge dieses Presets. Fortfahren? + Der Preset-Name darf nicht leer sein. diff --git a/ServerPickerX/Locales/Locale_en-us.axaml b/ServerPickerX/Locales/Locale_en-us.axaml index 39c4570..4ecc99f 100644 --- a/ServerPickerX/Locales/Locale_en-us.axaml +++ b/ServerPickerX/Locales/Locale_en-us.axaml @@ -5,24 +5,46 @@ Uncluster Servers Refresh Search for servers... + No presets Block All Block Selected Unblock All Unblock Selected + Save + Cancel Check for new version on startup Reset Firewall Select game mode + Select preset Group or ungroup servers Refresh all server ping This action will unblock all servers first to prevent firewall conflicts. Select language Reset firewall rules Info - Server data just got updated by Valve! All blocked servers will be unblocked in order to synchronize new server data + Server data just got updated by Valve! All blocked servers will be unblocked in order to synchronize new server data. Preset entries for servers that no longer exist will be removed, and the last selected preset will be reapplied if available. New version available! Go to releases? Hey! Please select at least one server to block Hey! Please select at least one server to unblock Whoa! There's already a pending operation. Please wait... This will attempt to reset firewall to its default state. Confirm action? Firewall has been successfully reset! + Presets + Manage presets + Add + New Preset + Create a new preset + Delete + Delete selected presets + Apply + Apply the selected preset + Presets + Blocked Servers + Blocked + Preset + Delete the preset '{0}'? + Delete the {0} selected presets? + A preset named '{0}' already exists for this game. Overwrite it? + Changing between clustered and unclustered view will clear this preset's blocked entries. Continue? + Preset name cannot be empty. diff --git a/ServerPickerX/Locales/Locale_es-es.axaml b/ServerPickerX/Locales/Locale_es-es.axaml index 6879e10..ed19545 100644 --- a/ServerPickerX/Locales/Locale_es-es.axaml +++ b/ServerPickerX/Locales/Locale_es-es.axaml @@ -1,28 +1,50 @@ - es-es Servidores de clúster Servidores Uncluster Refrescar Buscar servidores... + No hay presets Bloquear Todo Bloque Seleccionado Desbloquear Todo Desbloquear Seleccionado + Guardar + Cancelar Comprobar si hay nueva versión al iniciar Restablecer el cortafuegos Seleccionar modo de juego + Seleccionar preset Agrupar o desagrupar servidores Actualizar el ping de todos los servidores Esta acción desbloqueará primero todos los servidores para evitar conflictos de firewall. Seleccionar idioma Restablecer reglas del firewall Información - ¡Valve acaba de actualizar los datos del servidor! Todos los servidores bloqueados serán desbloqueados para sincronizar los nuevos datos. + Valve acaba de actualizar los datos del servidor. Todos los servidores bloqueados se desbloquearán para sincronizar los nuevos datos. Se eliminarán las entradas del preset correspondientes a servidores que ya no existen y, si está disponible, se volverá a aplicar el último preset seleccionado. ¡Nueva versión disponible! ¿Ir a lanzamientos? Por favor, selecciona al menos un servidor para bloquear. Seleccione al menos un servidor para desbloquear. ¡Vaya! Ya hay una operación pendiente. Por favor, espere... Esto intentará restablecer el cortafuegos a su estado predeterminado. ¿Confirma la acción? ¡El cortafuegos se ha restablecido correctamente a su estado predeterminado! + Preajustes + Administrar preajustes + Añadir + Nuevo preajuste + Crear un nuevo preajuste + Eliminar + Eliminar los preajustes seleccionados + Aplicar + Aplicar el preajuste seleccionado + Preajustes + Servidores bloqueados + Bloqueado + Preajuste + ¿Eliminar el preset '{0}'? + ¿Eliminar los {0} preajustes seleccionados? + Ya existe un preset llamado '{0}' para este juego. ¿Sobrescribirlo? + Cambiar entre la vista agrupada y no agrupada borrará las entradas bloqueadas de este preajuste. ¿Continuar? + El nombre del preset no puede estar vacío. diff --git a/ServerPickerX/Locales/Locale_ja-jp.axaml b/ServerPickerX/Locales/Locale_ja-jp.axaml index aff28c6..82aef1f 100644 --- a/ServerPickerX/Locales/Locale_ja-jp.axaml +++ b/ServerPickerX/Locales/Locale_ja-jp.axaml @@ -5,24 +5,46 @@ サーバーのグループ解除 更新 サーバーを検索... + プリセットなし すべてブロック 選択したサーバーをブロック すべて解除 選択したサーバーを解除 + 保存 + キャンセル 起動時に新しいバージョンを確認 ファイアウォールをリセット ゲームモードを選択 + プリセットを選択 サーバーをグループ化またはグループ解除 すべてのサーバーの ping を更新 この操作は、ファイアウォールの競合を防ぐためにまずすべてのサーバーを解除します。 言語を選択 ファイアウォールルールをリセット 情報 - Valveによってサーバーデータが更新されました!新しいサーバーデータを同期するために、ブロックされていたすべてのサーバーのブロックが解除されます。 + Valve によってサーバーデータが更新されました。新しいサーバーデータと同期するために、ブロックされていたすべてのサーバーのブロックが解除されます。存在しなくなったサーバーのプリセット項目は削除され、可能であれば最後に選択したプリセットが再適用されます。 新バージョンがリリースされました!リリース一覧へどうぞ。 こんにちは!ブロックするサーバーを少なくとも1つ選択してください。 ブロックを解除したいサーバーを少なくとも1つ選択してください。 おっと!既に処理が実行されています。しばらくお待ちください。 これにより、ファイアウォールがデフォルトの状態にリセットされます。操作を確定しますか? ファイアウォールが正常にリセットされました! + プリセット + プリセットの管理 + 追加 + 新しいプリセット + 新しいプリセットを作成 + 削除 + 選択したプリセットを削除 + 適用 + 選択したプリセットを適用 + プリセット + ブロック中のサーバー + ブロック + プリセット + プリセット '{0}' を削除しますか? + 選択した {0} 個のプリセットを削除しますか? + このゲームには '{0}' という名前のプリセットが既にあります。上書きしますか? + クラスタ表示と非クラスタ表示を切り替えると、このプリセットのブロック済み項目は消去されます。続行しますか? + プリセット名は空にできません。 diff --git a/ServerPickerX/Locales/Locale_pl-pl.axaml b/ServerPickerX/Locales/Locale_pl-pl.axaml index 84b0b97..edf2f13 100644 --- a/ServerPickerX/Locales/Locale_pl-pl.axaml +++ b/ServerPickerX/Locales/Locale_pl-pl.axaml @@ -5,24 +5,46 @@ Serwery Uncluster Odświeżać Wyszukaj serwery... + Brak presetów Zablokuj wszystko Blok wybrany Odblokuj wszystko Odblokuj wybrane + Zapisz + Anuluj Sprawdź nową wersję podczas uruchamiania Zresetuj zaporę sieciową Wybierz Tryb gry + Wybierz preset Grupowanie lub rozgrupowywanie serwerów Odśwież ping wszystkich serwerów Ta czynność odblokuje najpierw wszystkie serwery, aby zapobiec konfliktom z zaporą sieciową. Wybierz język Zresetuj reguły zapory sieciowej informacje - Valve właśnie zaktualizowało dane serwera! Wszystkie zablokowane serwery zostaną odblokowane, aby zsynchronizować nowe dane serwera. + Valve właśnie zaktualizowało dane serwerów. Wszystkie zablokowane serwery zostaną odblokowane, aby zsynchronizować nowe dane serwera. Wpisy presetów dla serwerów, które już nie istnieją, zostaną usunięte, a ostatnio wybrany preset zostanie zastosowany ponownie, jeśli jest dostępny. Nowa wersja dostępna! Przejdź do wydań? Hej! Wybierz co najmniej jeden serwer do zablokowania Hej! Wybierz co najmniej jeden serwer do odblokowania Ojej! Jest już operacja w toku. Proszę czekać... Spowoduje to próbę przywrócenia zapory do stanu domyślnego. Czy potwierdzić działanie? Zapora sieciowa została pomyślnie zresetowana! + Presety + Zarządzaj presetami + Dodaj + Nowy preset + Utwórz nowy preset + Usuń + Usuń wybrane presety + Zastosuj + Zastosuj wybrany preset + Presety + Zablokowane serwery + Zablokowany + Preset + Usunąć preset '{0}'? + Usunąć {0} wybranych presetów? + Preset o nazwie '{0}' już istnieje dla tej gry. Nadpisać? + Przełączenie między widokiem grupowanym i niegrupowanym wyczyści zablokowane wpisy tego presetu. Kontynuować? + Nazwa presetu nie może być pusta. diff --git a/ServerPickerX/Locales/Locale_ru-ru.axaml b/ServerPickerX/Locales/Locale_ru-ru.axaml index 46c46e6..4a2349c 100644 --- a/ServerPickerX/Locales/Locale_ru-ru.axaml +++ b/ServerPickerX/Locales/Locale_ru-ru.axaml @@ -5,24 +5,46 @@ Разгруппировать серверы Обновить Поиск серверов... + Нет пресетов Блокировать все Блокировать выбранные Разблокировать все Разблокировать выбранные + Сохранить + Отмена Проверять новые версии при запуске Сбросить брандмауэр Выберите режим игры + Выбрать пресет Группировать или разгруппировать серверы Обновить пинг всех серверов Это действие сначала разблокирует все сервера для предотвращения конфликтов брандмауэра. Выберите язык Сбросить правила брандмауэра информация - Valve обновила данные серверов! Все заблокированные серверы будут разблокированы для синхронизации новых данных. + Valve обновила данные серверов. Все заблокированные серверы будут разблокированы для синхронизации новых данных. Записи пресета для серверов, которые больше не существуют, будут удалены, а последний выбранный пресет будет применен снова, если он доступен. Доступна новая версия! Перейти к релизам? Пожалуйста, выберите хотя бы один сервер для блокировки. Пожалуйста, выберите хотя бы один сервер для разблокировки. Ого! Уже есть ожидающая операция. Пожалуйста, подождите... Это попытается сбросить брандмауэр до состояния по умолчанию. Подтвердите действие? Межсетевой экран успешно перезагружен и вернулся в состояние по умолчанию! + Пресеты + Управление пресетами + Добавить + Новый пресет + Создать новый пресет + Удалить + Удалить выбранные пресеты + Применить + Применить выбранный пресет + Пресеты + Заблокированные серверы + Блок + Пресет + Удалить пресет '{0}'? + Удалить {0} выбранных пресетов? + Пресет с именем '{0}' уже существует для этой игры. Перезаписать его? + Переключение между сгруппированным и несгруппированным видом очистит заблокированные записи этого пресета. Продолжить? + Имя пресета не может быть пустым. diff --git a/ServerPickerX/Locales/Locale_sv-se.axaml b/ServerPickerX/Locales/Locale_sv-se.axaml index 3f625da..aee60b9 100644 --- a/ServerPickerX/Locales/Locale_sv-se.axaml +++ b/ServerPickerX/Locales/Locale_sv-se.axaml @@ -5,24 +5,46 @@ Avgruppera servrar Uppdatera Sök efter servrar... + Inga förinställningar Blockera alla Blockera valda Avblockera alla Avblockera valda + Spara + Avbryt Kontrollera efter ny version vid start Återställ brandvägg Välj spelläge + Välj förinställning Gruppera eller avgruppera servrar Uppdatera ping för alla servrar Detta åtgärder kommer att avblockera alla servrar först för att undvika brandvägskonflikter. Välj språk Återställ brandväggsregler info - Serverdata har precis uppdaterats av Valve! Alla blockerade servrar kommer att avblockeras för att synkronisera ny serverdata. + Serverdata har just uppdaterats av Valve. Alla blockerade servrar kommer att avblockeras för att synkronisera ny serverdata. Förinställningsposter för servrar som inte längre finns kommer att tas bort, och den senast valda förinställningen kommer att tillämpas igen om den fortfarande finns. Ny version tillgänglig! Gå till utgåvor? Välj minst en server att blockera Välj minst en server att avblockera. Oj! Det finns redan en väntande åtgärd. Vänta... Detta kommer att försöka återställa brandväggen till standardläget. Bekräfta åtgärd? Brandväggen har återställts! + Förinställningar + Hantera förinställningar + Lägg till + Ny förinställning + Skapa en ny förinställning + Ta bort + Ta bort markerade förinställningar + Verkställ + Verkställ vald förinställning + Förinställningar + Blockerade servrar + Blockerad + Förinställning + Ta bort förinställningen '{0}'? + Ta bort de {0} valda förinställningarna? + En förinställning med namnet '{0}' finns redan för det här spelet. Skriva över den? + Att växla mellan grupperad och ogrupperad vy kommer att rensa det här förinställets blockerade poster. Fortsätta? + Namnet på förinställningen får inte vara tomt. diff --git a/ServerPickerX/Locales/Locale_tr-tr.axaml b/ServerPickerX/Locales/Locale_tr-tr.axaml index ae65a1e..e5962ce 100644 --- a/ServerPickerX/Locales/Locale_tr-tr.axaml +++ b/ServerPickerX/Locales/Locale_tr-tr.axaml @@ -5,24 +5,46 @@ Sunucu kümelemeyi kaldır Yenile Sunucu ara... + Ön ayar yok Tümünü engelle Seçili olanları engelle Tüm engellemeleri kaldır Seçili engellemeleri kaldır + Kaydet + İptal Başlangıçta yeni versiyonları denetle Güvenlik duvarını sıfırla Oyunu seç + Ön ayar seç Sunucuları grupla veya gruplamayı kaldır Tüm sunucu gecikmelerini yenile Bu işlem, güvenlik duvarı çakışmalarını önlemek amacıyla önce tüm sunucuların engellemelerini kaldıracak. Dil seç Güvenlik duvarı kurallarını sıfırla Bilgi - Sunucu verileri Valve tarafından güncellendi! Yeni sunucu verilerini senkronize etmek için tüm engellenen sunucuların engelleri kaldırılacak + Sunucu verileri Valve tarafından güncellendi! Yeni sunucu verilerini senkronize etmek için tüm engellenen sunucuların engelleri kaldırılacak. Artık mevcut olmayan sunuculara ait preset girdileri kaldırılacak ve varsa son seçilen preset yeniden uygulanacak. Yeni sürüm yayınlandı! Güncelle? Lütfen engellenecek en az bir sunucu seç Lütfen engeli kaldırılacak en az bir sunucu seçin Zaten bekleyen bir işlem var. Lütfen bekleyin... Bu işlem güvenlik duvarını sıfırlayacak. Onaylıyor musun? Güvenlik duvarı başarıyla sıfırlandı! - \ No newline at end of file + Ön Ayarlar + Ön ayarları yönet + Ekle + Yeni ön ayar + Yeni bir ön ayar oluştur + Sil + Seçili ön ayarları sil + Uygula + Seçili ön ayarı uygula + Ön Ayarlar + Engellenen sunucular + Engelli + Ön Ayar + '{0}' ön ayarı silinsin mi? + Seçili {0} ön ayar silinsin mi? + Bu oyun için '{0}' adında bir ön ayar zaten var. Üzerine yazılsın mı? + Kümelenmiş ve kümelenmemiş görünüm arasında geçiş yapmak bu ön ayarın engellenen girdilerini temizleyecek. Devam edilsin mi? + Ön ayar adı boş olamaz. + diff --git a/ServerPickerX/Locales/Locale_zh-cn.axaml b/ServerPickerX/Locales/Locale_zh-cn.axaml index 897e933..1a35430 100644 --- a/ServerPickerX/Locales/Locale_zh-cn.axaml +++ b/ServerPickerX/Locales/Locale_zh-cn.axaml @@ -5,24 +5,46 @@ 解组服务器 刷新 搜索服务器... + 无预设 全部阻止 块选定 全部解锁 取消阻止所选内容 + 保存 + 取消 启动时检查新版本 重置防火墙 选择游戏模式 + 选择预设 分组或取消分组服务器 刷新所有服务器 ping 此操作将首先解除所有服务器的阻止,以防止防火墙冲突。 选择语言 重置防火墙规则 信息 - Valve刚刚更新了服务器数据!所有被屏蔽的服务器都将被解除屏蔽,以便同步新的服务器数据。 + Valve 刚刚更新了服务器数据。所有被屏蔽的服务器都将被解除屏蔽,以便同步新的服务器数据。对于已不存在的服务器,其预设条目将被移除,并且如果可用,最后选中的预设将被重新应用。 新版本已发布!前往发布页面? 嘿!请至少选择一个服务器进行屏蔽。 嘿!请至少选择一个服务器进行解锁。 目前有待处理的操作。请稍候…… 此操作将尝试把防火墙重置为默认状态。确认操作? 防火墙已成功重置! + 预设 + 管理预设 + 添加 + 新预设 + 创建新预设 + 删除 + 删除所选预设 + 应用 + 应用所选预设 + 预设 + 已屏蔽服务器 + 已屏蔽 + 预设 + 要删除预设“{0}”吗? + 要删除这 {0} 个选中的预设吗? + 此游戏已存在名为“{0}”的预设。要覆盖吗? + 在集群视图和非集群视图之间切换将清除此预设中已阻止的条目。是否继续? + 预设名称不能为空。 diff --git a/ServerPickerX/Models/PresetModel.cs b/ServerPickerX/Models/PresetModel.cs new file mode 100644 index 0000000..4b952b1 --- /dev/null +++ b/ServerPickerX/Models/PresetModel.cs @@ -0,0 +1,32 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using System; +using System.Collections.Generic; + +namespace ServerPickerX.Models +{ + public class PresetModel : ObservableObject + { + public string Name { get; set; } = string.Empty; + + public string GameMode { get; set; } = string.Empty; + + public bool IsClustered { get; set; } + + public List BlockedServerKeys { get; set; } = []; + + public override bool Equals(object? obj) + { + return obj is PresetModel other && + GameMode.Equals(other.GameMode, StringComparison.OrdinalIgnoreCase) && + Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase); + } + + public override int GetHashCode() + { + return HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(GameMode), + StringComparer.OrdinalIgnoreCase.GetHashCode(Name) + ); + } + } +} diff --git a/ServerPickerX/Models/PresetServerModel.cs b/ServerPickerX/Models/PresetServerModel.cs new file mode 100644 index 0000000..0f6139b --- /dev/null +++ b/ServerPickerX/Models/PresetServerModel.cs @@ -0,0 +1,67 @@ +using Avalonia.Platform; +using CommunityToolkit.Mvvm.ComponentModel; +using System; +using System.Collections.Generic; +using System.Security.Cryptography; + +namespace ServerPickerX.Models +{ + public partial class PresetServerModel : ObservableObject + { + private static readonly Dictionary FlagSortKeyCache = new(StringComparer.OrdinalIgnoreCase); + + public ServerModel ServerModel { get; } + + public string Key { get; } + + public string Flag => ServerModel.Flag; + + public string FlagSortKey { get; } + + public string Name => ServerModel.Name; + + public string Description => ServerModel.Description; + + [ObservableProperty] + private bool isBlocked; + + public PresetServerModel(ServerModel serverModel, string key, bool isBlocked) + { + ServerModel = serverModel; + Key = key; + IsBlocked = isBlocked; + FlagSortKey = GetFlagSortKey(serverModel.Flag); + } + + private static string GetFlagSortKey(string flagPath) + { + if (string.IsNullOrWhiteSpace(flagPath)) + { + return string.Empty; + } + + if (FlagSortKeyCache.TryGetValue(flagPath, out string? cachedValue)) + { + return cachedValue; + } + + string assetUri = $"avares://ServerPickerX{flagPath}"; + + try + { + using var stream = AssetLoader.Open(new Uri(assetUri)); + byte[] hash = SHA256.HashData(stream); + string flagSortKey = Convert.ToHexString(hash); + FlagSortKeyCache[flagPath] = flagSortKey; + + return flagSortKey; + } + catch + { + FlagSortKeyCache[flagPath] = flagPath; + + return flagPath; + } + } + } +} diff --git a/ServerPickerX/ServerPickerX.csproj b/ServerPickerX/ServerPickerX.csproj index 64d084c..51d84f3 100644 --- a/ServerPickerX/ServerPickerX.csproj +++ b/ServerPickerX/ServerPickerX.csproj @@ -22,8 +22,8 @@ https://github.com/FN-FAL113/server-picker-x https://github.com/FN-FAL113/server-picker-x Assets\favicon.ico - 1.0.4.0 - 1.0.4.0 + 1.0.5.0 + 1.0.5.0 @@ -51,5 +51,6 @@ + diff --git a/ServerPickerX/Services/Servers/CS2PerfectWorldServerDataService.cs b/ServerPickerX/Services/Servers/CS2PerfectWorldServerDataService.cs index aaed89d..b4a56b3 100644 --- a/ServerPickerX/Services/Servers/CS2PerfectWorldServerDataService.cs +++ b/ServerPickerX/Services/Servers/CS2PerfectWorldServerDataService.cs @@ -1,4 +1,3 @@ -using ServerPickerX.Settings; using ServerPickerX.Models; using System; using System.Collections.Generic; @@ -14,8 +13,7 @@ namespace ServerPickerX.Services.Servers public class CS2PerfectWorldServerDataService( ILoggerService _logger, IMessageBoxService _messageBoxService, - HttpClient _httpClient, - JsonSetting _jsonSettings + HttpClient _httpClient ) : IServerDataService { private ServerData _serverData = new(); @@ -47,14 +45,6 @@ public async Task LoadServersAsync() _serverData.Revision = revision; - // Update settings if app is initialized for the first time - if (_jsonSettings.cs2_server_revision == "-1") - { - _jsonSettings.cs2_server_revision = revision; - - await _jsonSettings.SaveSettingsAsync(); - } - ProcessServers(mainJson, _serverData); } catch (Exception ex) @@ -158,4 +148,4 @@ private List GetWhiteListedServerKeywords() return ["China"]; } } -} \ No newline at end of file +} diff --git a/ServerPickerX/Services/Servers/CS2ServerDataService.cs b/ServerPickerX/Services/Servers/CS2ServerDataService.cs index b3b928b..e29bc45 100644 --- a/ServerPickerX/Services/Servers/CS2ServerDataService.cs +++ b/ServerPickerX/Services/Servers/CS2ServerDataService.cs @@ -1,4 +1,3 @@ -using ServerPickerX.Settings; using ServerPickerX.Models; using System; using System.Collections.Generic; @@ -14,8 +13,7 @@ namespace ServerPickerX.Services.Servers public class CS2ServerDataService( ILoggerService _logger, IMessageBoxService _messageBoxService, - HttpClient _httpClient, - JsonSetting _jsonSettings + HttpClient _httpClient ) : IServerDataService { private ServerData _serverData = new(); @@ -47,12 +45,6 @@ public async Task LoadServersAsync() _serverData.Revision = revision; - // Update settings if app is initialized for the first time - if (_jsonSettings.cs2_server_revision == "-1") - { - await _jsonSettings.SetRevisionByGameModeAsync(revision); - } - ProcessServers(mainJson, _serverData); } catch (Exception ex) @@ -155,4 +147,4 @@ private List GetExcludedServerKeywords() return ["China"]; } } -} \ No newline at end of file +} diff --git a/ServerPickerX/Services/Servers/DeadLockServerDataService.cs b/ServerPickerX/Services/Servers/DeadLockServerDataService.cs index f139ae7..d17beaf 100644 --- a/ServerPickerX/Services/Servers/DeadLockServerDataService.cs +++ b/ServerPickerX/Services/Servers/DeadLockServerDataService.cs @@ -1,4 +1,3 @@ -using ServerPickerX.Settings; using ServerPickerX.Models; using System; using System.Collections.Generic; @@ -14,8 +13,7 @@ namespace ServerPickerX.Services.Servers public class DeadLockServerDataService( ILoggerService _logger, IMessageBoxService _messageBoxService, - HttpClient _httpClient, - JsonSetting _jsonSettings + HttpClient _httpClient ) : IServerDataService { private ServerData _serverData = new(); @@ -47,12 +45,6 @@ public async Task LoadServersAsync() _serverData.Revision = revision; - // Update settings if app is initialized for the first time - if (_jsonSettings.deadlock_server_revision == "-1") - { - await _jsonSettings.SetRevisionByGameModeAsync(revision); - } - ProcessServers(mainJson, _serverData); } catch (Exception ex) @@ -142,4 +134,4 @@ public List GetClusterKeywords() }; } } -} \ No newline at end of file +} diff --git a/ServerPickerX/Services/Servers/MarathonServerDataService.cs b/ServerPickerX/Services/Servers/MarathonServerDataService.cs index b549a28..a8cb480 100644 --- a/ServerPickerX/Services/Servers/MarathonServerDataService.cs +++ b/ServerPickerX/Services/Servers/MarathonServerDataService.cs @@ -1,4 +1,3 @@ -using ServerPickerX.Settings; using ServerPickerX.Models; using System; using System.Collections.Generic; @@ -14,8 +13,7 @@ namespace ServerPickerX.Services.Servers public class MarathonServerDataService( ILoggerService _logger, IMessageBoxService _messageBoxService, - HttpClient _httpClient, - JsonSetting _jsonSettings + HttpClient _httpClient ) : IServerDataService { private ServerData _serverData = new(); @@ -47,12 +45,6 @@ public async Task LoadServersAsync() _serverData.Revision = revision; - // Update settings if app is initialized for the first time - if (_jsonSettings.marathon_server_revision == "-1") - { - await _jsonSettings.SetRevisionByGameModeAsync(revision); - } - ProcessServers(mainJson, _serverData); } catch (Exception ex) @@ -143,4 +135,4 @@ public List GetClusterKeywords() ]; } } -} \ No newline at end of file +} diff --git a/ServerPickerX/Services/Settings/JsonSetting.cs b/ServerPickerX/Services/Settings/JsonSetting.cs index 30e1264..589dc86 100644 --- a/ServerPickerX/Services/Settings/JsonSetting.cs +++ b/ServerPickerX/Services/Settings/JsonSetting.cs @@ -1,13 +1,15 @@ - using ServerPickerX.Constants; using ServerPickerX.Helpers; +using ServerPickerX.Models; using ServerPickerX.Services.DependencyInjection; using ServerPickerX.Services.Loggers; using ServerPickerX.Services.MessageBoxes; using ServerPickerX.Services.Settings; using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -41,6 +43,10 @@ public class JsonSetting : ISetting public virtual bool version_check_on_startup { get; set; } = true; + public virtual List server_presets { get; set; } = []; + + public virtual Dictionary last_selected_preset_names { get; set; } = new(StringComparer.OrdinalIgnoreCase); + [JsonIgnore] public readonly string jsonFilePath = "./settings.json"; @@ -96,6 +102,10 @@ public async Task LoadSettingsAsync() marathon_server_revision = localSettings.marathon_server_revision; is_clustered = localSettings.is_clustered; version_check_on_startup = localSettings.version_check_on_startup; + server_presets = localSettings.server_presets ?? []; + last_selected_preset_names = localSettings.last_selected_preset_names != null + ? new Dictionary(localSettings.last_selected_preset_names, StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); } catch (Exception ex) { @@ -192,5 +202,166 @@ public async Task SetLanguageAsync(string language) await this.SaveSettingsAsync(); } + + public string GetLastSelectedPresetNameByGameMode() + { + if (string.IsNullOrWhiteSpace(game_mode)) + { + return string.Empty; + } + + return last_selected_preset_names.TryGetValue(game_mode, out string? presetName) + ? presetName + : string.Empty; + } + + public async Task SetLastSelectedPresetNameByGameModeAsync(string presetName) + { + if (string.IsNullOrWhiteSpace(game_mode)) + { + return; + } + + last_selected_preset_names[game_mode] = presetName; + + await SaveSettingsAsync(); + } + + public async Task ClearLastSelectedPresetNameByGameModeAsync() + { + if (string.IsNullOrWhiteSpace(game_mode)) + { + return; + } + + last_selected_preset_names.Remove(game_mode); + + await SaveSettingsAsync(); + } + + public List GetPresetsByGameMode(string gameMode) + { + string normalizedGameMode = gameMode ?? string.Empty; + + return (server_presets ?? []) + .Where(preset => (preset.GameMode ?? string.Empty).Equals(normalizedGameMode, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + public PresetModel? GetPresetByGameMode(string gameMode, string presetName) + { + string normalizedGameMode = gameMode ?? string.Empty; + string normalizedPresetName = presetName ?? string.Empty; + + return (server_presets ?? []).FirstOrDefault(preset => + (preset.GameMode ?? string.Empty).Equals(normalizedGameMode, StringComparison.OrdinalIgnoreCase) && + (preset.Name ?? string.Empty).Equals(normalizedPresetName, StringComparison.OrdinalIgnoreCase)); + } + + public bool HasDuplicatePresetNameByCurrentGameMode(string presetName) + { + string normalizedPresetName = presetName ?? string.Empty; + + return (server_presets ?? []) + .Count(preset => + (preset.GameMode ?? string.Empty).Equals(this.game_mode, StringComparison.OrdinalIgnoreCase) && + (preset.Name ?? string.Empty).Equals(normalizedPresetName, StringComparison.OrdinalIgnoreCase) + ) > 1; + } + + public async Task AddOrUpdatePresetAsync(PresetModel preset) + { + server_presets ??= []; + + PresetModel? existingPreset = GetPresetByGameMode(preset.GameMode, preset.Name); + + List blockedServerKeys = preset.BlockedServerKeys + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(key => key, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (existingPreset == null) + { + server_presets.Add(new PresetModel + { + Name = preset.Name, + GameMode = preset.GameMode, + IsClustered = preset.IsClustered, + BlockedServerKeys = blockedServerKeys, + }); + } + else + { + existingPreset.IsClustered = preset.IsClustered; + existingPreset.BlockedServerKeys = blockedServerKeys; + } + + await SaveSettingsAsync(); + } + + public async Task RemovePresetAsync(string gameMode, string presetName) + { + server_presets ??= []; + server_presets.RemoveAll(preset => + preset.GameMode.Equals(gameMode, StringComparison.OrdinalIgnoreCase) && + preset.Name.Equals(presetName, StringComparison.OrdinalIgnoreCase)); + + await SaveSettingsAsync(); + } + + public async Task PrunePresetEntriesByGameModeAsync( + string gameMode, + HashSet clusteredServerKeys, + HashSet unclusteredServerKeys + ) + { + if (string.IsNullOrWhiteSpace(gameMode)) + { + return false; + } + + server_presets ??= []; + + bool presetsChanged = false; + + foreach (PresetModel preset in server_presets.Where(preset => + (preset.GameMode ?? string.Empty).Equals(gameMode, StringComparison.OrdinalIgnoreCase))) + { + HashSet validServerKeys = preset.IsClustered + ? clusteredServerKeys + : unclusteredServerKeys; + + List prunedServerKeys = (preset.BlockedServerKeys ?? []) + .Where(serverKey => !string.IsNullOrWhiteSpace(serverKey) && validServerKeys.Contains(serverKey)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(serverKey => serverKey, StringComparer.OrdinalIgnoreCase) + .ToList(); + + List currentServerKeys = (preset.BlockedServerKeys ?? []) + .Where(serverKey => !string.IsNullOrWhiteSpace(serverKey)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(serverKey => serverKey, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (currentServerKeys.SequenceEqual(prunedServerKeys, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + preset.BlockedServerKeys = prunedServerKeys; + presetsChanged = true; + } + + if (!presetsChanged) + { + return false; + } + + await SaveSettingsAsync(); + + return true; + } + + } } diff --git a/ServerPickerX/ViewModels/MainWindowViewModel.cs b/ServerPickerX/ViewModels/MainWindowViewModel.cs index 15704d7..d5f63d6 100644 --- a/ServerPickerX/ViewModels/MainWindowViewModel.cs +++ b/ServerPickerX/ViewModels/MainWindowViewModel.cs @@ -1,6 +1,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using MsBox.Avalonia.Enums; +using ServerPickerX.Constants; using ServerPickerX.Extensions; using ServerPickerX.Models; using ServerPickerX.Services.DependencyInjection; @@ -32,6 +33,8 @@ public partial class MainWindowViewModel : ViewModelBase s.Description.Contains(SearchText, StringComparison.OrdinalIgnoreCase) )); + public ObservableCollectionExtended PresetItems { get; set; } = []; + public ServerModel? SelectedDataGridServerModel { get; set; } // Mvvm tool kit will auto generate source code to make this property observable @@ -48,15 +51,26 @@ public partial class MainWindowViewModel : ViewModelBase [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsOperationAllowed))] + [NotifyPropertyChangedFor(nameof(CanSelectPresets))] public bool serverModelsInitialized = false; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsOperationAllowed))] + [NotifyPropertyChangedFor(nameof(CanSelectPresets))] public bool pendingOperation = false; + [ObservableProperty] + public PresetModel? selectedPreset; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanSelectPresets))] + public bool hasPresets = false; + // Dependent/Computed prop for main UI buttons `IsEnabled` state public bool IsOperationAllowed => !PendingOperation && ServerModelsInitialized; + public bool CanSelectPresets => IsOperationAllowed && HasPresets; + private readonly ILoggerService _loggerService; private readonly IMessageBoxService _messageBoxService; private readonly ILocalizationService _localizationService; @@ -99,7 +113,7 @@ public async Task LoadServersAsync() if (!ServersLoaded) return; - await ClusterUnclusterServersAsync(); + await SetClusterStateAsync(_jsonSetting.is_clustered, false); ServerModelsInitialized = true; } @@ -107,30 +121,143 @@ public async Task LoadServersAsync() [RelayCommand] public async Task ClusterUnclusterServersAsync() { - if (!ServersLoaded) return; + await SetClusterStateAsync(!_jsonSetting.is_clustered, true); + } + + public async Task SetClusterStateAsync(bool isClustered, bool shouldUnblockCurrentServers) + { + if (!ServersLoaded) + { + return; + } + + bool clusterStateChanged = _jsonSetting.is_clustered != isClustered; - // Update json settings and unblock all servers only after servers are initialized on first load - if (ServerModelsInitialized) + // After initial load, clear the full current view before switching representations + // so clustered/unclustered transitions do not carry stale rules forward + if (shouldUnblockCurrentServers && ServerModelsInitialized && ServerModels.Count > 0) { - _jsonSetting.is_clustered = !_jsonSetting.is_clustered; + bool unblocked = await PerformOperationAsync(false, ServerModels, false); + + if (!unblocked) + { + return; + } + } + + if (clusterStateChanged) + { + _jsonSetting.is_clustered = isClustered; await _jsonSetting.SaveSettingsAsync(); - await UnblockAllAsync(); + await MarkPresetSelectionDirtyAsync(); } ServerData serverData = _serverDataService.GetServerData(); - - List serverModels = _jsonSetting.is_clustered ? - serverData.ClusteredServers : serverData.UnclusteredServers; + List serverModels = _jsonSetting.is_clustered + ? serverData.ClusteredServers + : serverData.UnclusteredServers; ServerModels.Clear(); - ServerModels.AddRange(serverModels); PingServers(serverModels); } + public PresetModel? GetCurrentGamePreset(string presetName) + { + return _jsonSetting.GetPresetByGameMode(_jsonSetting.game_mode, presetName); + } + + public void LoadPresetPickerItems() + { + string? selectedPresetName = SelectedPreset?.Name; + List presetItems = _jsonSetting.GetPresetsByGameMode(_jsonSetting.game_mode); + + PresetItems.Clear(); + + if (presetItems.Count == 0) + { + HasPresets = false; + ClearSelectedPreset(); + return; + } + + HasPresets = true; + PresetItems.AddRange(presetItems); + + if (!string.IsNullOrWhiteSpace(selectedPresetName)) + { + SelectPresetByName(selectedPresetName); + return; + } + + ClearSelectedPreset(); + } + + public void SelectPresetByName(string presetName) + { + SelectedPreset = PresetItems.FirstOrDefault(preset => + preset.Name.Equals(presetName, StringComparison.OrdinalIgnoreCase)); + } + + public string GetCurrentGameMode() => _jsonSetting.game_mode; + + public IReadOnlyList GetCurrentGameServerModels(bool isClustered) + { + ServerData serverData = _serverDataService.GetServerData(); + + return isClustered + ? serverData.ClusteredServers + : serverData.UnclusteredServers; + } + + public async Task DeletePresetAsync(PresetModel preset) + { + string deletedPresetName = preset.Name; + + await _jsonSetting.RemovePresetAsync(_jsonSetting.game_mode, deletedPresetName); + + if (_jsonSetting.GetLastSelectedPresetNameByGameMode().Equals(deletedPresetName, StringComparison.OrdinalIgnoreCase)) + { + await _jsonSetting.ClearLastSelectedPresetNameByGameModeAsync(); + } + + LoadPresetPickerItems(); + + if (SelectedPreset?.Equals(preset) == true) + { + ClearSelectedPreset(); + } + } + + public async Task ApplyPresetAsync(PresetModel preset) + { + if (!ServersLoaded) + { + return false; + } + + if (!preset.GameMode.Equals(_jsonSetting.game_mode, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + bool presetApplied = await ApplyPresetWithResetAsync(preset); + + if (!presetApplied) + { + return false; + } + + SelectPresetByName(preset.Name); + + await _jsonSetting.SetLastSelectedPresetNameByGameModeAsync(preset.Name); + + return true; + } + [RelayCommand] public void PingServers(ICollection serverModels) { @@ -202,6 +329,15 @@ public async Task UnblockAllAsync() return await PerformOperationAsync(false, FilteredServerModels); } + public async Task UnblockCurrentGameServersAsync() + { + if (ServerModels.Count == 0) + { + return true; + } + + return await PerformOperationAsync(false, new ObservableCollection(ServerModels), false); + } [RelayCommand] public async Task UnblockSelectedAsync(IList selectedServers) @@ -221,7 +357,11 @@ await _messageBoxService.ShowMessageBoxAsync( return await PerformOperationAsync(false, serverModels); } - public async Task PerformOperationAsync(bool shouldBlock, ObservableCollection serverModels) + public async Task PerformOperationAsync( + bool shouldBlock, + ObservableCollection serverModels, + bool shouldUpdatePresetSelection = true + ) { if (PendingOperation) { @@ -254,8 +394,15 @@ await _messageBoxService.ShowMessageBoxAsync( await _loggerService.LogInfoAsync("Servers unblocked successfully"); } + if (shouldUpdatePresetSelection) + { + await MarkPresetSelectionDirtyAsync(); + } + // Ping servers (parallel operation) PingServers(serverModels); + + return true; } catch (Exception ex) { @@ -268,17 +415,176 @@ await _messageBoxService.ShowMessageBoxAsync( return false; } + finally + { + PendingOperation = false; + ShowProgressBar = false; + } + } + + public IServerDataService GetServerDataService() + { + return _serverDataService; + } + + public async Task PruneCurrentGamePresetEntriesAsync() + { + if (!ServersLoaded) + { + return false; + } - PendingOperation = false; - ShowProgressBar = false; + return await PrunePresetEntriesAsync(_jsonSetting.game_mode, _serverDataService.GetServerData()); + } + + public async Task PruneCounterStrikeFamilyPresetEntriesAsync() + { + // CS2 and Perfect World share one revision bucket, but their filtered server sets differ. + // When either mode syncs, prune the sibling mode too before marking the shared revision current. + if (_jsonSetting.game_mode == GameModes.CounterStrike2) + { + CS2PerfectWorldServerDataService perfectWorldServerDataService = + ServiceLocator.GetRequiredService(); + + if (!await perfectWorldServerDataService.LoadServersAsync()) + { + return false; + } + + await PrunePresetEntriesAsync( + GameModes.CounterStrike2PerfectWorld, + perfectWorldServerDataService.GetServerData() + ); + + return true; + } + + CS2ServerDataService counterStrikeServerDataService = + ServiceLocator.GetRequiredService(); + + if (!await counterStrikeServerDataService.LoadServersAsync()) + { + return false; + } + + await PrunePresetEntriesAsync( + GameModes.CounterStrike2, + counterStrikeServerDataService.GetServerData() + ); return true; } - public IServerDataService GetServerDataService() + public async Task PrunePresetEntriesAsync(string gameMode, ServerData serverData) { - return _serverDataService; + HashSet clusteredServerKeys = serverData.ClusteredServers + .Select(serverModel => serverModel.Description) + .Where(serverKey => !string.IsNullOrWhiteSpace(serverKey)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + HashSet unclusteredServerKeys = serverData.UnclusteredServers + .Select(serverModel => serverModel.Name) + .Where(serverKey => !string.IsNullOrWhiteSpace(serverKey)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + bool presetsPruned = await _jsonSetting.PrunePresetEntriesByGameModeAsync( + gameMode, + clusteredServerKeys, + unclusteredServerKeys + ); + + return presetsPruned; + } + + public string GetServerKey(ServerModel serverModel, bool isClustered) + { + return isClustered + ? serverModel.Description + : serverModel.Name; } + private string GetServerKey(ServerModel serverModel) + { + return GetServerKey(serverModel, _jsonSetting.is_clustered); + } + + public async Task RestoreLastSelectedPresetAsync() + { + if (!HasPresets) + { + await _jsonSetting.ClearLastSelectedPresetNameByGameModeAsync(); + + ClearSelectedPreset(); + return; + } + + string lastSelectedPresetName = _jsonSetting.GetLastSelectedPresetNameByGameMode(); + + if (string.IsNullOrWhiteSpace(lastSelectedPresetName)) + { + ClearSelectedPreset(); + return; + } + + PresetModel? lastSelectedPreset = _jsonSetting.GetPresetByGameMode(_jsonSetting.game_mode, lastSelectedPresetName); + + if (lastSelectedPreset == null) + { + await _jsonSetting.ClearLastSelectedPresetNameByGameModeAsync(); + ClearSelectedPreset(); + return; + } + + bool restored = await ApplyPresetAsync(lastSelectedPreset); + + if (!restored) + { + ClearSelectedPreset(); + } + } + + private async Task ApplyPresetWithResetAsync(PresetModel serverPreset) + { + if (ServerModels.Count > 0) + { + bool unblocked = await PerformOperationAsync(false, ServerModels, false); + + if (!unblocked) + { + return false; + } + } + + await SetClusterStateAsync(serverPreset.IsClustered, false); + + ObservableCollection matchingServerModels = GetMatchingServerModels(serverPreset); + + if (matchingServerModels.Count == 0) + { + return true; + } + + return await PerformOperationAsync(true, matchingServerModels, false); + } + + private ObservableCollection GetMatchingServerModels(PresetModel serverPreset) + { + return new ObservableCollection( + ServerModels.Where(serverModel => + serverPreset.BlockedServerKeys + .Contains(GetServerKey(serverModel), StringComparer.OrdinalIgnoreCase)) + ); + } + + private async Task MarkPresetSelectionDirtyAsync() + { + await _jsonSetting.ClearLastSelectedPresetNameByGameModeAsync(); + + ClearSelectedPreset(); + } + + private void ClearSelectedPreset() + { + SelectedPreset = null; + } } } diff --git a/ServerPickerX/ViewModels/PresetManagerWindowViewModel.cs b/ServerPickerX/ViewModels/PresetManagerWindowViewModel.cs new file mode 100644 index 0000000..1e61874 --- /dev/null +++ b/ServerPickerX/ViewModels/PresetManagerWindowViewModel.cs @@ -0,0 +1,478 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using MsBox.Avalonia.Enums; +using ServerPickerX.Comparers; +using ServerPickerX.Extensions; +using ServerPickerX.Models; +using ServerPickerX.Services.DependencyInjection; +using ServerPickerX.Services.Localizations; +using ServerPickerX.Services.MessageBoxes; +using ServerPickerX.Settings; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; + +namespace ServerPickerX.ViewModels +{ + public partial class PresetManagerWindowViewModel : ViewModelBase + { + [ObservableProperty] + private ObservableCollectionExtended presets = []; + + public ObservableCollectionExtended PresetServers { get; } = []; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanDelete))] + [NotifyPropertyChangedFor(nameof(CanApply))] + [NotifyPropertyChangedFor(nameof(CanEditPreset))] + [NotifyPropertyChangedFor(nameof(CanToggleClusterMode))] + private PresetModel? selectedPresetItem; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanDelete))] + private bool isDeletingPreset; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ClusterToggleText))] + private bool editorIsClustered; + + private readonly MainWindowViewModel _mainVm; + private readonly JsonSetting _jsonSetting; + private readonly IMessageBoxService _messageBoxService; + private readonly ILocalizationService _localizationService; + + public string WindowTitle => + $"{_localizationService.GetLocaleValue("PresetsWindowTitle")} - {_mainVm.GetCurrentGameMode()}"; + + public bool CanDelete => SelectedPresetItem != null && !IsDeletingPreset; + + public bool CanApply => SelectedPresetItem != null; + + public bool CanEditPreset => SelectedPresetItem != null; + + public bool CanToggleClusterMode => SelectedPresetItem != null; + + public string ClusterToggleText => _localizationService.GetLocaleValue( + EditorIsClustered ? "UnclusterServers" : "ClusterServers"); + + public PresetManagerWindowViewModel( + MainWindowViewModel mainVm, + JsonSetting jsonSetting, + IMessageBoxService messageBoxService, + ILocalizationService localizationService + ) + { + _mainVm = mainVm; + _jsonSetting = jsonSetting; + _messageBoxService = messageBoxService; + _localizationService = localizationService; + EditorIsClustered = false; + + ReloadPresets(_mainVm.SelectedPreset?.Name); + } + + partial void OnSelectedPresetItemChanged(PresetModel? oldValue, PresetModel? newValue) + { + if (newValue != null) + { + EditorIsClustered = newValue.IsClustered; + } + else + { + EditorIsClustered = false; + } + + LoadServerItemsForSelectedPreset(); + } + + public async Task AddPresetAsync() + { + string presetName = GetNextPresetName(); + PresetModel newPreset = new() + { + Name = presetName, + GameMode = _mainVm.GetCurrentGameMode(), + IsClustered = false, + BlockedServerKeys = [], + }; + + await _jsonSetting.AddOrUpdatePresetAsync(ClonePreset(newPreset)); + + ReloadPresets(presetName); + } + + public async Task DeletePresetsAsync(IReadOnlyList presetsToDelete) + { + if (presetsToDelete == null || presetsToDelete.Count == 0 || IsDeletingPreset) + { + return false; + } + + List normalizedPresets = presetsToDelete + .Where(preset => preset != null) + .Distinct() + .ToList(); + + if (normalizedPresets.Count == 0) + { + return false; + } + + IsDeletingPreset = true; + + try + { + string confirmationText = normalizedPresets.Count == 1 + ? string.Format( + _localizationService.GetLocaleValue("PresetDeleteConfirmDialogue"), + normalizedPresets[0].Name + ) + : string.Format( + _localizationService.GetLocaleValue("PresetDeleteSelectedConfirmDialogue"), + normalizedPresets.Count + ); + + bool shouldDelete = await _messageBoxService.ShowMessageBoxConfirmationAsync( + _localizationService.GetLocaleValue("MessageBoxInfoTitle"), + confirmationText, + Icon.Setting + ); + + if (!shouldDelete) + { + return false; + } + + List currentPresets = GetCurrentGamePresets(); + HashSet deletedPresetNames = normalizedPresets + .Select(preset => preset.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + string? preferredPresetName = currentPresets + .FirstOrDefault(preset => !deletedPresetNames.Contains(preset.Name)) + ?.Name; + + foreach (PresetModel preset in normalizedPresets) + { + await _mainVm.DeletePresetAsync(preset); + } + + ReloadPresets(preferredPresetName); + + return true; + } + finally + { + IsDeletingPreset = false; + } + } + + public async Task ApplySelectedPresetAsync() + { + if (SelectedPresetItem == null) + { + return false; + } + + // The modal can add/delete presets without touching the main dropdown state. + // Refresh the main preset list before applying so the selected preset can be reselected there too. + _mainVm.LoadPresetPickerItems(); + + return await _mainVm.ApplyPresetAsync(ClonePreset(SelectedPresetItem)); + } + + public async Task RenamePresetAsync(PresetModel preset, string originalPresetName) + { + if (preset == null) + { + return false; + } + + string newPresetName = (preset.Name ?? string.Empty).Trim(); + string currentPresetName = originalPresetName.Trim(); + + if (string.IsNullOrWhiteSpace(newPresetName)) + { + await _messageBoxService.ShowMessageBoxAsync( + _localizationService.GetLocaleValue("MessageBoxInfoTitle"), + _localizationService.GetLocaleValue("PresetNameRequiredDialogue") + ); + + ReloadPresets(currentPresetName); + return false; + } + + if (newPresetName.Equals(currentPresetName, StringComparison.OrdinalIgnoreCase)) + { + ReloadPresets(currentPresetName); + return true; + } + + bool hasDuplicatePresetName = _jsonSetting.HasDuplicatePresetNameByCurrentGameMode(newPresetName); + bool overwroteDifferentPreset = false; + + if (hasDuplicatePresetName) + { + overwroteDifferentPreset = await _messageBoxService.ShowMessageBoxConfirmationAsync( + _localizationService.GetLocaleValue("MessageBoxInfoTitle"), + string.Format( + _localizationService.GetLocaleValue("PresetOverwriteConfirmDialogue"), + newPresetName + ), + Icon.Setting + ); + + if (!overwroteDifferentPreset) + { + // Revert back prename and reload presets + preset.Name = currentPresetName; + ReloadPresets(currentPresetName); + return false; + } + } + + await _jsonSetting.RemovePresetAsync(_mainVm.GetCurrentGameMode(), newPresetName); + await _jsonSetting.AddOrUpdatePresetAsync(preset); + await SyncPresetReferenceAfterRenameAsync(currentPresetName, newPresetName, overwroteDifferentPreset); + + ReloadPresets(newPresetName); + + return true; + } + + public async Task PersistSelectedPresetServerKeysAsync() + { + if (SelectedPresetItem == null) + { + return; + } + + SelectedPresetItem.BlockedServerKeys = PresetServers + .Where(serverItem => serverItem.IsBlocked) + .Select(serverItem => serverItem.Key) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(serverKey => serverKey, StringComparer.OrdinalIgnoreCase) + .ToList(); + + await _jsonSetting.AddOrUpdatePresetAsync(ClonePreset(SelectedPresetItem)); + await ClearAppliedPresetReferenceIfNeededAsync(SelectedPresetItem.Name); + } + + public async Task ToggleSelectedPresetClusterModeAsync() + { + if (SelectedPresetItem == null) + { + return; + } + + bool hasBlockedEntries = (SelectedPresetItem.BlockedServerKeys?.Count ?? 0) > 0; + + if (hasBlockedEntries) + { + bool shouldChangeMode = await _messageBoxService.ShowMessageBoxConfirmationAsync( + _localizationService.GetLocaleValue("MessageBoxInfoTitle"), + _localizationService.GetLocaleValue("PresetChangeViewModeConfirmDialogue"), + Icon.Setting + ); + + if (!shouldChangeMode) + { + return; + } + } + + SelectedPresetItem.IsClustered = !SelectedPresetItem.IsClustered; + SelectedPresetItem.BlockedServerKeys = []; + EditorIsClustered = SelectedPresetItem.IsClustered; + + await _jsonSetting.AddOrUpdatePresetAsync(ClonePreset(SelectedPresetItem)); + await ClearAppliedPresetReferenceIfNeededAsync(SelectedPresetItem.Name); + + LoadServerItemsForSelectedPreset(); + } + + // Use NaturalStringComparer to sort preset names. + // This is to avoid issues with numbers in preset names being treated as part of the number sequence. + // For example, "Preset 1" should come before "Preset 10" in ascending order, not after. + public void SortPresets(ListSortDirection direction) + { + string? selectedPresetName = SelectedPresetItem?.Name; + List sortedPresets = (direction == ListSortDirection.Ascending + ? Presets.OrderBy(preset => preset.Name, NaturalStringComparer.OrdinalIgnoreCase) + : Presets.OrderByDescending(preset => preset.Name, NaturalStringComparer.OrdinalIgnoreCase)) + .ToList(); + + Presets.Clear(); + Presets.AddRange(sortedPresets); + + if (!string.IsNullOrWhiteSpace(selectedPresetName)) + { + SelectedPresetItem = Presets.FirstOrDefault(preset => + preset.Name.Equals(selectedPresetName, StringComparison.OrdinalIgnoreCase)); + } + } + + public void SortServerItems(string sortKey, ListSortDirection direction) + { + List sortedItems = sortKey switch + { + "Blocked" => (direction == ListSortDirection.Ascending + ? PresetServers.OrderBy(serverItem => serverItem.IsBlocked) + : PresetServers.OrderByDescending(serverItem => serverItem.IsBlocked)).ToList(), + "Flag" => (direction == ListSortDirection.Ascending + ? PresetServers.OrderBy(serverItem => serverItem.FlagSortKey, StringComparer.OrdinalIgnoreCase) + .ThenBy(serverItem => serverItem.Description, StringComparer.OrdinalIgnoreCase) + : PresetServers.OrderByDescending(serverItem => serverItem.FlagSortKey, StringComparer.OrdinalIgnoreCase) + .ThenBy(serverItem => serverItem.Description, StringComparer.OrdinalIgnoreCase)).ToList(), + "ServerId" => (direction == ListSortDirection.Ascending + ? PresetServers.OrderBy(serverItem => serverItem.Name, StringComparer.OrdinalIgnoreCase) + : PresetServers.OrderByDescending(serverItem => serverItem.Name, StringComparer.OrdinalIgnoreCase)).ToList(), + _ => (direction == ListSortDirection.Ascending + ? PresetServers.OrderBy(serverItem => serverItem.Description, StringComparer.OrdinalIgnoreCase) + : PresetServers.OrderByDescending(serverItem => serverItem.Description, StringComparer.OrdinalIgnoreCase)).ToList(), + }; + + PresetServers.Clear(); + PresetServers.AddRange(sortedItems); + } + + private void ReloadPresets(string? selectedPresetName = null) + { + string? presetNameToSelect = selectedPresetName ?? SelectedPresetItem?.Name; + List currentPresets = GetCurrentGamePresets(); + + if (currentPresets.Count == 0) + { + Presets = []; + SelectedPresetItem = null; + PresetServers.Clear(); + return; + } + + Presets = new ObservableCollectionExtended(currentPresets); + + SelectedPresetItem = !string.IsNullOrWhiteSpace(presetNameToSelect) + ? Presets.FirstOrDefault(preset => + preset.Name.Equals(presetNameToSelect, StringComparison.OrdinalIgnoreCase)) + : null; + + SelectedPresetItem ??= Presets[0]; + } + + private void LoadServerItemsForSelectedPreset() + { + PresetServers.Clear(); + + if (SelectedPresetItem == null) + { + return; + } + + HashSet blockedServerKeys = (SelectedPresetItem.BlockedServerKeys ?? []) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (ServerModel serverModel in _mainVm.GetCurrentGameServerModels(SelectedPresetItem.IsClustered)) + { + string serverKey = _mainVm.GetServerKey(serverModel, SelectedPresetItem.IsClustered); + + PresetServers.Add(new PresetServerModel(serverModel, serverKey, blockedServerKeys.Contains(serverKey))); + } + } + + private List GetCurrentGamePresets() + { + return _jsonSetting.GetPresetsByGameMode(_mainVm.GetCurrentGameMode()); + } + + private async Task ClearAppliedPresetReferenceIfNeededAsync(string presetName) + { + bool matchesAppliedPreset = (_mainVm.SelectedPreset?.Name ?? string.Empty) + .Equals(presetName, StringComparison.OrdinalIgnoreCase); + bool matchesLastSelected = (_jsonSetting.GetLastSelectedPresetNameByGameMode() ?? string.Empty) + .Equals(presetName, StringComparison.OrdinalIgnoreCase); + + if (!matchesAppliedPreset && !matchesLastSelected) + { + return; + } + + if (matchesLastSelected) + { + await _jsonSetting.ClearLastSelectedPresetNameByGameModeAsync(); + } + + if (matchesAppliedPreset) + { + _mainVm.SelectPresetByName(string.Empty); + } + } + + private async Task SyncPresetReferenceAfterRenameAsync( + string originalPresetName, + string renamedPresetName, + bool overwroteDifferentPreset + ) + { + if (overwroteDifferentPreset) + { + await ClearAppliedPresetReferenceIfNeededAsync(renamedPresetName); + + return; + } + + bool renamedLastSelected = (_jsonSetting.GetLastSelectedPresetNameByGameMode() ?? string.Empty) + .Equals(originalPresetName, StringComparison.OrdinalIgnoreCase); + bool renamedAppliedPreset = (_mainVm.SelectedPreset?.Name ?? string.Empty) + .Equals(originalPresetName, StringComparison.OrdinalIgnoreCase); + + + if (renamedLastSelected) + { + await _jsonSetting.SetLastSelectedPresetNameByGameModeAsync(renamedPresetName); + } + + if (renamedAppliedPreset) + { + _mainVm.LoadPresetPickerItems(); + _mainVm.SelectPresetByName(renamedPresetName); + } + } + + private string GetNextPresetName() + { + string basePresetName = _localizationService.GetLocaleValue("NewPresetName"); + HashSet existingPresetNames = GetCurrentGamePresets() + .Select(preset => preset.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (!existingPresetNames.Contains(basePresetName)) + { + return basePresetName; + } + + for (int suffix = 2; ; suffix++) + { + string candidateName = $"{basePresetName} ({suffix})"; + + if (!existingPresetNames.Contains(candidateName)) + { + return candidateName; + } + } + } + + private static PresetModel ClonePreset(PresetModel preset) + { + return new PresetModel + { + Name = preset.Name, + GameMode = preset.GameMode, + IsClustered = preset.IsClustered, + BlockedServerKeys = (preset.BlockedServerKeys ?? []) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(serverKey => serverKey, StringComparer.OrdinalIgnoreCase) + .ToList(), + }; + } + } +} diff --git a/ServerPickerX/Views/MainWindow.axaml b/ServerPickerX/Views/MainWindow.axaml index 76bcde1..ccb08d9 100644 --- a/ServerPickerX/Views/MainWindow.axaml +++ b/ServerPickerX/Views/MainWindow.axaml @@ -10,8 +10,8 @@ mc:Ignorable="d" x:Class="ServerPickerX.Views.MainWindow" x:DataType="vm:MainWindowViewModel" - Width="850" - MinWidth="850" + Width="920" + MinWidth="920" MaxWidth="1280" Height="640" MinHeight="500" @@ -74,8 +74,14 @@ - - + + + + + + + + + + + + + + + + + + + + - +