diff --git a/ChatTwo/ChatTwo.csproj b/ChatTwo/ChatTwo.csproj index 71cea50..528b3f1 100755 --- a/ChatTwo/ChatTwo.csproj +++ b/ChatTwo/ChatTwo.csproj @@ -1,6 +1,6 @@ - 1.25.2 + 1.25.3 net8.0-windows enable enable diff --git a/ChatTwo/MessageStore.cs b/ChatTwo/MessageStore.cs index d1727b3..8345424 100644 --- a/ChatTwo/MessageStore.cs +++ b/ChatTwo/MessageStore.cs @@ -372,6 +372,78 @@ internal class MessageStore : IDisposable cmd.Parameters.AddWithValue("$Id", id); cmd.ExecuteNonQuery(); } + + internal long CountDateRange(DateTime after, DateTime before, ulong? receiver = null) + { + List whereClauses = ["deleted = false"]; + if (receiver != null) + whereClauses.Add("Receiver = $Receiver"); + + whereClauses.Add("Date BETWEEN $After AND $Before"); + whereClauses.Add("Code != 72"); + + 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 COUNT(*) + 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 (long) cmd.ExecuteScalar()!; + } + + internal MessageEnumerator GetDateRange(DateTime after, DateTime before, ulong? receiver = null, int page = 0) + { + List whereClauses = ["deleted = false"]; + if (receiver != null) + whereClauses.Add("Receiver = $Receiver"); + + whereClauses.Add("Date BETWEEN $After AND $Before"); + whereClauses.Add("Code != 72"); + + 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 + @" + LIMIT $Offset, 500; + "; + 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()); + cmd.Parameters.AddWithValue("$Offset", 500 * page); + + return new MessageEnumerator(cmd.ExecuteReader()); + } } internal class MessageEnumerator(DbDataReader reader) : IEnumerable diff --git a/ChatTwo/Plugin.cs b/ChatTwo/Plugin.cs index 1e6359a..2e7fe1f 100755 --- a/ChatTwo/Plugin.cs +++ b/ChatTwo/Plugin.cs @@ -45,6 +45,7 @@ public sealed class Plugin : IDalamudPlugin public readonly WindowSystem WindowSystem = new(PluginName); public SettingsWindow SettingsWindow { get; } public ChatLogWindow ChatLogWindow { get; } + public DbViewer DbViewer { get; } public InputPreview InputPreview { get; } public CommandHelpWindow CommandHelpWindow { get; } public SeStringDebugger SeStringDebugger { get; } @@ -89,6 +90,7 @@ public sealed class Plugin : IDalamudPlugin ChatLogWindow = new ChatLogWindow(this); SettingsWindow = new SettingsWindow(this); + DbViewer = new DbViewer(this); InputPreview = new InputPreview(ChatLogWindow); CommandHelpWindow = new CommandHelpWindow(ChatLogWindow); SeStringDebugger = new SeStringDebugger(this); @@ -96,6 +98,7 @@ public sealed class Plugin : IDalamudPlugin WindowSystem.AddWindow(ChatLogWindow); WindowSystem.AddWindow(SettingsWindow); + WindowSystem.AddWindow(DbViewer); WindowSystem.AddWindow(InputPreview); WindowSystem.AddWindow(CommandHelpWindow); WindowSystem.AddWindow(SeStringDebugger); @@ -152,6 +155,7 @@ public sealed class Plugin : IDalamudPlugin WindowSystem?.RemoveAllWindows(); ChatLogWindow?.Dispose(); + DbViewer?.Dispose(); InputPreview?.Dispose(); SettingsWindow?.Dispose(); DebuggerWindow?.Dispose(); diff --git a/ChatTwo/Ui/DbViewer.cs b/ChatTwo/Ui/DbViewer.cs new file mode 100644 index 0000000..9e6e2e7 --- /dev/null +++ b/ChatTwo/Ui/DbViewer.cs @@ -0,0 +1,212 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Numerics; +using ChatTwo.Util; +using Dalamud.Interface; +using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Interface.Windowing; +using ImGuiNET; + +namespace ChatTwo.Ui; + +public class DbViewer : Window +{ + private readonly Plugin Plugin; + + private static readonly DateTime MinimalDate = new(2021, 1, 1); + + private DateTime AfterDate; + private DateTime BeforeDate; + + private int CurrentPage = 1; + private string SimpleSearchTerm = ""; + private bool OnlyCurrentCharacter = true; + + private bool IsProcessing; + private long ProcessingStart = Environment.TickCount64; + private (DateTime Min, DateTime Max, int Page, bool Local) LastProcessed; + + private string MinDateString = ""; + private string MaxDateString = ""; + + private readonly string DateFormat; + private readonly string DateTimeFormat; + + private long Count; + private Message[] Messages = []; // Messages are only touched while processing is false + private ConcurrentStack Filtered = []; // Is used every frame, so ConcurrentStack for safety + + public DbViewer(Plugin plugin) : base("DBViewer###chat2-dbviewer") + { + Plugin = plugin; + + DateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern; + DateTimeFormat = CultureInfo.CurrentCulture.DateTimeFormat.FullDateTimePattern; + + LastProcessed = (AfterDate, BeforeDate, CurrentPage, OnlyCurrentCharacter); + DateReset(); + + SizeConstraints = new WindowSizeConstraints + { + MinimumSize = new Vector2(475, 600), + MaximumSize = new Vector2(float.MaxValue, float.MaxValue) + }; + + RespectCloseHotkey = false; + DisableWindowSounds = true; + + Plugin.Commands.Register("/chat2Viewer").Execute += Toggle; + } + + public void Dispose() + { + Plugin.Commands.Register("/chat2Viewer").Execute -= Toggle; + } + + private void Toggle(string _, string __) => Toggle(); + + public override void Draw() + { + var totalPages = (int)Math.Ceiling(Count / 500.0f); + if (totalPages < 1) + totalPages = 1; + + if (CurrentPage > totalPages) + CurrentPage = 1; + + ImGui.AlignTextToFramePadding(); + ImGui.TextColored(ImGuiColors.DalamudViolet, "FromTo:"); + ImGui.SameLine(); + + var spacing = 3.0f * ImGuiHelpers.GlobalScale; + DateWidget.DatePickerWithInput("##FromDate", 1, ref MinDateString, ref AfterDate, DateFormat); + DateWidget.DatePickerWithInput("##ToDate", 2, ref MaxDateString, ref BeforeDate, DateFormat, true); + ImGui.SameLine(0, spacing); + if (ImGuiUtil.IconButton(FontAwesomeIcon.Recycle)) + DateReset(); + ImGuiUtil.DrawArrows(ref CurrentPage, 1, totalPages, spacing); + + var skipText = "Only current character"; + var textLength = ImGui.GetTextLineHeight() + ImGui.CalcTextSize(skipText).X + ImGui.GetStyle().ItemInnerSpacing.X + ImGui.GetStyle().FramePadding.X * 2; + ImGui.SameLine(ImGui.GetContentRegionMax().X - textLength); + ImGui.Checkbox(skipText, ref OnlyCurrentCharacter); + + var width = 350 * ImGuiHelpers.GlobalScale; + var loadingIndicator = IsProcessing && ProcessingStart < Environment.TickCount64; + + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted($"Page: {CurrentPage} / {totalPages} ({Count} Rows) {(loadingIndicator ? "[Loading...]" : "")}"); + ImGui.SameLine(ImGui.GetContentRegionMax().X - width); + ImGui.SetNextItemWidth(width); + if (ImGui.InputTextWithHint("##searchbar", "Simple Sender/Content Search", ref SimpleSearchTerm, 30)) + Filter(); + + if (DateWidget.Validate(MinimalDate, ref AfterDate, ref BeforeDate)) + DateRefresh(); + + if (!IsProcessing && LastProcessed != (AfterDate, BeforeDate, CurrentPage, OnlyCurrentCharacter)) + { + // Page hasn't changed, so we reset it back to 1 + if (LastProcessed.Page == CurrentPage) + CurrentPage = 1; + + AdjustDates(); + IsProcessing = true; + ProcessingStart = Environment.TickCount64 + 1_000; // + 1 second + LastProcessed = (AfterDate, BeforeDate, CurrentPage, OnlyCurrentCharacter); + Task.Run(() => + { + try + { + ulong? character = OnlyCurrentCharacter ? Plugin.ClientState.LocalContentId : null; + + // We only want to fetch count if this is the first page + if (CurrentPage == 1) + Count = Plugin.MessageManager.Store.CountDateRange(AfterDate, BeforeDate, character); + Messages = Plugin.MessageManager.Store.GetDateRange(AfterDate, BeforeDate, character, CurrentPage - 1).ToArray(); + + Filter(); + } + catch (Exception ex) + { + Plugin.Log.Error(ex, "Failed reading messages from database"); + } + finally + { + IsProcessing = false; + } + }); + } + + ImGuiHelpers.ScaledDummy(5.0f); + + if (Filtered.IsEmpty) + { + ImGui.TextUnformatted(SimpleSearchTerm == "" ? "Nothing Found." : "Search had no match on this page."); + return; + } + + using var child = ImRaii.Child("##tableChild"); + if (!child.Success) + return; + + using var table = ImRaii.Table("##messageHistory", 3, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.Resizable); + if (!table.Success) + return; + + ImGui.TableSetupColumn("Date", ImGuiTableColumnFlags.WidthFixed); + ImGui.TableSetupColumn("Sender"); + ImGui.TableSetupColumn("Content"); + + ImGui.TableHeadersRow(); + foreach (var message in Filtered) + { + ImGui.TableNextColumn(); + ImGui.TextUnformatted(message.Date.ToLocalTime().ToString(DateTimeFormat)); + + ImGui.TableNextColumn(); + Plugin.ChatLogWindow.DrawChunks(message.Sender); + + ImGui.TableNextColumn(); + Plugin.ChatLogWindow.DrawChunks(message.Content); + } + } + + private void Filter() + { + if (SimpleSearchTerm == "") + { + Filtered = new ConcurrentStack(Messages.Reverse()); + return; + } + + Filtered = new ConcurrentStack( + Messages.Reverse().Where(m => + ChunkUtil.ToRawString(m.Sender).Contains(SimpleSearchTerm) || + ChunkUtil.ToRawString(m.Content).Contains(SimpleSearchTerm)) + ); + } + + private void DateRefresh() + { + MinDateString = AfterDate.ToString(DateFormat); + MaxDateString = BeforeDate.ToString(DateFormat); + } + + private void AdjustDates() + { + AfterDate = new DateTime(AfterDate.Year, AfterDate.Month, AfterDate.Day, 0, 0, 0); + BeforeDate = new DateTime(BeforeDate.Year, BeforeDate.Month, BeforeDate.Day, 23, 59, 59); + } + + private void DateReset() + { + AfterDate = DateTime.Now.AddDays(-5); + BeforeDate = DateTime.Now; + + AdjustDates(); + DateRefresh(); + } +} diff --git a/ChatTwo/Util/ChunkUtil.cs b/ChatTwo/Util/ChunkUtil.cs index de50713..10422f7 100755 --- a/ChatTwo/Util/ChunkUtil.cs +++ b/ChatTwo/Util/ChunkUtil.cs @@ -139,6 +139,19 @@ internal static class ChunkUtil return chunks; } + internal static string ToRawString(List chunks) + { + if (chunks.Count == 0) + return string.Empty; + + var builder = new StringBuilder(); + foreach (var chunk in chunks) + if (chunk is TextChunk text) + builder.Append(text.Content); + + return builder.ToString(); + } + internal static readonly RawPayload PeriodicRecruitmentLink = new([0x02, 0x27, 0x07, 0x08, 0x01, 0x01, 0x01, 0xFF, 0x01, 0x03]); private static uint GetInteger(BinaryReader input) diff --git a/ChatTwo/Util/DatePicker.cs b/ChatTwo/Util/DatePicker.cs new file mode 100644 index 0000000..4f8ef8c --- /dev/null +++ b/ChatTwo/Util/DatePicker.cs @@ -0,0 +1,262 @@ +using System.Globalization; +using System.Numerics; +using Dalamud.Interface; +using Dalamud.Interface.Colors; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.Internal.Notifications; +using Dalamud.Interface.Utility; +using Dalamud.Utility; +using ImGuiNET; + +namespace ChatTwo.Util; + +// From https://github.com/Flix01/imgui/blob/imgui_with_addons/addons/imguidatechooser/imguidatechooser.cpp +public static class DateWidget +{ + private const int HeightInItems = 1 + 1 + 1 + 4; + + private static readonly Vector4 Transparent = new(1, 1, 1, 0); + private static readonly string[] DayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + private static readonly string[] MonthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + private static readonly int[] NumDaysPerMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + + private static uint LastOpenComboID; + private static readonly int MaxMonthWidthIndex = -1; + + private static readonly DateTime Sample = DateTime.UnixEpoch; + + static DateWidget() + { + if (MaxMonthWidthIndex == -1) + { + float maxMonthWidth = 0; + for (var i = 0; i < 12; i++) + { + var mw = ImGui.CalcTextSize(MonthNames[i]).X; + if (maxMonthWidth < mw) + { + maxMonthWidth = mw; + MaxMonthWidthIndex = i; + } + } + } + } + + public static bool Validate(DateTime minimal, ref DateTime currentMin, ref DateTime currentMax) + { + var needsRefresh = false; + if (minimal > currentMin) + { + currentMin = minimal; + Plugin.Notification.AddNotification(new Notification + { + Content = "Date before {0} is not possible".Format(minimal.ToShortDateString()), + Type = NotificationType.Warning, + Minimized = false, + }); + needsRefresh = true; + } + else if (currentMin > currentMax) + { + currentMax = currentMin; + needsRefresh = true; + } + + return needsRefresh; + } + + public static void DatePickerWithInput(string label, int id, ref string dateString, ref DateTime date, string format, bool sameLine = false, bool closeWhenMouseLeavesIt = true) + { + if (sameLine) + ImGui.SameLine(); + + ImGui.SetNextItemWidth(ImGui.CalcTextSize(Sample.ToString(format)).X + ImGui.GetStyle().ItemInnerSpacing.X * 2); + if (ImGui.InputTextWithHint($"##{label}Input", format.ToUpper(), ref dateString, 32, ImGuiInputTextFlags.CallbackCompletion)) + { + if (DateTime.TryParseExact(dateString, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var tmp)) + date = tmp; + } + + ImGui.SameLine(0, 3.0f * ImGuiHelpers.GlobalScale); + + ImGuiUtil.IconButton(FontAwesomeIcon.Calendar, id.ToString()); + if (DatePicker(label, ref date, closeWhenMouseLeavesIt)) + dateString = date.ToString(format); + } + + public static bool DatePicker(string label, ref DateTime dateOut, bool closeWhenMouseLeavesIt, string leftArrow = "", string rightArrow = "") + { + var id = ImGui.GetID(label); + var style = ImGui.GetStyle(); + + var arrowLeft = leftArrow.Length > 0 ? leftArrow : "<"; + var arrowRight = rightArrow.Length > 0 ? rightArrow : ">"; + var arrowLeftWidth = ImGui.CalcTextSize(arrowLeft).X; + var arrowRightWidth = ImGui.CalcTextSize(arrowRight).X; + + var labelSize = ImGui.CalcTextSize(label, 0, true); + + var requiredMonthWidth = ImGui.CalcTextSize(MonthNames[MaxMonthWidthIndex]).X; + var widthRequiredByCalendar = (2.0f * arrowLeftWidth) + (2.0f * arrowRightWidth) + requiredMonthWidth + ImGui.CalcTextSize("9999").X + (120.0f * ImGuiHelpers.GlobalScale); + var popupHeight = ((labelSize.Y + (2 * style.ItemSpacing.Y)) * HeightInItems) + (style.FramePadding.Y * 3); + + var valueChanged = false; + ImGui.SetNextWindowSize(new Vector2(widthRequiredByCalendar, widthRequiredByCalendar)); + ImGui.SetNextWindowSizeConstraints(new Vector2(widthRequiredByCalendar, popupHeight + 40), new Vector2(widthRequiredByCalendar, popupHeight + 40)); + + if (!ImGui.BeginPopupContextItem(label, ImGuiPopupFlags.None)) + return valueChanged; + + if (ImGui.GetIO().MouseClicked[1]) + { + // reset date when user right clicks the date chooser header when the dialog is open + dateOut = DateTime.Now; + } + else if (LastOpenComboID != id) + { + LastOpenComboID = id; + if (dateOut.Year == 1) + dateOut = DateTime.Now; + } + + ImGui.PushFont(UiBuilder.MonoFont); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, style.FramePadding); + ImGui.Spacing(); + ImGui.PushStyleColor(ImGuiCol.Button, Transparent); + + var yearString = $"{dateOut.Year}"; + var yearPartWidth = arrowLeftWidth + arrowRightWidth + ImGui.CalcTextSize(yearString).X; + + var oldWindowRounding = style.WindowRounding; + style.WindowRounding = 0; + + ImGui.PushID(1234); + if (ImGui.SmallButton(arrowLeft)) + dateOut = dateOut.AddMonths(-1); + ImGui.SameLine(); + + ImGui.TextUnformatted($"{Center(MonthNames[dateOut.Month - 1], 9)}"); + + ImGui.SameLine(); + if (ImGui.SmallButton(arrowRight)) + dateOut = dateOut.AddMonths(1); + ImGui.PopID(); + + ImGui.SameLine(ImGui.GetWindowWidth() - yearPartWidth - style.WindowPadding.X - style.ItemSpacing.X * 4.0f); + + ImGui.PushID(1235); + if (ImGui.SmallButton(arrowLeft)) + dateOut = dateOut.AddYears(-1); + ImGui.SameLine(); + + ImGui.Text($"{dateOut.Year}"); + + ImGui.SameLine(); + if (ImGui.SmallButton(arrowRight)) + dateOut = dateOut.AddYears(1); + ImGui.PopID(); + + ImGui.Spacing(); + + var maxDayOfCurMonth = NumDaysPerMonth[dateOut.Month - 1]; // This could be calculated only when needed (but I guess it's fast in any case...) + if (maxDayOfCurMonth == 28) + { + var year = dateOut.Year; + var bis = ((year % 4) == 0) && ((year % 100) != 0 || (year % 400) == 0); + if (bis) + maxDayOfCurMonth = 29; + } + + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, ImGuiColors.DalamudOrange); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, ImGuiColors.DalamudYellow); + + ImGui.Separator(); + + // Display items + var dayOfWeek = (int)new DateTime(dateOut.Year, dateOut.Month, 1).DayOfWeek; + for (var dw = 0; dw < 7; dw++) + { + ImGui.BeginGroup(); + if (dw == 0) + { + var textColor = ImGuiColors.DalamudGrey; + var l = (textColor.X + textColor.Y + textColor.Z) * 0.33334f; + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(l * 2.0f > 1 ? 1 : l * 2.0f, l * .5f, l * .5f, textColor.W)); + } + + ImGui.Text($"{(dw == 0 ? "" : " ")}{DayNames[dw]}"); + if (dw == 0) + ImGui.Separator(); + else + ImGui.Spacing(); + + var curDay = dw - dayOfWeek; // Use dayOfWeek for spacing + for (var row = 0; row < 7; row++) + { + var cday = curDay + (7 * row); + if (cday >= 0 && cday < maxDayOfCurMonth) + { + ImGui.PushID(row * 10 + dw); + if (ImGui.SmallButton(string.Format(cday < 9 ? " {0}" : "{0}", cday + 1))) + { + valueChanged = true; + ImGui.SetItemDefaultFocus(); + dateOut = new DateTime(dateOut.Year, dateOut.Month, cday + 1); + } + + ImGui.PopID(); + } + else + { + ImGui.TextUnformatted(" "); + } + } + + if (dw == 0) + { + ImGui.Separator(); + ImGui.PopStyleColor(); + } + + ImGui.EndGroup(); + + if (dw != 6) + ImGui.SameLine(ImGui.GetWindowWidth() - ((6 - dw) * (ImGui.GetWindowWidth() / 7.0f))); + } + + style.WindowRounding = oldWindowRounding; + ImGui.PopStyleColor(2); + ImGui.PopStyleColor(); + ImGui.PopStyleVar(); + + var mustCloseCombo = valueChanged; + if (closeWhenMouseLeavesIt && !mustCloseCombo) + { + var distance = ImGui.GetFontSize() * 1.75f; //1.3334f; //24; + var pos = ImGui.GetWindowPos(); + pos.X -= distance; + pos.Y -= distance; + var size = ImGui.GetWindowSize(); + size.X += 2.0f * distance; + size.Y += 2.0f * distance; + var mousePos = ImGui.GetIO().MousePos; + if (mousePos.X < pos.X || mousePos.Y < pos.Y || mousePos.X > pos.X + size.X || mousePos.Y > pos.Y + size.Y) + mustCloseCombo = true; + } + + ImGui.PopFont(); + // ImGui issue #273849, children keep popups from closing automatically + if (mustCloseCombo) + ImGui.CloseCurrentPopup(); + ImGui.EndPopup(); + + return valueChanged; + } + + public static string Center(string source, int length) + { + var spaces = length - source.Length; + var padLeft = (spaces / 2) + source.Length; + return source.PadLeft(padLeft).PadRight(length); + } +} \ No newline at end of file diff --git a/ChatTwo/Util/ImGuiUtil.cs b/ChatTwo/Util/ImGuiUtil.cs index 4609bf9..3f33483 100755 --- a/ChatTwo/Util/ImGuiUtil.cs +++ b/ChatTwo/Util/ImGuiUtil.cs @@ -298,6 +298,25 @@ internal static class ImGuiUtil } } + public static void DrawArrows(ref int selected, int min, int max, float spacing, int id = 0) + { + // Prevents changing values from triggering EndDisable + var isMin = selected == 1; + var isMax = selected == max; + + ImGui.SameLine(0, spacing); + using (ImRaii.Disabled(isMin)) + { + if (IconButton(FontAwesomeIcon.ArrowLeft, id.ToString())) selected--; + } + + ImGui.SameLine(0, spacing); + using (ImRaii.Disabled(isMax)) + { + if (IconButton(FontAwesomeIcon.ArrowRight, id+1.ToString())) selected++; + } + } + internal static bool TryToImGui(this VirtualKey key, out ImGuiKey result) { result = key switch {