diff --git a/ChatTwo/Configuration.cs b/ChatTwo/Configuration.cs index e1f7d59..67901e7 100755 --- a/ChatTwo/Configuration.cs +++ b/ChatTwo/Configuration.cs @@ -54,6 +54,21 @@ public class Configuration : IPluginConfiguration return PrivacyPersistUnknownChannels; } + // Hellion Chat — Message retention (GDPR data minimization, time axis). + // Master switch defaults to false; the plugin will not delete history + // until the user explicitly opts in. + public bool RetentionEnabled; + public int RetentionDefaultDays = 30; + public Dictionary RetentionPerChannelDays = []; + public DateTimeOffset RetentionLastRunAt = DateTimeOffset.MinValue; + + public int GetRetentionDays(ChatType type) + { + if (RetentionPerChannelDays.TryGetValue(type, out var userOverride)) + return userOverride; + return RetentionDefaultDays; + } + public bool HideChat = true; public bool HideDuringCutscenes = true; public bool HideWhenNotLoggedIn = true; @@ -216,6 +231,11 @@ public class Configuration : IPluginConfiguration PrivacyFilterEnabled = other.PrivacyFilterEnabled; PrivacyPersistChannels = [..other.PrivacyPersistChannels]; PrivacyPersistUnknownChannels = other.PrivacyPersistUnknownChannels; + + RetentionEnabled = other.RetentionEnabled; + RetentionDefaultDays = other.RetentionDefaultDays; + RetentionPerChannelDays = other.RetentionPerChannelDays.ToDictionary(p => p.Key, p => p.Value); + RetentionLastRunAt = other.RetentionLastRunAt; } } diff --git a/ChatTwo/MessageStore.cs b/ChatTwo/MessageStore.cs index b44998b..cbbb811 100644 --- a/ChatTwo/MessageStore.cs +++ b/ChatTwo/MessageStore.cs @@ -331,6 +331,56 @@ internal class MessageStore : IDisposable return result; } + /// + /// Deletes messages older than the per-channel retention window, with a + /// global default for channels not listed explicitly. Cutoffs are + /// computed from "now" at call time. Runs VACUUM only if anything was + /// removed. Returns the number of rows deleted. + /// + internal long DeleteByRetentionPolicy(IReadOnlyDictionary chatTypeDaysMap, int defaultDays) + { + if (defaultDays < 0) + throw new ArgumentOutOfRangeException(nameof(defaultDays), "Negative retention is not allowed."); + foreach (var (_, days) in chatTypeDaysMap) + if (days < 0) + throw new ArgumentOutOfRangeException(nameof(chatTypeDaysMap), "Negative retention is not allowed."); + + var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var clauses = new List(); + foreach (var (type, days) in chatTypeDaysMap) + { + var cutoff = nowMs - days * 86400000L; + clauses.Add($"(ChatType = {type} AND Date < {cutoff})"); + } + + // Catch-all for channels without an explicit override. "0" is treated + // as "do not delete by default" — without an explicit user override, + // unmapped channels stay forever instead of getting wiped immediately. + if (defaultDays > 0) + { + var cutoff = nowMs - defaultDays * 86400000L; + var explicitTypes = chatTypeDaysMap.Count > 0 + ? string.Join(",", chatTypeDaysMap.Keys) + : "-1"; // empty list would produce invalid SQL + clauses.Add($"(ChatType NOT IN ({explicitTypes}) AND Date < {cutoff})"); + } + + if (clauses.Count == 0) + return 0; + + long deleted; + using (var cmd = Connection.CreateCommand()) + { + cmd.CommandText = $"DELETE FROM messages WHERE {string.Join(" OR ", clauses)};"; + cmd.CommandTimeout = 600; + deleted = cmd.ExecuteNonQuery(); + } + + if (deleted > 0) + PerformMaintenance(); + return deleted; + } + /// /// Hard-deletes every message whose ChatType is not in the supplied /// allowlist, then VACUUMs the database to reclaim disk space. diff --git a/ChatTwo/Plugin.cs b/ChatTwo/Plugin.cs index 36ccf6f..00fc10f 100755 --- a/ChatTwo/Plugin.cs +++ b/ChatTwo/Plugin.cs @@ -156,6 +156,11 @@ public sealed class Plugin : IDalamudPlugin MessageManager = new MessageManager(this); // Does it require UI? + // Hellion Chat — daily retention sweep, off-thread so it never + // blocks plugin load. Skips itself when disabled or already ran + // within the past 24 hours. + RunRetentionSweepIfDue(); + ChatLogWindow = new ChatLogWindow(this); SettingsWindow = new SettingsWindow(this); DbViewer = new DbViewer(this); @@ -305,6 +310,46 @@ public sealed class Plugin : IDalamudPlugin } } + private void RunRetentionSweepIfDue() + { + if (!Config.RetentionEnabled) + return; + if (DateTimeOffset.UtcNow - Config.RetentionLastRunAt < TimeSpan.FromHours(24)) + return; + + // Snapshot the policy so the user can edit settings while we run. + var policy = Config.RetentionPerChannelDays.ToDictionary(p => (int)(ushort)p.Key, p => p.Value); + var defaultDays = Config.RetentionDefaultDays; + + new Thread(() => + { + try + { + var deleted = MessageManager.Store.DeleteByRetentionPolicy(policy, defaultDays); + Config.RetentionLastRunAt = DateTimeOffset.UtcNow; + SaveConfig(); + + if (deleted > 0) + { + Log.Information($"Retention sweep deleted {deleted} expired messages."); + Framework.Run(() => + { + MessageManager.ClearAllTabs(); + MessageManager.FilterAllTabsAsync(); + }).Wait(); + } + else + { + Log.Information("Retention sweep ran, nothing expired."); + } + } + catch (Exception e) + { + Log.Error(e, "Retention sweep failed"); + } + }) { IsBackground = true }.Start(); + } + private void Draw() { ChatLogWindow.BeginFrame(); diff --git a/ChatTwo/Privacy/PrivacyDefaults.cs b/ChatTwo/Privacy/PrivacyDefaults.cs index f9dfff2..0007686 100644 --- a/ChatTwo/Privacy/PrivacyDefaults.cs +++ b/ChatTwo/Privacy/PrivacyDefaults.cs @@ -41,4 +41,47 @@ internal static class PrivacyDefaults ChatType.ExtraChatLinkshell7, ChatType.ExtraChatLinkshell8, }; + + // Default retention windows per channel (in days). Channels not listed + // here fall back to Configuration.RetentionDefaultDays. Reflects the + // design spec: Tells 365, own-conversation channels 90, everything else + // shorter via the global default. + internal static readonly IReadOnlyDictionary DefaultRetentionDays = new Dictionary + { + [ChatType.TellIncoming] = 365, + [ChatType.TellOutgoing] = 365, + + [ChatType.Party] = 90, + [ChatType.CrossParty] = 90, + [ChatType.Alliance] = 90, + [ChatType.PvpTeam] = 90, + [ChatType.FreeCompany] = 90, + + [ChatType.Linkshell1] = 90, + [ChatType.Linkshell2] = 90, + [ChatType.Linkshell3] = 90, + [ChatType.Linkshell4] = 90, + [ChatType.Linkshell5] = 90, + [ChatType.Linkshell6] = 90, + [ChatType.Linkshell7] = 90, + [ChatType.Linkshell8] = 90, + + [ChatType.CrossLinkshell1] = 90, + [ChatType.CrossLinkshell2] = 90, + [ChatType.CrossLinkshell3] = 90, + [ChatType.CrossLinkshell4] = 90, + [ChatType.CrossLinkshell5] = 90, + [ChatType.CrossLinkshell6] = 90, + [ChatType.CrossLinkshell7] = 90, + [ChatType.CrossLinkshell8] = 90, + + [ChatType.ExtraChatLinkshell1] = 90, + [ChatType.ExtraChatLinkshell2] = 90, + [ChatType.ExtraChatLinkshell3] = 90, + [ChatType.ExtraChatLinkshell4] = 90, + [ChatType.ExtraChatLinkshell5] = 90, + [ChatType.ExtraChatLinkshell6] = 90, + [ChatType.ExtraChatLinkshell7] = 90, + [ChatType.ExtraChatLinkshell8] = 90, + }; } diff --git a/ChatTwo/Ui/SettingsTabs/Privacy.cs b/ChatTwo/Ui/SettingsTabs/Privacy.cs index 814b3ef..36f785e 100644 --- a/ChatTwo/Ui/SettingsTabs/Privacy.cs +++ b/ChatTwo/Ui/SettingsTabs/Privacy.cs @@ -55,6 +55,8 @@ internal sealed class Privacy : ISettingsTab private long CleanupDeleteCount; private bool CleanupRunning; + private bool RetentionRunning; + public void Draw(bool changed) { ImGuiUtil.OptionCheckbox( @@ -131,9 +133,146 @@ internal sealed class Privacy : ISettingsTab ImGui.Separator(); ImGui.Spacing(); + DrawRetentionSection(); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + DrawCleanupSection(); } + private void DrawRetentionSection() + { + ImGui.TextUnformatted("Message retention"); + using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) + { + ImGuiUtil.OptionCheckbox( + ref Mutable.RetentionEnabled, + "Auto-delete messages after a per-channel retention window", + "When enabled, messages older than the configured window are deleted on every plugin start (at most once per 24 hours). " + + "Off by default — the plugin never deletes history without your explicit consent."); + + using (ImRaii.Disabled(!Mutable.RetentionEnabled)) + { + ImGui.Spacing(); + + var defaultDays = Mutable.RetentionDefaultDays; + if (ImGui.InputInt("Default retention (days, 0 = never)", ref defaultDays)) + Mutable.RetentionDefaultDays = Math.Max(0, defaultDays); + ImGuiUtil.HelpText("Applies to channels without an explicit override below."); + + ImGui.Spacing(); + + if (ImGui.Button("Reset overrides to spec defaults")) + { + Mutable.RetentionPerChannelDays = + PrivacyDefaults.DefaultRetentionDays.ToDictionary(p => p.Key, p => p.Value); + } + ImGui.SameLine(); + if (ImGui.Button("Clear all overrides")) + Mutable.RetentionPerChannelDays.Clear(); + + ImGui.Spacing(); + + using (var tree = ImRaii.TreeNode("Per-channel retention overrides")) + { + if (tree.Success) + { + using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) + foreach (var (heading, types) in Groups) + { + using var subTree = ImRaii.TreeNode(heading); + if (!subTree.Success) + continue; + + using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) + foreach (var type in types) + { + var hasOverride = Mutable.RetentionPerChannelDays.TryGetValue(type, out var days); + if (!hasOverride) + days = Mutable.RetentionDefaultDays; + + if (ImGui.InputInt($"{type}##retention-{(int)type}", ref days)) + { + days = Math.Max(0, days); + Mutable.RetentionPerChannelDays[type] = days; + } + + if (hasOverride) + { + ImGui.SameLine(); + if (ImGui.Button($"reset##retention-reset-{(int)type}")) + Mutable.RetentionPerChannelDays.Remove(type); + } + } + } + } + } + + ImGui.Spacing(); + + using (ImRaii.Disabled(RetentionRunning)) + { + if (ImGuiUtil.CtrlShiftButton("Apply retention policy now", + "Ctrl+Shift: runs the retention sweep immediately using the SAVED policy. Save your changes first.")) + StartRetentionRun(); + } + + if (RetentionRunning) + ImGuiUtil.HelpText("Retention sweep running in background…"); + + ImGui.Spacing(); + var lastRun = Plugin.Config.RetentionLastRunAt; + ImGuiUtil.HelpText(lastRun == DateTimeOffset.MinValue + ? "Last run: never" + : $"Last run: {lastRun.ToLocalTime():yyyy-MM-dd HH:mm}"); + } + } + } + + private void StartRetentionRun() + { + if (RetentionRunning) + return; + + RetentionRunning = true; + var policy = Plugin.Config.RetentionPerChannelDays.ToDictionary(p => (int)(ushort)p.Key, p => p.Value); + var defaultDays = Plugin.Config.RetentionDefaultDays; + + new Thread(() => + { + try + { + var deleted = Plugin.MessageManager.Store.DeleteByRetentionPolicy(policy, defaultDays); + Plugin.Config.RetentionLastRunAt = DateTimeOffset.UtcNow; + Plugin.SaveConfig(); + + Plugin.Log.Information($"Manual retention run deleted {deleted} expired messages."); + + if (deleted > 0) + { + Plugin.Framework.Run(() => + { + Plugin.MessageManager.ClearAllTabs(); + Plugin.MessageManager.FilterAllTabsAsync(); + }).Wait(); + } + + WrapperUtil.AddNotification($"Retention sweep complete: {deleted:N0} messages removed.", NotificationType.Success); + } + catch (Exception e) + { + Plugin.Log.Error(e, "Manual retention run failed"); + WrapperUtil.AddNotification("Retention sweep failed, see /xllog", NotificationType.Error); + } + finally + { + RetentionRunning = false; + } + }) { IsBackground = true }.Start(); + } + private void DrawCleanupSection() { ImGui.TextUnformatted("Apply filter to existing database");