diff --git a/ChatTwo/MessageStore.cs b/ChatTwo/MessageStore.cs
index 0400cc2..b44998b 100644
--- a/ChatTwo/MessageStore.cs
+++ b/ChatTwo/MessageStore.cs
@@ -310,6 +310,53 @@ internal class MessageStore : IDisposable
PerformMaintenance();
}
+ ///
+ /// Returns a (ChatType, count) snapshot over non-deleted messages.
+ /// Used by the Privacy tab to preview the impact of a retroactive
+ /// cleanup before the user confirms.
+ ///
+ internal Dictionary GetMessageCountsByChatType()
+ {
+ var result = new Dictionary();
+ using var cmd = Connection.CreateCommand();
+ cmd.CommandText = "SELECT ChatType, COUNT(*) FROM messages WHERE deleted = false GROUP BY ChatType;";
+ cmd.CommandTimeout = 120;
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ {
+ var chatType = reader.GetInt32(0);
+ var count = reader.GetInt64(1);
+ result[chatType] = count;
+ }
+ return result;
+ }
+
+ ///
+ /// Hard-deletes every message whose ChatType is not in the supplied
+ /// allowlist, then VACUUMs the database to reclaim disk space.
+ /// Returns the number of rows deleted.
+ ///
+ internal long CleanupRetainOnly(IReadOnlyCollection allowedTypes)
+ {
+ if (allowedTypes.Count == 0)
+ {
+ // Defensive: refuse a "delete everything" disguised as a filter.
+ // Use ClearMessages() if a full wipe is actually intended.
+ throw new InvalidOperationException("CleanupRetainOnly requires at least one allowed ChatType. Use ClearMessages for a full wipe.");
+ }
+
+ var inList = string.Join(",", allowedTypes);
+ long deleted;
+ using (var cmd = Connection.CreateCommand())
+ {
+ cmd.CommandText = $"DELETE FROM messages WHERE ChatType NOT IN ({inList});";
+ cmd.CommandTimeout = 600;
+ deleted = cmd.ExecuteNonQuery();
+ }
+ PerformMaintenance();
+ return deleted;
+ }
+
internal void PerformMaintenance()
{
Connection.Execute(@"
diff --git a/ChatTwo/Ui/Settings.cs b/ChatTwo/Ui/Settings.cs
index 96c3ff7..7976682 100755
--- a/ChatTwo/Ui/Settings.cs
+++ b/ChatTwo/Ui/Settings.cs
@@ -40,7 +40,7 @@ public sealed class SettingsWindow : Window
new Fonts(Mutable),
new ChatColours(Plugin, Mutable),
new Tabs(Plugin, Mutable),
- new SettingsTabs.Privacy(Mutable),
+ new SettingsTabs.Privacy(Plugin, Mutable),
new Database(Plugin, Mutable),
new Webinterface(Plugin, Mutable),
new Miscellaneous(Mutable),
diff --git a/ChatTwo/Ui/SettingsTabs/Privacy.cs b/ChatTwo/Ui/SettingsTabs/Privacy.cs
index 9f9db75..814b3ef 100644
--- a/ChatTwo/Ui/SettingsTabs/Privacy.cs
+++ b/ChatTwo/Ui/SettingsTabs/Privacy.cs
@@ -1,6 +1,7 @@
using ChatTwo.Code;
using ChatTwo.Privacy;
using ChatTwo.Util;
+using Dalamud.Interface.ImGuiNotification;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Bindings.ImGui;
@@ -8,12 +9,14 @@ namespace ChatTwo.Ui.SettingsTabs;
internal sealed class Privacy : ISettingsTab
{
+ private Plugin Plugin { get; }
private Configuration Mutable { get; }
public string Name => "Privacy###tabs-privacy";
- internal Privacy(Configuration mutable)
+ internal Privacy(Plugin plugin, Configuration mutable)
{
+ Plugin = plugin;
Mutable = mutable;
}
@@ -44,6 +47,14 @@ internal sealed class Privacy : ISettingsTab
]),
];
+ // Cleanup preview state. Held in the tab so the user can refresh and
+ // inspect before confirming. Resets when the tab is reopened (acceptable —
+ // a stale preview against a freshly-edited whitelist would be misleading).
+ private Dictionary? CleanupCounts;
+ private long CleanupKeepCount;
+ private long CleanupDeleteCount;
+ private bool CleanupRunning;
+
public void Draw(bool changed)
{
ImGuiUtil.OptionCheckbox(
@@ -115,5 +126,141 @@ internal sealed class Privacy : ISettingsTab
"Failsafe for ChatTypes added by future FFXIV patches that this plugin does not yet know about. " +
"Default OFF (Privacy-First). Turn ON if you want a complete log including future channels.");
}
+
+ ImGui.Spacing();
+ ImGui.Separator();
+ ImGui.Spacing();
+
+ DrawCleanupSection();
+ }
+
+ private void DrawCleanupSection()
+ {
+ ImGui.TextUnformatted("Apply filter to existing database");
+ using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
+ {
+ ImGuiUtil.HelpText(
+ "The privacy filter only applies to new messages. " +
+ "Use the cleanup below to retroactively remove already-stored messages " +
+ "that don't match your saved whitelist.");
+ ImGuiUtil.HelpText(
+ "Cleanup uses your SAVED whitelist (Plugin.Config), not unsaved edits above. " +
+ "Click Save first if you want to apply your current edits.");
+
+ ImGui.Spacing();
+
+ using (ImRaii.Disabled(CleanupRunning))
+ {
+ if (ImGui.Button("Refresh preview"))
+ RefreshCleanupPreview();
+ }
+
+ if (CleanupCounts is null)
+ {
+ ImGuiUtil.HelpText("No preview yet. Click Refresh to compute the impact.");
+ return;
+ }
+
+ ImGui.Spacing();
+ ImGuiUtil.HelpText($"Total stored messages: {CleanupKeepCount + CleanupDeleteCount:N0}");
+ ImGuiUtil.HelpText($"Will keep: {CleanupKeepCount:N0}");
+ ImGuiUtil.HelpText($"Will delete: {CleanupDeleteCount:N0}");
+
+ using (var tree = ImRaii.TreeNode("Per-channel breakdown"))
+ {
+ if (tree.Success)
+ {
+ using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false))
+ foreach (var (chatType, count) in CleanupCounts.OrderByDescending(p => p.Value))
+ {
+ var name = Enum.IsDefined(typeof(ChatType), (ushort)chatType)
+ ? ((ChatType)(ushort)chatType).ToString()
+ : $"Unknown({chatType})";
+ var keeps = WouldBeKept(chatType);
+ var marker = keeps ? "[KEEP] " : "[DELETE]";
+ ImGuiUtil.HelpText($"{marker} {name} — {count:N0}");
+ }
+ }
+ }
+
+ ImGui.Spacing();
+
+ using (ImRaii.Disabled(CleanupRunning || CleanupDeleteCount == 0))
+ {
+ if (ImGuiUtil.CtrlShiftButton("Apply current filter to database",
+ $"Ctrl+Shift: Hard-deletes {CleanupDeleteCount:N0} messages, then runs VACUUM. Cannot be undone."))
+ StartCleanup();
+ }
+
+ if (CleanupRunning)
+ ImGuiUtil.HelpText("Cleanup running in background…");
+ }
+ }
+
+ private bool WouldBeKept(int chatType)
+ {
+ if (!Plugin.Config.PrivacyFilterEnabled)
+ return true;
+ if (Plugin.Config.PrivacyPersistChannels.Contains((ChatType)(ushort)chatType))
+ return true;
+ return Plugin.Config.PrivacyPersistUnknownChannels;
+ }
+
+ private void RefreshCleanupPreview()
+ {
+ try
+ {
+ CleanupCounts = Plugin.MessageManager.Store.GetMessageCountsByChatType();
+ CleanupKeepCount = 0;
+ CleanupDeleteCount = 0;
+ foreach (var (chatType, count) in CleanupCounts)
+ {
+ if (WouldBeKept(chatType))
+ CleanupKeepCount += count;
+ else
+ CleanupDeleteCount += count;
+ }
+ }
+ catch (Exception e)
+ {
+ Plugin.Log.Error(e, "Failed to compute cleanup preview");
+ WrapperUtil.AddNotification("Failed to compute cleanup preview, see /xllog", NotificationType.Error);
+ }
+ }
+
+ private void StartCleanup()
+ {
+ if (CleanupRunning)
+ return;
+
+ CleanupRunning = true;
+ var allowed = Plugin.Config.PrivacyPersistChannels.Select(t => (int)(ushort)t).ToList();
+
+ new Thread(() =>
+ {
+ try
+ {
+ var deleted = Plugin.MessageManager.Store.CleanupRetainOnly(allowed);
+ Plugin.Log.Information($"Privacy cleanup: deleted {deleted} messages");
+
+ Plugin.Framework.Run(() =>
+ {
+ Plugin.MessageManager.ClearAllTabs();
+ Plugin.MessageManager.FilterAllTabsAsync();
+ }).Wait();
+
+ WrapperUtil.AddNotification($"Privacy cleanup complete: {deleted:N0} messages removed.", NotificationType.Success);
+ }
+ catch (Exception e)
+ {
+ Plugin.Log.Error(e, "Privacy cleanup failed");
+ WrapperUtil.AddNotification("Privacy cleanup failed, see /xllog", NotificationType.Error);
+ }
+ finally
+ {
+ CleanupRunning = false;
+ CleanupCounts = null;
+ }
+ }).Start();
}
}