From df0773ac650e9927832a873a6469750ede8ffcec Mon Sep 17 00:00:00 2001 From: Infi Date: Fri, 10 Apr 2026 22:17:37 +0200 Subject: [PATCH] - Implement message history export to text file --- ChatTwo.Tests/ChatTwo.Tests.csproj | 1 + ChatTwo/ChatTwo.csproj | 1 + ChatTwo/MessageStore.cs | 44 ++++++++- ChatTwo/Plugin.cs | 6 ++ ChatTwo/Ui/DbViewer.cs | 146 ++++++++++++++++++++++++++--- ChatTwo/packages.lock.json | 6 ++ 6 files changed, 189 insertions(+), 15 deletions(-) diff --git a/ChatTwo.Tests/ChatTwo.Tests.csproj b/ChatTwo.Tests/ChatTwo.Tests.csproj index 68fabd5..8ea66bc 100644 --- a/ChatTwo.Tests/ChatTwo.Tests.csproj +++ b/ChatTwo.Tests/ChatTwo.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/ChatTwo/ChatTwo.csproj b/ChatTwo/ChatTwo.csproj index 3cb0275..352f979 100755 --- a/ChatTwo/ChatTwo.csproj +++ b/ChatTwo/ChatTwo.csproj @@ -15,6 +15,7 @@ + diff --git a/ChatTwo/MessageStore.cs b/ChatTwo/MessageStore.cs index 578b0bc..a23d77f 100644 --- a/ChatTwo/MessageStore.cs +++ b/ChatTwo/MessageStore.cs @@ -387,7 +387,7 @@ internal class MessageStore : IDisposable cmd.ExecuteNonQuery(); } - internal long CountDateRange(DateTime after, DateTime before, uint[] channels, ulong? receiver = null) + internal long CountDateRange(DateTime after, DateTime before, IEnumerable channels, ulong? receiver = null) { List whereClauses = ["deleted = false"]; if (receiver != null) @@ -416,7 +416,47 @@ internal class MessageStore : IDisposable return (long) cmd.ExecuteScalar()!; } - internal MessageEnumerator GetDateRange(DateTime after, DateTime before, uint[] channels, ulong? receiver = null, int page = 0) + internal MessageEnumerator GetDateRange(DateTime after, DateTime before, IEnumerable channels, ulong? receiver = null) + { + List whereClauses = ["deleted = false"]; + if (receiver != null) + whereClauses.Add("Receiver = $Receiver"); + + whereClauses.Add("Date BETWEEN $After AND $Before"); + whereClauses.Add($"Channel IN ({string.Join(", ", channels)})"); + + var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}"; + + var cmd = Connection.CreateCommand(); + // Select last N messages by date DESC, but reverse the order to get + // them in ascending order. + cmd.CommandText = @" + SELECT + Id, + Receiver, + ContentId, + Date, + Code, + Sender, + Content, + SenderSource, + ContentSource, + SortCode, + ExtraChatChannel + FROM messages + " + whereClause; + cmd.CommandTimeout = 120; // this could take a while on slow computers + + if (receiver != null) + cmd.Parameters.AddWithValue("$Receiver", receiver); + + cmd.Parameters.AddWithValue("$After", ((DateTimeOffset) after).ToUnixTimeMilliseconds()); + cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset) before).ToUnixTimeMilliseconds()); + + return new MessageEnumerator(cmd.ExecuteReader()); + } + + internal MessageEnumerator GetPagedDateRange(DateTime after, DateTime before, IEnumerable channels, ulong? receiver = null, int page = 0) { List whereClauses = ["deleted = false"]; if (receiver != null) diff --git a/ChatTwo/Plugin.cs b/ChatTwo/Plugin.cs index 442741d..991f1bd 100755 --- a/ChatTwo/Plugin.cs +++ b/ChatTwo/Plugin.cs @@ -12,6 +12,7 @@ using Dalamud.IoC; using Dalamud.Plugin; using Dalamud.Plugin.Services; using Dalamud.Bindings.ImGui; +using Dalamud.Interface.ImGuiFileDialog; namespace ChatTwo; @@ -42,6 +43,7 @@ public sealed class Plugin : IDalamudPlugin [PluginService] internal static ISeStringEvaluator Evaluator { get; private set; } = null!; internal static Configuration Config = null!; + public static FileDialogManager FileDialogManager { get; private set; } = null!; public readonly WindowSystem WindowSystem = new(PluginName); public SettingsWindow SettingsWindow { get; } @@ -93,6 +95,8 @@ public sealed class Plugin : IDalamudPlugin LanguageChanged(Interface.UiLanguage); ImGuiUtil.Initialize(this); + FileDialogManager = new FileDialogManager(); + // Functions calls this in its ctor if the player is already logged in ServerCore = new ServerCore(this); @@ -216,6 +220,8 @@ public sealed class Plugin : IDalamudPlugin ChatLogWindow.FinalizeFrame(); TypingIpc.Update(); + + FileDialogManager.Draw(); } internal void SaveConfig() diff --git a/ChatTwo/Ui/DbViewer.cs b/ChatTwo/Ui/DbViewer.cs index 8dd1a3b..5fc8798 100644 --- a/ChatTwo/Ui/DbViewer.cs +++ b/ChatTwo/Ui/DbViewer.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Globalization; using System.Numerics; +using System.Text; using ChatTwo.Code; using ChatTwo.Resources; using ChatTwo.Util; @@ -10,6 +11,9 @@ using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; using Dalamud.Bindings.ImGui; +using Dalamud.Interface.ImGuiNotification; +using Lumina.Text.ReadOnly; +using MoreLinq; namespace ChatTwo.Ui; @@ -43,6 +47,10 @@ public class DbViewer : Window private Message[] Messages = []; // Messages are only touched while processing is false private ConcurrentStack Filtered = []; // Is used every frame, so ConcurrentStack for safety + private bool IsExporting; + private string InputPath = string.Empty; + private IActiveNotification Notification = null!; + public DbViewer(Plugin plugin) : base("DBViewer###chat2-dbviewer") { Plugin = plugin; @@ -63,12 +71,12 @@ public class DbViewer : Window RespectCloseHotkey = false; DisableWindowSounds = true; - Plugin.Commands.Register("/chat2Viewer", "Database Viewer", true).Execute += Toggle; + Plugin.Commands.Register("/chat2Viewer", "Get access to your message history, with simple filter options.", true).Execute += Toggle; } public void Dispose() { - Plugin.Commands.Register("/chat2Viewer", "Database Viewer", true).Execute -= Toggle; + Plugin.Commands.Register("/chat2Viewer", "Get access to your message history, with simple filter options.", true).Execute -= Toggle; } private void Toggle(string _, string __) => Toggle(); @@ -82,6 +90,8 @@ public class DbViewer : Window if (CurrentPage > totalPages) CurrentPage = 1; + // First row + ImGui.AlignTextToFramePadding(); ImGui.TextColored(ImGuiColors.DalamudViolet, Language.DbViewer_DatePicker_FromTo); ImGui.SameLine(); @@ -101,6 +111,49 @@ public class DbViewer : Window ImGui.SameLine(ImGui.GetContentRegionMax().X - textLength); ImGui.Checkbox(skipText, ref OnlyCurrentCharacter); + // Second row + + ImGui.AlignTextToFramePadding(); + ImGui.TextColored(ImGuiColors.DalamudViolet, "Export:"); + ImGui.SameLine(0, spacing); + ImGui.SetNextItemWidth(ImGui.GetWindowWidth() * 0.4f); + ImGui.InputText("##InputPath", ref InputPath, 255); + ImGui.SameLine(0, spacing); + if (ImGuiUtil.IconButton(FontAwesomeIcon.FolderClosed, "##folderPicker")) + ImGui.OpenPopup("InputPathDialog"); + + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + ImGui.SetTooltip("Pick a folder location for export."); + + using (var innerPopup = ImRaii.Popup("InputPathDialog")) + { + if (innerPopup.Success) + Plugin.FileDialogManager.OpenFolderDialog("Pick an export location", (b, s) => { if (b) InputPath = s; }, null, true); + } + + ImGui.SameLine(0, spacing); + using (ImRaii.Disabled(InputPath.Length == 0 || IsExporting)) + { + if (ImGuiUtil.IconButton(FontAwesomeIcon.Save)) + { + Notification = Plugin.Notification.AddNotification( + new Notification + { + Title = "Chat2 Export", + Content = "Loading logs ...", + Type = NotificationType.Info, + Minimized = false, + UserDismissable = false, + InitialDuration = TimeSpan.FromSeconds(10000), + Progress = 0.0f, + }); + CreateTxtBackup(); + } + } + + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + ImGui.SetTooltip("Export the message history to a text file."); + var width = 350 * ImGuiHelpers.GlobalScale; var loadingIndicator = IsProcessing && ProcessingStart < Environment.TickCount64; @@ -109,7 +162,9 @@ public class DbViewer : Window ImGui.SameLine(ImGui.GetContentRegionMax().X - width); ImGui.SetNextItemWidth(width); if (ImGui.InputTextWithHint("##searchbar", Language.DbViewer_SearcHint, ref SimpleSearchTerm, 30)) - Filter(); + Filtered = Filter(Messages); + + // Third row if (DateWidget.Validate(MinimalDate, ref AfterDate, ref BeforeDate)) DateRefresh(); @@ -135,10 +190,10 @@ public class DbViewer : Window if (CurrentPage == 1) Count = Plugin.MessageManager.Store.CountDateRange(AfterDate, BeforeDate, channels, character); - using var dateRangeMessageEnumerator = Plugin.MessageManager.Store.GetDateRange(AfterDate, BeforeDate, channels, character, CurrentPage - 1); - Messages = dateRangeMessageEnumerator.ToArray(); + using var rangeMessageEnumerator = Plugin.MessageManager.Store.GetPagedDateRange(AfterDate, BeforeDate, channels, character, CurrentPage - 1); + Messages = rangeMessageEnumerator.ToArray(); - Filter(); + Filtered = Filter(Messages); } catch (Exception ex) { @@ -236,16 +291,13 @@ public class DbViewer : Window } } - private void Filter() + private ConcurrentStack Filter(Message[] messages) { if (SimpleSearchTerm == "") - { - Filtered = new ConcurrentStack(Messages.Reverse().OrderByDescending(m => m.Date)); - return; - } + return new ConcurrentStack(messages.Reverse().OrderByDescending(m => m.Date)); - Filtered = new ConcurrentStack( - Messages.Reverse().Where(m => + return new ConcurrentStack( + messages.Reverse().Where(m => ChunkUtil.ToRawString(m.Sender).Contains(SimpleSearchTerm, StringComparison.InvariantCultureIgnoreCase) || ChunkUtil.ToRawString(m.Content).Contains(SimpleSearchTerm, StringComparison.InvariantCultureIgnoreCase) ).OrderByDescending(m => m.Date)); @@ -271,4 +323,72 @@ public class DbViewer : Window AdjustDates(); DateRefresh(); } + + private void CreateTxtBackup() + { + IsExporting = true; + Task.Run(async () => + { + try + { + ulong? character = OnlyCurrentCharacter ? Plugin.PlayerState.ContentId : null; + var channels = ChatCodes.Select(c => (uint)c.Key).ToArray(); + + var rangeMessageEnumerator = Plugin.MessageManager.Store.GetDateRange(AfterDate, BeforeDate, channels, character); + var messageHistory = rangeMessageEnumerator.ToArray(); + await rangeMessageEnumerator.DisposeAsync(); + + var filteredHistory = Filter(messageHistory); + + var sb = new StringBuilder(); + await using var stream = new StreamWriter(Path.Join(InputPath, $"Chat2_{DateTime.Now:yyyy_dd_M__HH_mm_ss}.txt")); + + var batch = 0; + foreach (var messages in filteredHistory.Batch(5000)) + { + await Plugin.Framework.RunOnTick(() => + { + foreach (var message in messages) + { + if (!Sheets.LogKindSheet.TryGetRow((uint)message.Code.Type, out var logKind)) + logKind = Sheets.LogKindSheet.GetRow(10); // default to say + + var rossSender = new ReadOnlySeString(message.SenderSource.Encode()); + var rossMessage = new ReadOnlySeString(message.ContentSource.Encode()); + + var timestamp = message.Date.ToLocalTime().ToString(DateTimeFormat); + var text = Plugin.Evaluator.Evaluate(logKind.Format, [rossSender, rossMessage]).ToString(); + sb.AppendLine($"[{timestamp}][{message.Code.Type.Name()}] {text}"); + + batch++; + } + }, delayTicks: 5); + + Notification.Progress = (float)batch / filteredHistory.Count; + Notification.Content = $"Exported {batch} of {filteredHistory.Count} messages"; + await stream.WriteAsync(sb.ToString()); + sb.Clear(); + } + + await stream.WriteAsync(sb.ToString()); + sb.Clear(); + + Notification.Progress = 1.0f; + Notification.Content = "Done!!!"; + Notification.Type = NotificationType.Success; + } + catch (Exception ex) + { + Plugin.Log.Error(ex, "Failed creating txt backup"); + + Notification.Content = "Error ..."; + Notification.Type = NotificationType.Error; + } + finally + { + IsExporting = false; + Notification.UserDismissable = true; + } + }); + } } diff --git a/ChatTwo/packages.lock.json b/ChatTwo/packages.lock.json index 019ea19..480ffe7 100644 --- a/ChatTwo/packages.lock.json +++ b/ChatTwo/packages.lock.json @@ -36,6 +36,12 @@ "SQLitePCLRaw.core": "2.1.10" } }, + "morelinq": { + "type": "Direct", + "requested": "[4.4.0, )", + "resolved": "4.4.0", + "contentHash": "QX3bsK9oFeUXk8tFsc9NkI6NnCr8Ar/ex027p+ZZ/jdLCdX2RlryDtxUqZW5j45NVwn4E4Z4hzupsoMQd6Yxtg==" + }, "Pidgin": { "type": "Direct", "requested": "[3.3.0, )",