From fbb4fcf1c17f73ad069a8c62684caf0ef38691f8 Mon Sep 17 00:00:00 2001 From: Jay Long Date: Thu, 29 Jan 2026 09:14:28 -0600 Subject: [PATCH 01/31] Place indicators into the Pen Activity field for Alert Details --- src/AvatarManagement/AvatarManagement.cs | 2 ++ src/Clients/Ollama/Ollama.cs | 3 +++ src/PlayerManagement/PlayerManagement.cs | 6 ++++++ 3 files changed, 11 insertions(+) diff --git a/src/AvatarManagement/AvatarManagement.cs b/src/AvatarManagement/AvatarManagement.cs index bea9128..4f249e6 100644 --- a/src/AvatarManagement/AvatarManagement.cs +++ b/src/AvatarManagement/AvatarManagement.cs @@ -6,6 +6,7 @@ using Tailgrab.Common; using Tailgrab.Config; using Tailgrab.Models; +using Tailgrab.PlayerManagement; using VRChat.API.Model; namespace Tailgrab.AvatarManagement @@ -261,6 +262,7 @@ internal bool CheckAvatarByName(string avatarName) { string? soundSetting = ConfigStore.LoadSecret(Common.Common.Registry_Alert_Avatar) ?? "Hand"; SoundManager.PlaySound(soundSetting); + return true; } diff --git a/src/Clients/Ollama/Ollama.cs b/src/Clients/Ollama/Ollama.cs index a713ccb..de7a3cf 100644 --- a/src/Clients/Ollama/Ollama.cs +++ b/src/Clients/Ollama/Ollama.cs @@ -159,6 +159,7 @@ private async static void GetUserGroupInformation(ServiceRegistry serviceRegistr { logger.Debug($"Processing User Group subscription for userId: {item.UserId}"); bool isSuspectGroup = false; + string? watchedGroups = string.Empty; foreach (LimitedUserGroups group in userGroups) { GroupInfo? groupInfo = dBContext.GroupInfos.Find(group.GroupId); @@ -183,6 +184,7 @@ private async static void GetUserGroupInformation(ServiceRegistry serviceRegistr if (groupInfo.IsBos) { + watchedGroups = string.Concat( watchedGroups, " " + groupInfo.GroupName ); isSuspectGroup = true; } } @@ -194,6 +196,7 @@ private async static void GetUserGroupInformation(ServiceRegistry serviceRegistr if (player != null) { player.IsGroupWatch = true; + player.PenActivity = watchedGroups; serviceRegistry.GetPlayerManager().OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, player); } diff --git a/src/PlayerManagement/PlayerManagement.cs b/src/PlayerManagement/PlayerManagement.cs index df6dd5b..a2c64bc 100644 --- a/src/PlayerManagement/PlayerManagement.cs +++ b/src/PlayerManagement/PlayerManagement.cs @@ -442,6 +442,12 @@ public void SetAvatarForPlayer(string displayName, string avatarName) { p.IsAvatarWatch = watchedAvatar; p.AvatarName = avatarName; + + if (watchedAvatar) + { + p.PenActivity = $"AV: {avatarName}"; + } + OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, p); } From b5f3145c58ff275082693387f0eb92b1f5135105 Mon Sep 17 00:00:00 2001 From: Jay Long Date: Thu, 29 Jan 2026 10:13:02 -0600 Subject: [PATCH 02/31] Tweaks to the Profile Evaluation and Prompt to make it more successful. --- src/Clients/Ollama/Ollama.cs | 16 ++++++++-------- src/Common/Common.cs | 2 +- tailgrab.csproj | 7 ++++--- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/Clients/Ollama/Ollama.cs b/src/Clients/Ollama/Ollama.cs index de7a3cf..1473111 100644 --- a/src/Clients/Ollama/Ollama.cs +++ b/src/Clients/Ollama/Ollama.cs @@ -134,7 +134,7 @@ public static async Task ProfileCheckTask(ConcurrentPriorityQueue } } - private static void GetEvaluationFromStore(ServiceRegistry serviceRegistry, ProfileEvaluation evaluated, QueuedProcess item) + private static void GetEvaluationFromStore(ServiceRegistry serviceRegistry, ProfileEvaluation evaluated, string? userId) { - if (item.UserId != null) + if (userId != null) { - Player? player = serviceRegistry.GetPlayerManager().GetPlayerByUserId(item.UserId ?? string.Empty); + Player? player = serviceRegistry.GetPlayerManager().GetPlayerByUserId(userId ?? string.Empty); if (player != null) { player.AIEval = System.Text.Encoding.UTF8.GetString(evaluated.Evaluation); @@ -266,11 +266,11 @@ private static void GetEvaluationFromStore(ServiceRegistry serviceRegistry, Prof player.IsProfileWatch = true; } serviceRegistry.GetPlayerManager().OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, player); - logger.Debug($"User profile already processed for userId: {item.UserId}"); + logger.Debug($"User profile already processed for userId: {userId}"); } else { - logger.Debug($"User profile lookup fails for userId: {item.UserId}"); + logger.Debug($"User profile lookup fails for userId: {userId}"); } } diff --git a/src/Common/Common.cs b/src/Common/Common.cs index 2ffc066..08f4126 100644 --- a/src/Common/Common.cs +++ b/src/Common/Common.cs @@ -16,7 +16,7 @@ public static class Common public const string Registry_Ollama_API_Endpoint = "OLLAMA_API_ENDPOINT"; public const string Registry_Ollama_API_Prompt = "OLLAMA_API_PROMPT"; public const string Registry_Ollama_API_Model = "OLLAMA_API_Model"; - public const string Default_Ollama_API_Prompt = "From the following block of text, classify the contents into a single class;\n'OK', 'Explicit Sexual', 'Harrassment & Bullying', 'Self Harm' or 'Other'.\nWhen replying, give a single line for the Classification and then a new line for the resoning: \n"; + public const string Default_Ollama_API_Prompt = "From the following block of text, classify the contents into a single class from the following classes; 'OK', 'Explicit Sexual', 'Harassment & Bullying', 'Self Harm'. If there is not enough information to determine the class, use a default of OK. When replying, return a single line for the Classification and a carriage return, then place the reasoning on subsequent lines: \n"; public const string Default_Ollama_API_Endpoint = "https://ollama.com"; public const string Default_Ollama_API_Model = "gemma3:27b"; diff --git a/tailgrab.csproj b/tailgrab.csproj index 84f4115..5189e51 100644 --- a/tailgrab.csproj +++ b/tailgrab.csproj @@ -7,9 +7,9 @@ true true enable - 1.0.8.1475 - 1.0.8.1475 - 1.0.8.1475 + 1.0.9.1501 + 1.0.9.1501 + 1.0.9.1501 src\Resources\tailgrab_large.ico @@ -70,6 +70,7 @@ + From 6010af296e5056ca6c01f5852d595d31142a08d2 Mon Sep 17 00:00:00 2001 From: Jay Long Date: Thu, 29 Jan 2026 14:31:16 -0600 Subject: [PATCH 03/31] Refactor to move the Paged Collections to seperate classes --- src/PlayerManagement/AvatarCollection.cs | 161 +++++++ src/PlayerManagement/GroupCollection.cs | 156 +++++++ src/PlayerManagement/TailgrabPannel.xaml.cs | 476 +------------------- src/PlayerManagement/UserCollection.cs | 152 +++++++ src/Program.cs | 144 +++--- tailgrab.csproj | 10 +- 6 files changed, 563 insertions(+), 536 deletions(-) create mode 100644 src/PlayerManagement/AvatarCollection.cs create mode 100644 src/PlayerManagement/GroupCollection.cs create mode 100644 src/PlayerManagement/UserCollection.cs diff --git a/src/PlayerManagement/AvatarCollection.cs b/src/PlayerManagement/AvatarCollection.cs new file mode 100644 index 0000000..46fca86 --- /dev/null +++ b/src/PlayerManagement/AvatarCollection.cs @@ -0,0 +1,161 @@ +using System.Collections.Specialized; +using System.ComponentModel; + +namespace Tailgrab.PlayerManagement +{ + // Lightweight virtualizing collection for Avatar DB. It only fetches items on demand + // and holds a small cache to limit memory usage. It queries the EF DB context for + // counts and pages of avatars ordered by AvatarName. + public class AvatarVirtualizingCollection : System.Collections.IList, System.Collections.IEnumerable, System.Collections.Specialized.INotifyCollectionChanged + { + private readonly ServiceRegistry _services; + private readonly int _pageSize = 100; + private readonly Dictionary> _pages = new Dictionary>(); + private int _count = -1; + + public AvatarVirtualizingCollection(ServiceRegistry services) + { + _services = services; + } + + public void Refresh() + { + _pages.Clear(); + _count = -1; + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + + private void EnsureCount() + { + if (_count >= 0) return; + try + { + var db = _services.GetDBContext(); + _count = db.AvatarInfos.Count(); + } + catch + { + _count = 0; + } + } + + private AvatarInfoViewModel? LoadAtIndex(int index) + { + if (index < 0) return null; + EnsureCount(); + if (index >= _count) return null; + var page = index / _pageSize; + if (!_pages.TryGetValue(page, out var list)) + { + // load this page + try + { + var db = _services.GetDBContext(); + var skip = page * _pageSize; + var items = db.AvatarInfos.OrderBy(a => a.AvatarName).Skip(skip).Take(_pageSize).ToList(); + list = items.Select(a => new AvatarInfoViewModel(a)).ToList(); + _pages[page] = list; + // Keep only a couple pages in memory (current, prev, next) + var keep = new HashSet { page, page - 1, page + 1 }; + var keys = _pages.Keys.ToList(); + foreach (var k in keys) + { + if (!keep.Contains(k)) _pages.Remove(k); + } + } + catch + { + list = new List(); + } + } + var idxInPage = index % _pageSize; + if (idxInPage < list.Count) return list[idxInPage]; + return null; + } + + // IList implementation (read-only for UI) + public int Add(object? value) => throw new NotSupportedException(); + public void Clear() => throw new NotSupportedException(); + public bool Contains(object? value) + { + EnsureCount(); + if (value is AvatarInfoViewModel vm) return this.Cast().Any(x => x.AvatarId == vm.AvatarId); + return false; + } + public int IndexOf(object? value) => -1; + public void Insert(int index, object? value) => throw new NotSupportedException(); + public void Remove(object? value) => throw new NotSupportedException(); + public void RemoveAt(int index) => throw new NotSupportedException(); + public bool IsReadOnly => true; + public bool IsFixedSize => false; + public object? this[int index] + { + get { return LoadAtIndex(index); } + set => throw new NotSupportedException(); + } + + public void CopyTo(Array array, int index) + { + EnsureCount(); + for (int i = 0; i < _count; i++) array.SetValue(LoadAtIndex(i), index + i); + } + + public int Count + { + get { EnsureCount(); return _count; } + } + + public bool IsSynchronized => false; + public object SyncRoot => this; + public System.Collections.IEnumerator GetEnumerator() + { + EnsureCount(); + for (int i = 0; i < _count; i++) yield return LoadAtIndex(i)!; + } + + // Collection changed event for WPF to react to resets + public event NotifyCollectionChangedEventHandler? CollectionChanged; + } + + public class AvatarInfoViewModel : INotifyPropertyChanged + { + public string AvatarId { get; set; } + public string AvatarName { get; set; } + private bool _isBos; + public bool IsBos + { + get => _isBos; + set + { + if (_isBos != value) + { + _isBos = value; + IsBosText = BoolToYesNo(_isBos); + OnPropertyChanged(nameof(IsBos)); + OnPropertyChanged(nameof(IsBosText)); + } + } + } + + public string IsBosText { get; set; } + public DateTime? UpdatedAt { get; set; } + + public AvatarInfoViewModel(Tailgrab.Models.AvatarInfo a) + { + AvatarId = a.AvatarId; + AvatarName = a.AvatarName; + IsBos = a.IsBos; + UpdatedAt = a.UpdatedAt; + IsBosText = BoolToYesNo(IsBos); + } + + // Convert boolean to YES/NO string for display + public static string BoolToYesNo(bool value) => value ? "YES" : "NO"; + + public event PropertyChangedEventHandler? PropertyChanged; + protected void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/PlayerManagement/GroupCollection.cs b/src/PlayerManagement/GroupCollection.cs new file mode 100644 index 0000000..c967d04 --- /dev/null +++ b/src/PlayerManagement/GroupCollection.cs @@ -0,0 +1,156 @@ +using System.Collections.Specialized; +using System.ComponentModel; + +namespace Tailgrab.PlayerManagement +{ + // Virtualizing collection for Groups similar to AvatarVirtualizingCollection + public class GroupVirtualizingCollection : System.Collections.IList, System.Collections.IEnumerable, System.Collections.Specialized.INotifyCollectionChanged + { + private readonly ServiceRegistry _services; + private readonly int _pageSize = 100; + private readonly Dictionary> _pages = new Dictionary>(); + private int _count = -1; + + public GroupVirtualizingCollection(ServiceRegistry services) + { + _services = services; + } + + public void Refresh() + { + _pages.Clear(); + _count = -1; + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + + private void EnsureCount() + { + if (_count >= 0) return; + try + { + var db = _services.GetDBContext(); + _count = db.GroupInfos.Count(); + } + catch + { + _count = 0; + } + } + + private GroupInfoViewModel? LoadAtIndex(int index) + { + if (index < 0) return null; + EnsureCount(); + if (index >= _count) return null; + var page = index / _pageSize; + if (!_pages.TryGetValue(page, out var list)) + { + try + { + var db = _services.GetDBContext(); + var skip = page * _pageSize; + var items = db.GroupInfos.OrderBy(a => a.GroupName).Skip(skip).Take(_pageSize).ToList(); + list = items.Select(a => new GroupInfoViewModel(a)).ToList(); + _pages[page] = list; + var keep = new HashSet { page, page - 1, page + 1 }; + var keys = _pages.Keys.ToList(); + foreach (var k in keys) + { + if (!keep.Contains(k)) _pages.Remove(k); + } + } + catch + { + list = new List(); + } + } + var idxInPage = index % _pageSize; + if (idxInPage < list.Count) return list[idxInPage]; + return null; + } + + // IList implementation (read-only) + public int Add(object? value) => throw new NotSupportedException(); + public void Clear() => throw new NotSupportedException(); + public bool Contains(object? value) + { + EnsureCount(); + if (value is GroupInfoViewModel vm) return this.Cast().Any(x => x.GroupId == vm.GroupId); + return false; + } + public int IndexOf(object? value) => -1; + public void Insert(int index, object? value) => throw new NotSupportedException(); + public void Remove(object? value) => throw new NotSupportedException(); + public void RemoveAt(int index) => throw new NotSupportedException(); + public bool IsReadOnly => true; + public bool IsFixedSize => false; + public object? this[int index] + { + get { return LoadAtIndex(index); } + set => throw new NotSupportedException(); + } + + public void CopyTo(Array array, int index) + { + EnsureCount(); + for (int i = 0; i < _count; i++) array.SetValue(LoadAtIndex(i), index + i); + } + + public int Count + { + get { EnsureCount(); return _count; } + } + + public bool IsSynchronized => false; + public object SyncRoot => this; + public System.Collections.IEnumerator GetEnumerator() + { + EnsureCount(); + for (int i = 0; i < _count; i++) yield return LoadAtIndex(i)!; + } + + public event NotifyCollectionChangedEventHandler? CollectionChanged; + } + + public class GroupInfoViewModel : INotifyPropertyChanged + { + public string GroupId { get; set; } + public string GroupName { get; set; } + private bool _isBos; + public bool IsBos + { + get => _isBos; + set + { + if (_isBos != value) + { + _isBos = value; + IsBosText = BoolToYesNo(_isBos); + OnPropertyChanged(nameof(IsBos)); + OnPropertyChanged(nameof(IsBosText)); + } + } + } + + public string IsBosText { get; set; } + public DateTime? UpdatedAt { get; set; } + + public GroupInfoViewModel(Tailgrab.Models.GroupInfo a) + { + GroupId = a.GroupId; + GroupName = a.GroupName; + IsBos = a.IsBos; + UpdatedAt = a.UpdatedAt; + IsBosText = BoolToYesNo(IsBos); + } + + // Convert boolean to YES/NO string for display + public static string BoolToYesNo(bool value) => value ? "YES" : "NO"; + + public event PropertyChangedEventHandler? PropertyChanged; + protected void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/PlayerManagement/TailgrabPannel.xaml.cs b/src/PlayerManagement/TailgrabPannel.xaml.cs index 3eb7e11..63ad4db 100644 --- a/src/PlayerManagement/TailgrabPannel.xaml.cs +++ b/src/PlayerManagement/TailgrabPannel.xaml.cs @@ -79,9 +79,7 @@ public TailgrabPannel(ServiceRegistry serviceRegistry) new KeyValuePair("NO", false) }; - // Initial load of avatars - RefreshAvatarDb(); - + #region Secret Config Load // Load saved secrets into UI fields if desired (not displayed in this view directly) var vrUser = ConfigStore.LoadSecret(Tailgrab.Common.Common.Registry_VRChat_Web_UserName); var vrPass = ConfigStore.LoadSecret(Tailgrab.Common.Common.Registry_VRChat_Web_Password); @@ -100,10 +98,6 @@ public TailgrabPannel(ServiceRegistry serviceRegistry) if (!string.IsNullOrEmpty(ollamaModel)) VrOllamaModelBox.Text = ollamaModel; if (!string.IsNullOrEmpty(ollamaPrompt)) VrOllamaPromptBox.Text = ollamaPrompt; - // Initial load of Groups and Users - RefreshGroupDb(); - RefreshUserDb(); - // Populate sound combo boxes try { @@ -122,6 +116,12 @@ public TailgrabPannel(ServiceRegistry serviceRegistry) if (!string.IsNullOrEmpty(profile)) ProfileAlertCombo.SelectedItem = profile; } catch { } + #endregion + + // Initial load of Avatars, Groups and Users + RefreshAvatarDb(); + RefreshGroupDb(); + RefreshUserDb(); // Subscribe to PlayerManager events for reactive updates PlayerManager.PlayerChanged += PlayerManager_PlayerChanged; @@ -161,45 +161,6 @@ protected void OnPropertyChanged(string propertyName) } } - public class UserInfoViewModel : INotifyPropertyChanged - { - public string UserId { get; set; } - public string DisplayName { get; set; } - public double ElapsedMinutes { get; set; } - private int _isBos; - public int IsBos - { - get => _isBos; - set - { - if (_isBos != value) - { - _isBos = value; - OnPropertyChanged(nameof(IsBos)); - } - } - } - - - public DateTime UpdatedAt { get; set; } - - public UserInfoViewModel(Tailgrab.Models.UserInfo u) - { - UserId = u.UserId; - DisplayName = u.DisplayName; - ElapsedMinutes = u.ElapsedMinutes; - IsBos = u.IsBos; - UpdatedAt = u.UpdatedAt; - } - - public event PropertyChangedEventHandler? PropertyChanged; - - protected void OnPropertyChanged(string propertyName) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - } - private void UserHyperlink_RequestNavigate(object? sender, System.Windows.Navigation.RequestNavigateEventArgs e) { try @@ -278,338 +239,6 @@ private void UserDbGrid_CellEditEnding(object sender, System.Windows.Controls.Da } } - // Virtualizing collection for Users - public class UserVirtualizingCollection : System.Collections.IList, System.Collections.IEnumerable, System.Collections.Specialized.INotifyCollectionChanged - { - private readonly ServiceRegistry _services; - private readonly int _pageSize = 100; - private readonly Dictionary> _pages = new Dictionary>(); - private int _count = -1; - - public UserVirtualizingCollection(ServiceRegistry services) - { - _services = services; - } - - public void Refresh() - { - _pages.Clear(); - _count = -1; - CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); - } - - private void EnsureCount() - { - if (_count >= 0) return; - try - { - var db = _services.GetDBContext(); - _count = db.UserInfos.Count(); - } - catch - { - _count = 0; - } - } - - private UserInfoViewModel? LoadAtIndex(int index) - { - if (index < 0) return null; - EnsureCount(); - if (index >= _count) return null; - var page = index / _pageSize; - if (!_pages.TryGetValue(page, out var list)) - { - try - { - var db = _services.GetDBContext(); - var skip = page * _pageSize; - var items = db.UserInfos.OrderBy(a => a.DisplayName).Skip(skip).Take(_pageSize).ToList(); - list = items.Select(a => new UserInfoViewModel(a)).ToList(); - _pages[page] = list; - var keep = new HashSet { page, page - 1, page + 1 }; - var keys = _pages.Keys.ToList(); - foreach (var k in keys) - { - if (!keep.Contains(k)) _pages.Remove(k); - } - } - catch - { - list = new List(); - } - } - var idxInPage = index % _pageSize; - if (idxInPage < list.Count) return list[idxInPage]; - return null; - } - - // IList implementation (read-only) - public int Add(object? value) => throw new NotSupportedException(); - public void Clear() => throw new NotSupportedException(); - public bool Contains(object? value) - { - EnsureCount(); - if (value is UserInfoViewModel vm) return this.Cast().Any(x => x.UserId == vm.UserId); - return false; - } - public int IndexOf(object? value) => -1; - public void Insert(int index, object? value) => throw new NotSupportedException(); - public void Remove(object? value) => throw new NotSupportedException(); - public void RemoveAt(int index) => throw new NotSupportedException(); - public bool IsReadOnly => true; - public bool IsFixedSize => false; - public object? this[int index] - { - get { return LoadAtIndex(index); } - set => throw new NotSupportedException(); - } - - public void CopyTo(Array array, int index) - { - EnsureCount(); - for (int i = 0; i < _count; i++) array.SetValue(LoadAtIndex(i), index + i); - } - - public int Count - { - get { EnsureCount(); return _count; } - } - - public bool IsSynchronized => false; - public object SyncRoot => this; - public System.Collections.IEnumerator GetEnumerator() - { - EnsureCount(); - for (int i = 0; i < _count; i++) yield return LoadAtIndex(i)!; - } - - public event NotifyCollectionChangedEventHandler? CollectionChanged; - } - - // Virtualizing collection for Groups similar to AvatarVirtualizingCollection - public class GroupVirtualizingCollection : System.Collections.IList, System.Collections.IEnumerable, System.Collections.Specialized.INotifyCollectionChanged - { - private readonly ServiceRegistry _services; - private readonly int _pageSize = 100; - private readonly Dictionary> _pages = new Dictionary>(); - private int _count = -1; - - public GroupVirtualizingCollection(ServiceRegistry services) - { - _services = services; - } - - public void Refresh() - { - _pages.Clear(); - _count = -1; - CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); - } - - private void EnsureCount() - { - if (_count >= 0) return; - try - { - var db = _services.GetDBContext(); - _count = db.GroupInfos.Count(); - } - catch - { - _count = 0; - } - } - - private GroupInfoViewModel? LoadAtIndex(int index) - { - if (index < 0) return null; - EnsureCount(); - if (index >= _count) return null; - var page = index / _pageSize; - if (!_pages.TryGetValue(page, out var list)) - { - try - { - var db = _services.GetDBContext(); - var skip = page * _pageSize; - var items = db.GroupInfos.OrderBy(a => a.GroupName).Skip(skip).Take(_pageSize).ToList(); - list = items.Select(a => new GroupInfoViewModel(a)).ToList(); - _pages[page] = list; - var keep = new HashSet { page, page - 1, page + 1 }; - var keys = _pages.Keys.ToList(); - foreach (var k in keys) - { - if (!keep.Contains(k)) _pages.Remove(k); - } - } - catch - { - list = new List(); - } - } - var idxInPage = index % _pageSize; - if (idxInPage < list.Count) return list[idxInPage]; - return null; - } - - // IList implementation (read-only) - public int Add(object? value) => throw new NotSupportedException(); - public void Clear() => throw new NotSupportedException(); - public bool Contains(object? value) - { - EnsureCount(); - if (value is GroupInfoViewModel vm) return this.Cast().Any(x => x.GroupId == vm.GroupId); - return false; - } - public int IndexOf(object? value) => -1; - public void Insert(int index, object? value) => throw new NotSupportedException(); - public void Remove(object? value) => throw new NotSupportedException(); - public void RemoveAt(int index) => throw new NotSupportedException(); - public bool IsReadOnly => true; - public bool IsFixedSize => false; - public object? this[int index] - { - get { return LoadAtIndex(index); } - set => throw new NotSupportedException(); - } - - public void CopyTo(Array array, int index) - { - EnsureCount(); - for (int i = 0; i < _count; i++) array.SetValue(LoadAtIndex(i), index + i); - } - - public int Count - { - get { EnsureCount(); return _count; } - } - - public bool IsSynchronized => false; - public object SyncRoot => this; - public System.Collections.IEnumerator GetEnumerator() - { - EnsureCount(); - for (int i = 0; i < _count; i++) yield return LoadAtIndex(i)!; - } - - public event NotifyCollectionChangedEventHandler? CollectionChanged; - } - - // Lightweight virtualizing collection for Avatar DB. It only fetches items on demand - // and holds a small cache to limit memory usage. It queries the EF DB context for - // counts and pages of avatars ordered by AvatarName. - public class AvatarVirtualizingCollection : System.Collections.IList, System.Collections.IEnumerable, System.Collections.Specialized.INotifyCollectionChanged - { - private readonly ServiceRegistry _services; - private readonly int _pageSize = 100; - private readonly Dictionary> _pages = new Dictionary>(); - private int _count = -1; - - public AvatarVirtualizingCollection(ServiceRegistry services) - { - _services = services; - } - - public void Refresh() - { - _pages.Clear(); - _count = -1; - CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); - } - - private void EnsureCount() - { - if (_count >= 0) return; - try - { - var db = _services.GetDBContext(); - _count = db.AvatarInfos.Count(); - } - catch - { - _count = 0; - } - } - - private AvatarInfoViewModel? LoadAtIndex(int index) - { - if (index < 0) return null; - EnsureCount(); - if (index >= _count) return null; - var page = index / _pageSize; - if (!_pages.TryGetValue(page, out var list)) - { - // load this page - try - { - var db = _services.GetDBContext(); - var skip = page * _pageSize; - var items = db.AvatarInfos.OrderBy(a => a.AvatarName).Skip(skip).Take(_pageSize).ToList(); - list = items.Select(a => new AvatarInfoViewModel(a)).ToList(); - _pages[page] = list; - // Keep only a couple pages in memory (current, prev, next) - var keep = new HashSet { page, page - 1, page + 1 }; - var keys = _pages.Keys.ToList(); - foreach (var k in keys) - { - if (!keep.Contains(k)) _pages.Remove(k); - } - } - catch - { - list = new List(); - } - } - var idxInPage = index % _pageSize; - if (idxInPage < list.Count) return list[idxInPage]; - return null; - } - - // IList implementation (read-only for UI) - public int Add(object? value) => throw new NotSupportedException(); - public void Clear() => throw new NotSupportedException(); - public bool Contains(object? value) - { - EnsureCount(); - if (value is AvatarInfoViewModel vm) return this.Cast().Any(x => x.AvatarId == vm.AvatarId); - return false; - } - public int IndexOf(object? value) => -1; - public void Insert(int index, object? value) => throw new NotSupportedException(); - public void Remove(object? value) => throw new NotSupportedException(); - public void RemoveAt(int index) => throw new NotSupportedException(); - public bool IsReadOnly => true; - public bool IsFixedSize => false; - public object? this[int index] - { - get { return LoadAtIndex(index); } - set => throw new NotSupportedException(); - } - - public void CopyTo(Array array, int index) - { - EnsureCount(); - for (int i = 0; i < _count; i++) array.SetValue(LoadAtIndex(i), index + i); - } - - public int Count - { - get { EnsureCount(); return _count; } - } - - public bool IsSynchronized => false; - public object SyncRoot => this; - public System.Collections.IEnumerator GetEnumerator() - { - EnsureCount(); - for (int i = 0; i < _count; i++) yield return LoadAtIndex(i)!; - } - - // Collection changed event for WPF to react to resets - public event NotifyCollectionChangedEventHandler? CollectionChanged; - } - private void SaveConfig_Click(object sender, RoutedEventArgs e) { try @@ -745,7 +374,6 @@ private void AvatarDbGrid_CellEditEnding(object sender, System.Windows.Controls. catch { } } } - private void AvatarFetch_Click(object sender, RoutedEventArgs e) { string? id = AvatarIdBox.Text?.Trim(); @@ -1552,92 +1180,4 @@ public PrintInfoViewModel(VRChat.API.Model.Print p) PrintUrl = p.Files.Image; } } - - - public class AvatarInfoViewModel : INotifyPropertyChanged - { - public string AvatarId { get; set; } - public string AvatarName { get; set; } - private bool _isBos; - public bool IsBos - { - get => _isBos; - set - { - if (_isBos != value) - { - _isBos = value; - IsBosText = BoolToYesNo(_isBos); - OnPropertyChanged(nameof(IsBos)); - OnPropertyChanged(nameof(IsBosText)); - } - } - } - - public string IsBosText { get; set; } - public DateTime? UpdatedAt { get; set; } - - public AvatarInfoViewModel(Tailgrab.Models.AvatarInfo a) - { - AvatarId = a.AvatarId; - AvatarName = a.AvatarName; - IsBos = a.IsBos; - UpdatedAt = a.UpdatedAt; - IsBosText = BoolToYesNo(IsBos); - } - - // Convert boolean to YES/NO string for display - public static string BoolToYesNo(bool value) => value ? "YES" : "NO"; - - - public event PropertyChangedEventHandler? PropertyChanged; - protected void OnPropertyChanged(string propertyName) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - } - - - public class GroupInfoViewModel : INotifyPropertyChanged - { - public string GroupId { get; set; } - public string GroupName { get; set; } - private bool _isBos; - public bool IsBos - { - get => _isBos; - set - { - if (_isBos != value) - { - _isBos = value; - IsBosText = BoolToYesNo(_isBos); - OnPropertyChanged(nameof(IsBos)); - OnPropertyChanged(nameof(IsBosText)); - } - } - } - - public string IsBosText { get; set; } - public DateTime? UpdatedAt { get; set; } - - public GroupInfoViewModel(Tailgrab.Models.GroupInfo a) - { - GroupId = a.GroupId; - GroupName = a.GroupName; - IsBos = a.IsBos; - UpdatedAt = a.UpdatedAt; - IsBosText = BoolToYesNo(IsBos); - } - - // Convert boolean to YES/NO string for display - public static string BoolToYesNo(bool value) => value ? "YES" : "NO"; - - - public event PropertyChangedEventHandler? PropertyChanged; - protected void OnPropertyChanged(string propertyName) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - } -} +} \ No newline at end of file diff --git a/src/PlayerManagement/UserCollection.cs b/src/PlayerManagement/UserCollection.cs new file mode 100644 index 0000000..96e8a78 --- /dev/null +++ b/src/PlayerManagement/UserCollection.cs @@ -0,0 +1,152 @@ +using System.Collections.Specialized; +using System.ComponentModel; + +namespace Tailgrab.PlayerManagement +{ + // Virtualizing collection for Users + public class UserVirtualizingCollection : System.Collections.IList, System.Collections.IEnumerable, System.Collections.Specialized.INotifyCollectionChanged + { + private readonly ServiceRegistry _services; + private readonly int _pageSize = 100; + private readonly Dictionary> _pages = new Dictionary>(); + private int _count = -1; + + public UserVirtualizingCollection(ServiceRegistry services) + { + _services = services; + } + + public void Refresh() + { + _pages.Clear(); + _count = -1; + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + + private void EnsureCount() + { + if (_count >= 0) return; + try + { + var db = _services.GetDBContext(); + _count = db.UserInfos.Count(); + } + catch + { + _count = 0; + } + } + + private UserInfoViewModel? LoadAtIndex(int index) + { + if (index < 0) return null; + EnsureCount(); + if (index >= _count) return null; + var page = index / _pageSize; + if (!_pages.TryGetValue(page, out var list)) + { + try + { + var db = _services.GetDBContext(); + var skip = page * _pageSize; + var items = db.UserInfos.OrderBy(a => a.DisplayName).Skip(skip).Take(_pageSize).ToList(); + list = items.Select(a => new UserInfoViewModel(a)).ToList(); + _pages[page] = list; + var keep = new HashSet { page, page - 1, page + 1 }; + var keys = _pages.Keys.ToList(); + foreach (var k in keys) + { + if (!keep.Contains(k)) _pages.Remove(k); + } + } + catch + { + list = new List(); + } + } + var idxInPage = index % _pageSize; + if (idxInPage < list.Count) return list[idxInPage]; + return null; + } + + // IList implementation (read-only) + public int Add(object? value) => throw new NotSupportedException(); + public void Clear() => throw new NotSupportedException(); + public bool Contains(object? value) + { + EnsureCount(); + if (value is UserInfoViewModel vm) return this.Cast().Any(x => x.UserId == vm.UserId); + return false; + } + public int IndexOf(object? value) => -1; + public void Insert(int index, object? value) => throw new NotSupportedException(); + public void Remove(object? value) => throw new NotSupportedException(); + public void RemoveAt(int index) => throw new NotSupportedException(); + public bool IsReadOnly => true; + public bool IsFixedSize => false; + public object? this[int index] + { + get { return LoadAtIndex(index); } + set => throw new NotSupportedException(); + } + + public void CopyTo(Array array, int index) + { + EnsureCount(); + for (int i = 0; i < _count; i++) array.SetValue(LoadAtIndex(i), index + i); + } + + public int Count + { + get { EnsureCount(); return _count; } + } + + public bool IsSynchronized => false; + public object SyncRoot => this; + public System.Collections.IEnumerator GetEnumerator() + { + EnsureCount(); + for (int i = 0; i < _count; i++) yield return LoadAtIndex(i)!; + } + + public event NotifyCollectionChangedEventHandler? CollectionChanged; + } + + public class UserInfoViewModel : INotifyPropertyChanged + { + public string UserId { get; set; } + public string DisplayName { get; set; } + public double ElapsedMinutes { get; set; } + private int _isBos; + public int IsBos + { + get => _isBos; + set + { + if (_isBos != value) + { + _isBos = value; + OnPropertyChanged(nameof(IsBos)); + } + } + } + + public DateTime UpdatedAt { get; set; } + + public UserInfoViewModel(Tailgrab.Models.UserInfo u) + { + UserId = u.UserId; + DisplayName = u.DisplayName; + ElapsedMinutes = u.ElapsedMinutes; + IsBos = u.IsBos; + UpdatedAt = u.UpdatedAt; + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/Program.cs b/src/Program.cs index 88715b2..d6db207 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -1,15 +1,17 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Win32; using NLog; +using Polly; using System.IO; using System.Reflection; using System.Text; using System.Text.Json; using System.Windows; using System.Windows.Media; -using Microsoft.Win32; using Tailgrab; using Tailgrab.Configuration; using Tailgrab.LineHandler; +using Tailgrab.Models; using Tailgrab.PlayerManagement; public class FileTailer @@ -235,63 +237,43 @@ public static void Main(string[] args) if (clearRegistry) { - try - { - // Remove the Tailgrab subtree from HKCU\Software\DeviousFox - using var baseKey = Registry.CurrentUser.OpenSubKey("Software\\DeviousFox", writable: true); - if (baseKey != null) - { - try - { - baseKey.DeleteSubKeyTree("Tailgrab", false); - logger.Info("Application registry settings cleared from HKCU\\Software\\DeviousFox\\Tailgrab"); - } - catch (Exception ex) - { - logger.Warn(ex, "Failed to delete Tailgrab registry subtree"); - } - } - else - { - logger.Info("No registry settings found to clear."); - } - } - catch (Exception ex) - { - logger.Warn(ex, "Failed while attempting to clear registry settings"); - } + DeleteTailgrabRegistrySettings(); // Exit application after clearing settings return; } - // Ensure Resources/tailgrab.ico is present in the application folder. If missing, write a small embedded PNG as the icon file. - try - { - var dataDir = Path.Combine(AppContext.BaseDirectory, "data"); - Directory.CreateDirectory(dataDir); + CreateResourceDirectory(); - var resourcesDir = Path.Combine(AppContext.BaseDirectory, "Resources"); - Directory.CreateDirectory(resourcesDir); + _serviceRegistry = new ServiceRegistry(); + _serviceRegistry.StartAllServices(); - var iconPath = Path.Combine(resourcesDir, "tailgrab.ico"); - if (!File.Exists(iconPath)) - { - // A tiny 1x1 PNG (transparent). We'll write it to the .ico path so WPF can load it as an ImageSource. - var base64Png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII="; - var bytes = Convert.FromBase64String(base64Png); - File.WriteAllBytes(iconPath, bytes); - logger.Info($"Wrote placeholder icon to: {iconPath}"); - } - } - catch (Exception ex) + string filePath = GetLogsPath(args, explicitPath); + if (!Directory.Exists(filePath)) { - logger.Warn(ex, "Failed to ensure Resources/tailgrab.ico exists"); + logger.Info($"Missing VRChat log directory at '{filePath}'"); + return; } - _serviceRegistry = new ServiceRegistry(); - _serviceRegistry.StartAllServices(); + ConfigurationManager configurationManager = new ConfigurationManager(_serviceRegistry); + configurationManager.LoadLineHandlersFromConfig(HandlerList); + + // Start the watcher task on a background thread so it doesn't block the STA UI thread + logger.Info($"Starting file watcher and showing UI for: '{filePath}'"); + _ = Task.Run(() => WatchPath(filePath)); + + // Start the Amplitude Cache watcher task on a background thread + string ampPath = VRChatAmplitudePath + Path.DirectorySeparatorChar; + logger.Info($"Starting Amplitude Cache watcher for: '{ampPath}'"); + _ = Task.Run(() => WatchAmpCache(ampPath, _serviceRegistry)); + + BuildAppWindow(_serviceRegistry); + + // When the window closes, allow Main to complete. The watcher task will be abandoned; if desired add cancellation. + } + private static string GetLogsPath(string[] args, string? explicitPath) + { string filePath = explicitPath ?? (VRChatAppDataPath + Path.DirectorySeparatorChar); if (explicitPath == null) { @@ -309,27 +291,63 @@ public static void Main(string[] args) logger.Info($"Using explicit path from -l: '{filePath}'"); } - if (!Directory.Exists(filePath)) + return filePath; + } + + private static void DeleteTailgrabRegistrySettings() + { + try { - logger.Info($"Missing VRChat log directory at '{filePath}'"); - return; + // Remove the Tailgrab subtree from HKCU\Software\DeviousFox + using var baseKey = Registry.CurrentUser.OpenSubKey("Software\\DeviousFox", writable: true); + if (baseKey != null) + { + try + { + baseKey.DeleteSubKeyTree("Tailgrab", false); + logger.Info("Application registry settings cleared from HKCU\\Software\\DeviousFox\\Tailgrab"); + } + catch (Exception ex) + { + logger.Warn(ex, "Failed to delete Tailgrab registry subtree"); + } + } + else + { + logger.Info("No registry settings found to clear."); + } } + catch (Exception ex) + { + logger.Warn(ex, "Failed while attempting to clear registry settings"); + } + } - ConfigurationManager configurationManager = new ConfigurationManager(_serviceRegistry); - configurationManager.LoadLineHandlersFromConfig(HandlerList); - - // Start the watcher task on a background thread so it doesn't block the STA UI thread - logger.Info($"Starting file watcher and showing UI for: '{filePath}'"); - _ = Task.Run(() => WatchPath(filePath)); - - // Start the Amplitude Cache watcher task on a background thread - string ampPath = VRChatAmplitudePath + Path.DirectorySeparatorChar; - logger.Info($"Starting Amplitude Cache watcher for: '{ampPath}'"); - _ = Task.Run(() => WatchAmpCache(ampPath, _serviceRegistry)); + private static void CreateResourceDirectory() + { + // Ensure Resources/tailgrab.ico is present in the application folder. If missing, write a small embedded PNG as the icon file. + try + { + var dataDir = Path.Combine(AppContext.BaseDirectory, "data"); + Directory.CreateDirectory(dataDir); - BuildAppWindow(_serviceRegistry); + var resourcesDir = Path.Combine(AppContext.BaseDirectory, "Resources"); + Directory.CreateDirectory(resourcesDir); - // When the window closes, allow Main to complete. The watcher task will be abandoned; if desired add cancellation. + var iconPath = Path.Combine(resourcesDir, "tailgrab.ico"); + if (!File.Exists(iconPath)) + { + // A tiny 1x1 PNG (transparent). We'll write it to the .ico path so WPF can load it as an ImageSource. + var base64Png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII="; + var bytes = Convert.FromBase64String(base64Png); + File.WriteAllBytes(iconPath, bytes); + logger.Info($"Wrote placeholder icon to: {iconPath}"); + } + } + catch (Exception ex) + { + logger.Warn(ex, "Failed to ensure Resources/tailgrab.ico exists"); + } } private static void BuildAppWindow(ServiceRegistry serviceRegistryInstance) diff --git a/tailgrab.csproj b/tailgrab.csproj index 5189e51..ee98d87 100644 --- a/tailgrab.csproj +++ b/tailgrab.csproj @@ -57,17 +57,17 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + - + - + From e01f2516bb2cc1e69a7ea7925c81a7f7a59ba2de Mon Sep 17 00:00:00 2001 From: Jay Long Date: Thu, 29 Jan 2026 15:05:16 -0600 Subject: [PATCH 04/31] Building A EF Migrations --- Migrations/20260129210247_V1.0.9.Designer.cs | 126 +++++++++++++++++++ Migrations/20260129210247_V1.0.9.cs | 93 ++++++++++++++ Migrations/TailgrabDBContextModelSnapshot.cs | 123 ++++++++++++++++++ src/Models/TailgrabDBContext.cs | 13 ++ 4 files changed, 355 insertions(+) create mode 100644 Migrations/20260129210247_V1.0.9.Designer.cs create mode 100644 Migrations/20260129210247_V1.0.9.cs create mode 100644 Migrations/TailgrabDBContextModelSnapshot.cs diff --git a/Migrations/20260129210247_V1.0.9.Designer.cs b/Migrations/20260129210247_V1.0.9.Designer.cs new file mode 100644 index 0000000..0fe4ec7 --- /dev/null +++ b/Migrations/20260129210247_V1.0.9.Designer.cs @@ -0,0 +1,126 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Tailgrab.Models; + +#nullable disable + +namespace tailgrab.Migrations +{ + [DbContext(typeof(TailgrabDBContext))] + [Migration("20260129210247_V1.0.9")] + partial class V109 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("Tailgrab.Models.AvatarInfo", b => + { + b.Property("AvatarId") + .HasColumnType("TEXT"); + + b.Property("AvatarName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasColumnType("TEXT"); + + b.Property("IsBos") + .HasColumnType("INTEGER") + .HasColumnName("IsBOS"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("AvatarId"); + + b.ToTable("AvatarInfo", (string)null); + }); + + modelBuilder.Entity("Tailgrab.Models.GroupInfo", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("createDate"); + + b.Property("GroupName") + .HasColumnType("TEXT"); + + b.Property("IsBos") + .HasColumnType("INTEGER") + .HasColumnName("IsBOS"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updateDate"); + + b.HasKey("GroupId"); + + b.ToTable("GroupInfo", (string)null); + }); + + modelBuilder.Entity("Tailgrab.Models.ProfileEvaluation", b => + { + b.Property("Md5checksum") + .HasColumnType("TEXT") + .HasColumnName("MD5Checksum"); + + b.Property("Evaluation") + .HasColumnType("BLOB"); + + b.Property("LastDateTime") + .HasColumnType("TEXT"); + + b.Property("ProfileText") + .HasColumnType("BLOB"); + + b.HasKey("Md5checksum"); + + b.ToTable("ProfileEvaluation", (string)null); + }); + + modelBuilder.Entity("Tailgrab.Models.UserInfo", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("ElapsedMinutes") + .HasColumnType("REAL") + .HasColumnName("elapsedHours"); + + b.Property("IsBos") + .HasColumnType("INTEGER") + .HasColumnName("IsBOS"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("UserInfo", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260129210247_V1.0.9.cs b/Migrations/20260129210247_V1.0.9.cs new file mode 100644 index 0000000..ac91d06 --- /dev/null +++ b/Migrations/20260129210247_V1.0.9.cs @@ -0,0 +1,93 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace tailgrab.Migrations +{ + /// + public partial class V109 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AvatarInfo", + columns: table => new + { + AvatarId = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: true), + AvatarName = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: true), + IsBOS = table.Column(type: "INTEGER", nullable: false), + ImageUrl = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AvatarInfo", x => x.AvatarId); + }); + + migrationBuilder.CreateTable( + name: "GroupInfo", + columns: table => new + { + GroupId = table.Column(type: "TEXT", nullable: false), + GroupName = table.Column(type: "TEXT", nullable: true), + IsBOS = table.Column(type: "INTEGER", nullable: false), + createDate = table.Column(type: "TEXT", nullable: false), + updateDate = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_GroupInfo", x => x.GroupId); + }); + + migrationBuilder.CreateTable( + name: "ProfileEvaluation", + columns: table => new + { + MD5Checksum = table.Column(type: "TEXT", nullable: false), + ProfileText = table.Column(type: "BLOB", nullable: true), + Evaluation = table.Column(type: "BLOB", nullable: true), + LastDateTime = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ProfileEvaluation", x => x.MD5Checksum); + }); + + migrationBuilder.CreateTable( + name: "UserInfo", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + DisplayName = table.Column(type: "TEXT", nullable: true), + elapsedHours = table.Column(type: "REAL", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + IsBOS = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserInfo", x => x.UserId); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AvatarInfo"); + + migrationBuilder.DropTable( + name: "GroupInfo"); + + migrationBuilder.DropTable( + name: "ProfileEvaluation"); + + migrationBuilder.DropTable( + name: "UserInfo"); + } + } +} diff --git a/Migrations/TailgrabDBContextModelSnapshot.cs b/Migrations/TailgrabDBContextModelSnapshot.cs new file mode 100644 index 0000000..d6e96eb --- /dev/null +++ b/Migrations/TailgrabDBContextModelSnapshot.cs @@ -0,0 +1,123 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Tailgrab.Models; + +#nullable disable + +namespace tailgrab.Migrations +{ + [DbContext(typeof(TailgrabDBContext))] + partial class TailgrabDBContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("Tailgrab.Models.AvatarInfo", b => + { + b.Property("AvatarId") + .HasColumnType("TEXT"); + + b.Property("AvatarName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasColumnType("TEXT"); + + b.Property("IsBos") + .HasColumnType("INTEGER") + .HasColumnName("IsBOS"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("AvatarId"); + + b.ToTable("AvatarInfo", (string)null); + }); + + modelBuilder.Entity("Tailgrab.Models.GroupInfo", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("createDate"); + + b.Property("GroupName") + .HasColumnType("TEXT"); + + b.Property("IsBos") + .HasColumnType("INTEGER") + .HasColumnName("IsBOS"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updateDate"); + + b.HasKey("GroupId"); + + b.ToTable("GroupInfo", (string)null); + }); + + modelBuilder.Entity("Tailgrab.Models.ProfileEvaluation", b => + { + b.Property("Md5checksum") + .HasColumnType("TEXT") + .HasColumnName("MD5Checksum"); + + b.Property("Evaluation") + .HasColumnType("BLOB"); + + b.Property("LastDateTime") + .HasColumnType("TEXT"); + + b.Property("ProfileText") + .HasColumnType("BLOB"); + + b.HasKey("Md5checksum"); + + b.ToTable("ProfileEvaluation", (string)null); + }); + + modelBuilder.Entity("Tailgrab.Models.UserInfo", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("ElapsedMinutes") + .HasColumnType("REAL") + .HasColumnName("elapsedHours"); + + b.Property("IsBos") + .HasColumnType("INTEGER") + .HasColumnName("IsBOS"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId"); + + b.ToTable("UserInfo", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Models/TailgrabDBContext.cs b/src/Models/TailgrabDBContext.cs index 1b15339..39a0968 100644 --- a/src/Models/TailgrabDBContext.cs +++ b/src/Models/TailgrabDBContext.cs @@ -1,12 +1,25 @@ // This file has been auto generated by EF Core Power Tools. #nullable disable using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Options; using System; using System.Collections.Generic; namespace Tailgrab.Models; + +public class TailgrabContextFactory : IDesignTimeDbContextFactory +{ + public TailgrabDBContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlite("Data Source=./Resources/tailgrab-dev.sqlite"); + + return new TailgrabDBContext(optionsBuilder.Options); + } +} + public partial class TailgrabDBContext : DbContext { public TailgrabDBContext(DbContextOptions options) From 71735cdc975d477c134576621b99846cf4c412c8 Mon Sep 17 00:00:00 2001 From: Jay Long Date: Fri, 30 Jan 2026 06:45:46 -0600 Subject: [PATCH 05/31] Update for GIST Download of BOS Avatars/Groups --- src/AvatarManagement/AvatarManagement.cs | 2 +- src/Common/Common.cs | 7 + src/NLog.config | 3 +- src/PlayerManagement/PlayerManagement.cs | 51 ++++ src/PlayerManagement/TailgrabPannel.xaml | 27 +- src/PlayerManagement/TailgrabPannel.xaml.cs | 103 ++++--- src/Program.cs | 26 +- src/configuration/AvatarBosGistListManager.cs | 275 ++++++++++++++++++ src/configuration/GroupBosGistListManager.cs | 272 +++++++++++++++++ 9 files changed, 717 insertions(+), 49 deletions(-) create mode 100644 src/configuration/AvatarBosGistListManager.cs create mode 100644 src/configuration/GroupBosGistListManager.cs diff --git a/src/AvatarManagement/AvatarManagement.cs b/src/AvatarManagement/AvatarManagement.cs index 4f249e6..64b76ea 100644 --- a/src/AvatarManagement/AvatarManagement.cs +++ b/src/AvatarManagement/AvatarManagement.cs @@ -349,7 +349,7 @@ private static void CreateAvatarInfoForPrivate(TailgrabDBContext dBContext, stri } } - private static Avatar? FetchUpdateAvatarData(ServiceRegistry serviceRegistry, TailgrabDBContext dBContext, string AvatarId, AvatarInfo? dbAvatarInfo) + public static Avatar? FetchUpdateAvatarData(ServiceRegistry serviceRegistry, TailgrabDBContext dBContext, string AvatarId, AvatarInfo? dbAvatarInfo) { Avatar? avatarData = null; try diff --git a/src/Common/Common.cs b/src/Common/Common.cs index 08f4126..7dd1228 100644 --- a/src/Common/Common.cs +++ b/src/Common/Common.cs @@ -25,5 +25,12 @@ public static class Common public const string Registry_Alert_Group = "ALERT_GROUP_SOUND"; public const string Registry_Alert_Profile = "ALERT_PROFILE_SOUND"; + // Gist related registry keys + public const string Registry_Group_Checksum = "GIST_GROUP_LIST_CHECKSUM"; + public const string Registry_Group_Gist = "GIST_GROUP_LIST_URL"; + + // Avatar Gist related registry keys + public const string Registry_Avatar_Checksum = "GIST_AVATAR_LIST_CHECKSUM"; + public const string Registry_Avatar_Gist = "GIST_AVATAR_LIST_URL"; } } diff --git a/src/NLog.config b/src/NLog.config index 97c860e..b15b72e 100644 --- a/src/NLog.config +++ b/src/NLog.config @@ -12,7 +12,6 @@ - - + \ No newline at end of file diff --git a/src/PlayerManagement/PlayerManagement.cs b/src/PlayerManagement/PlayerManagement.cs index a2c64bc..8d05b3c 100644 --- a/src/PlayerManagement/PlayerManagement.cs +++ b/src/PlayerManagement/PlayerManagement.cs @@ -1,6 +1,8 @@ using Microsoft.EntityFrameworkCore; using NLog; using System.Text; +using System.Windows; +using Tailgrab.Clients.VRChat; using Tailgrab.Common; using Tailgrab.LineHandler; using Tailgrab.Models; @@ -507,5 +509,54 @@ internal void AddPrintData(string printId) } } } + + public GroupInfo? AddUpdateGroupFromVRC(string? groupId) + { + if (string.IsNullOrEmpty(groupId)) + return null; + + try + { + VRChatClient vrcClient = serviceRegistry.GetVRChatAPIClient(); + VRChat.API.Model.Group? group = vrcClient.getGroupById(groupId); + if (group != null) + { + TailgrabDBContext dbContext = serviceRegistry.GetDBContext(); + GroupInfo? existing = dbContext.GroupInfos.Find(group.Id); + if (existing == null) + { + GroupInfo newEntity = new GroupInfo + { + GroupId = group.Id, + GroupName = group.Name ?? string.Empty, + CreatedAt = group.CreatedAt, + UpdatedAt = DateTime.UtcNow, + IsBos = false + }; + + dbContext.GroupInfos.Add(newEntity); + dbContext.SaveChanges(); + return newEntity; + } + else + { + existing.GroupId = group.Id; + existing.GroupName = group.Name ?? string.Empty; + existing.CreatedAt = group.CreatedAt; + existing.UpdatedAt = DateTime.UtcNow; + dbContext.GroupInfos.Update(existing); + dbContext.SaveChanges(); + return existing; + } + + } + } + catch (Exception ex) + { + logger.Warn($"Failed to fetch Group: {ex.Message}"); + } + + return null; + } } } diff --git a/src/PlayerManagement/TailgrabPannel.xaml b/src/PlayerManagement/TailgrabPannel.xaml index 7243cfe..5f5e124 100644 --- a/src/PlayerManagement/TailgrabPannel.xaml +++ b/src/PlayerManagement/TailgrabPannel.xaml @@ -609,6 +609,7 @@ + @@ -635,7 +636,26 @@ - + + + + + + + + + + + + + + + @@ -646,6 +666,7 @@ + - + @@ -731,7 +752,7 @@ - +