diff --git a/ChatTwo/Export/MessageExporter.cs b/ChatTwo/Export/MessageExporter.cs new file mode 100644 index 0000000..3cadaa4 --- /dev/null +++ b/ChatTwo/Export/MessageExporter.cs @@ -0,0 +1,229 @@ +using System.Globalization; +using System.Text; +using ChatTwo.Code; + +namespace ChatTwo.Export; + +internal enum ExportFormat +{ + Markdown, + Json, + Csv, +} + +internal static class ExportFormatExt +{ + internal static string Extension(this ExportFormat fmt) => fmt switch + { + ExportFormat.Markdown => "md", + ExportFormat.Json => "json", + ExportFormat.Csv => "csv", + _ => "txt", + }; + + internal static string Filter(this ExportFormat fmt) => fmt switch + { + ExportFormat.Markdown => ".md", + ExportFormat.Json => ".json", + ExportFormat.Csv => ".csv", + _ => ".txt", + }; +} + +/// +/// Serializes message snapshots into Markdown, JSON, or CSV. The caller is +/// expected to filter the input enumerable; this class only handles +/// formatting and writes to the supplied path. Sender substring filtering +/// happens here because it requires deserialized SeString.TextValue. +/// +internal static class MessageExporter +{ + internal record FilterDescription( + IReadOnlyCollection? ChatTypes, + DateTimeOffset? From, + DateTimeOffset? To, + string? SenderSubstring); + + internal static int ExportToFile( + string path, + ExportFormat format, + IEnumerable messages, + FilterDescription filter) + { + var matching = filter.SenderSubstring is { Length: > 0 } needle + ? messages.Where(m => MatchesSender(m, needle)) + : messages; + + using var writer = new StreamWriter(path, append: false, encoding: Encoding.UTF8); + return format switch + { + ExportFormat.Markdown => WriteMarkdown(writer, matching, filter), + ExportFormat.Json => WriteJson(writer, matching, filter), + ExportFormat.Csv => WriteCsv(writer, matching, filter), + _ => throw new ArgumentOutOfRangeException(nameof(format), format, null), + }; + } + + private static bool MatchesSender(Message m, string needle) + => m.SenderSource.TextValue.Contains(needle, StringComparison.OrdinalIgnoreCase); + + private static int WriteMarkdown(StreamWriter w, IEnumerable messages, FilterDescription filter) + { + w.WriteLine("# Hellion Chat Export"); + w.WriteLine(); + w.WriteLine($"Generated: {DateTimeOffset.Now:yyyy-MM-dd HH:mm zzz}"); + WriteFilterSummaryMarkdown(w, filter); + w.WriteLine(); + + DateTimeOffset? lastDate = null; + var count = 0; + foreach (var m in messages) + { + count++; + var localDate = m.Date.ToLocalTime(); + if (lastDate is null || localDate.Date != lastDate.Value.Date) + { + w.WriteLine(); + w.WriteLine($"## {localDate:yyyy-MM-dd}"); + w.WriteLine(); + lastDate = localDate; + } + + var chatType = (ChatType)(ushort)m.Code.Type; + var sender = m.SenderSource.TextValue.Trim().Trim('<', '>', '[', ']', ':').Trim(); + var content = m.ContentSource.TextValue; + if (string.IsNullOrEmpty(sender)) + w.WriteLine($"**[{localDate:HH:mm}] {chatType}:** {content}"); + else + w.WriteLine($"**[{localDate:HH:mm}] {chatType} {sender}:** {content}"); + } + + w.WriteLine(); + w.WriteLine($"---"); + w.WriteLine($"Total messages: {count}"); + return count; + } + + private static void WriteFilterSummaryMarkdown(StreamWriter w, FilterDescription filter) + { + if (filter.ChatTypes is { Count: > 0 }) + w.WriteLine($"ChatTypes: {string.Join(", ", filter.ChatTypes.Select(t => $"{(ChatType)(ushort)t}({t})"))}"); + if (filter.From is not null) + w.WriteLine($"From: {filter.From.Value.ToLocalTime():yyyy-MM-dd HH:mm}"); + if (filter.To is not null) + w.WriteLine($"To: {filter.To.Value.ToLocalTime():yyyy-MM-dd HH:mm}"); + if (filter.SenderSubstring is { Length: > 0 }) + w.WriteLine($"Sender contains: \"{filter.SenderSubstring}\""); + } + + private static int WriteJson(StreamWriter w, IEnumerable messages, FilterDescription filter) + { + // Manual JSON to avoid pulling in System.Text.Json policy choices. + // Output is a single object with metadata and an array of messages. + w.Write("{\n \"exported_at\": \""); + w.Write(DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)); + w.Write("\",\n \"plugin\": \"Hellion Chat\",\n"); + w.Write(" \"filter\": {\n"); + w.Write(" \"chat_types\": "); + if (filter.ChatTypes is { Count: > 0 }) + w.Write("[" + string.Join(",", filter.ChatTypes) + "]"); + else + w.Write("null"); + w.Write(",\n \"from\": "); + w.Write(filter.From is null ? "null" : "\"" + filter.From.Value.ToString("O", CultureInfo.InvariantCulture) + "\""); + w.Write(",\n \"to\": "); + w.Write(filter.To is null ? "null" : "\"" + filter.To.Value.ToString("O", CultureInfo.InvariantCulture) + "\""); + w.Write(",\n \"sender_substring\": "); + w.Write(filter.SenderSubstring is null ? "null" : JsonString(filter.SenderSubstring)); + w.Write("\n },\n \"messages\": [\n"); + + var first = true; + var count = 0; + foreach (var m in messages) + { + if (!first) + w.Write(",\n"); + first = false; + count++; + + var chatType = (ChatType)(ushort)m.Code.Type; + w.Write(" {"); + w.Write($"\"id\":\"{m.Id}\""); + w.Write($",\"date\":\"{m.Date.ToString("O", CultureInfo.InvariantCulture)}\""); + w.Write($",\"chat_type\":{(int)m.Code.Type}"); + w.Write($",\"chat_type_name\":\"{chatType}\""); + w.Write($",\"source_kind\":{m.Code.Source}"); + w.Write($",\"target_kind\":{m.Code.Target}"); + w.Write($",\"receiver\":{m.Receiver}"); + w.Write($",\"content_id\":{m.ContentId}"); + w.Write($",\"sender\":{JsonString(m.SenderSource.TextValue)}"); + w.Write($",\"content\":{JsonString(m.ContentSource.TextValue)}"); + w.Write("}"); + } + + w.Write("\n ],\n"); + w.Write($" \"total\": {count}\n}}\n"); + return count; + } + + private static int WriteCsv(StreamWriter w, IEnumerable messages, FilterDescription filter) + { + // Header line always written so empty exports are still importable. + w.WriteLine("Date,ChatType,ChatTypeName,Sender,Content,Receiver,ContentId"); + var count = 0; + foreach (var m in messages) + { + count++; + var chatType = (ChatType)(ushort)m.Code.Type; + w.Write(m.Date.ToString("O", CultureInfo.InvariantCulture)); + w.Write(','); + w.Write((int)m.Code.Type); + w.Write(','); + w.Write(CsvString(chatType.ToString())); + w.Write(','); + w.Write(CsvString(m.SenderSource.TextValue)); + w.Write(','); + w.Write(CsvString(m.ContentSource.TextValue)); + w.Write(','); + w.Write(m.Receiver); + w.Write(','); + w.Write(m.ContentId); + w.WriteLine(); + } + return count; + } + + private static string JsonString(string s) + { + var sb = new StringBuilder(s.Length + 2); + sb.Append('"'); + foreach (var c in s) + { + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\b': sb.Append("\\b"); break; + case '\f': sb.Append("\\f"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (c < 0x20) + sb.Append($"\\u{(int)c:x4}"); + else + sb.Append(c); + break; + } + } + sb.Append('"'); + return sb.ToString(); + } + + private static string CsvString(string s) + { + if (s.IndexOfAny(['"', ',', '\n', '\r']) < 0) + return s; + return "\"" + s.Replace("\"", "\"\"") + "\""; + } +} diff --git a/ChatTwo/MessageStore.cs b/ChatTwo/MessageStore.cs index cbbb811..cd05ad2 100644 --- a/ChatTwo/MessageStore.cs +++ b/ChatTwo/MessageStore.cs @@ -500,6 +500,54 @@ internal class MessageStore : IDisposable cmd.ExecuteNonQuery(); } + /// + /// Streams messages for export. Optional filters: + /// - : limit to these ChatTypes + /// - / : inclusive date range + /// Result is sorted ascending by Date and excludes soft-deleted rows. + /// Caller is responsible for disposing the enumerator. + /// + internal MessageEnumerator StreamForExport( + IReadOnlyCollection? chatTypes, + DateTimeOffset? from, + DateTimeOffset? to) + { + var clauses = new List { "deleted = false" }; + if (chatTypes is { Count: > 0 }) + clauses.Add($"ChatType IN ({string.Join(",", chatTypes)})"); + if (from is not null) + clauses.Add("Date >= $From"); + if (to is not null) + clauses.Add("Date <= $To"); + + var cmd = Connection.CreateCommand(); + cmd.CommandText = @" + SELECT + Id, + Receiver, + ContentId, + Date, + ChatType, + SourceKind, + TargetKind, + Sender, + Content, + SenderSource, + ContentSource, + ExtraChatChannel + FROM messages + WHERE " + string.Join(" AND ", clauses) + @" + ORDER BY Date ASC;"; + cmd.CommandTimeout = 600; + + if (from is not null) + cmd.Parameters.AddWithValue("$From", from.Value.ToUnixTimeMilliseconds()); + if (to is not null) + cmd.Parameters.AddWithValue("$To", to.Value.ToUnixTimeMilliseconds()); + + return new MessageEnumerator(cmd.ExecuteReader()); + } + /// /// Get the most recent messages. /// diff --git a/ChatTwo/Resources/HellionStrings.Designer.cs b/ChatTwo/Resources/HellionStrings.Designer.cs index 5fc0b9e..7781b01 100644 --- a/ChatTwo/Resources/HellionStrings.Designer.cs +++ b/ChatTwo/Resources/HellionStrings.Designer.cs @@ -113,4 +113,21 @@ internal class HellionStrings internal static string Wizard_Profile_FullHistory_GdprWarning => Get(nameof(Wizard_Profile_FullHistory_GdprWarning)); internal static string Wizard_Profile_FullHistory_Apply => Get(nameof(Wizard_Profile_FullHistory_Apply)); internal static string Wizard_Reopen_Button => Get(nameof(Wizard_Reopen_Button)); + + internal static string Export_Heading => Get(nameof(Export_Heading)); + internal static string Export_Help => Get(nameof(Export_Help)); + internal static string Export_Range_Label => Get(nameof(Export_Range_Label)); + internal static string Export_Sender_Label => Get(nameof(Export_Sender_Label)); + internal static string Export_Channels_Heading => Get(nameof(Export_Channels_Heading)); + internal static string Export_Channels_AllOff => Get(nameof(Export_Channels_AllOff)); + internal static string Export_Format_Label => Get(nameof(Export_Format_Label)); + internal static string Export_Format_Markdown => Get(nameof(Export_Format_Markdown)); + internal static string Export_Format_Json => Get(nameof(Export_Format_Json)); + internal static string Export_Format_Csv => Get(nameof(Export_Format_Csv)); + internal static string Export_Button => Get(nameof(Export_Button)); + internal static string Export_Dialog_Title => Get(nameof(Export_Dialog_Title)); + internal static string Export_Running => Get(nameof(Export_Running)); + internal static string Export_Success => Get(nameof(Export_Success)); + internal static string Export_Empty => Get(nameof(Export_Empty)); + internal static string Export_Error => Get(nameof(Export_Error)); } diff --git a/ChatTwo/Resources/HellionStrings.de.resx b/ChatTwo/Resources/HellionStrings.de.resx index 9c6f948..bc615ac 100644 --- a/ChatTwo/Resources/HellionStrings.de.resx +++ b/ChatTwo/Resources/HellionStrings.de.resx @@ -216,4 +216,52 @@ Wizard erneut zeigen + + Export (DSGVO Art. 15 — Auskunftsrecht) + + + Gespeicherte Nachrichten als Markdown, JSON oder CSV exportieren. Damit kannst du einer Auskunftsanfrage einer Person nachkommen, deren Nachrichten du gespeichert hast, oder deine eigene Historie mitnehmen. + + + Letzte X Tage (0 = ohne Zeitlimit) + + + Sender enthält (optional, Groß-/Kleinschreibung egal) + + + Auf Kanäle einschränken + + + (nichts ausgewählt = alle gespeicherten Kanäle) + + + Format + + + Markdown + + + JSON + + + CSV + + + In Datei exportieren… + + + Export speichern + + + Export läuft im Hintergrund… + + + Export abgeschlossen, {0:N0} Nachrichten in {1} geschrieben + + + Export abgeschlossen, keine Nachricht passte zum Filter. + + + Export fehlgeschlagen, siehe /xllog + diff --git a/ChatTwo/Resources/HellionStrings.resx b/ChatTwo/Resources/HellionStrings.resx index 6814810..db4d961 100644 --- a/ChatTwo/Resources/HellionStrings.resx +++ b/ChatTwo/Resources/HellionStrings.resx @@ -216,4 +216,52 @@ Show wizard again + + Export (GDPR Art. 15 — right of access) + + + Export stored messages to Markdown, JSON or CSV. Use this to fulfil a request for access from someone whose messages you have, or to take your own history with you. + + + Last X days (0 = all time) + + + Sender contains (optional, case-insensitive) + + + Limit to channels + + + (none selected = all stored channels) + + + Format + + + Markdown + + + JSON + + + CSV + + + Export to file… + + + Save export + + + Export running in background… + + + Export complete: {0:N0} messages written to {1} + + + Export complete: no messages matched the filter. + + + Export failed, see /xllog + diff --git a/ChatTwo/Ui/SettingsTabs/Privacy.cs b/ChatTwo/Ui/SettingsTabs/Privacy.cs index 4ed8a97..e0502e5 100644 --- a/ChatTwo/Ui/SettingsTabs/Privacy.cs +++ b/ChatTwo/Ui/SettingsTabs/Privacy.cs @@ -1,4 +1,5 @@ using ChatTwo.Code; +using ChatTwo.Export; using ChatTwo.Privacy; using ChatTwo.Resources; using ChatTwo.Util; @@ -56,6 +57,13 @@ internal sealed class Privacy : ISettingsTab private bool RetentionRunning; + // Export form state + private int ExportRangeDays = 30; + private string ExportSenderSubstring = string.Empty; + private readonly HashSet ExportSelectedChannels = []; + private ExportFormat ExportFormat = ExportFormat.Markdown; + private bool ExportRunning; + public void Draw(bool changed) { if (ImGui.Button(HellionStrings.Wizard_Reopen_Button)) @@ -138,6 +146,142 @@ internal sealed class Privacy : ISettingsTab ImGui.Spacing(); DrawCleanupSection(); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + DrawExportSection(); + } + + private void DrawExportSection() + { + ImGui.TextUnformatted(HellionStrings.Export_Heading); + using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) + { + ImGuiUtil.HelpText(HellionStrings.Export_Help); + + ImGui.Spacing(); + + if (ImGui.InputInt(HellionStrings.Export_Range_Label, ref ExportRangeDays)) + ExportRangeDays = Math.Max(0, ExportRangeDays); + + ImGui.InputText(HellionStrings.Export_Sender_Label, ref ExportSenderSubstring, 256); + + using (var tree = ImRaii.TreeNode(HellionStrings.Export_Channels_Heading)) + { + if (tree.Success) + { + using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) + { + ImGuiUtil.HelpText(HellionStrings.Export_Channels_AllOff); + foreach (var (heading, types) in Groups) + { + using var subTree = ImRaii.TreeNode($"{heading()}##export-group-{heading()}"); + if (!subTree.Success) + continue; + + using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) + foreach (var type in types) + { + var enabled = ExportSelectedChannels.Contains(type); + if (ImGui.Checkbox($"{type}##export-{(int)type}", ref enabled)) + { + if (enabled) + ExportSelectedChannels.Add(type); + else + ExportSelectedChannels.Remove(type); + } + } + } + } + } + } + + ImGui.Spacing(); + ImGui.TextUnformatted(HellionStrings.Export_Format_Label); + ImGui.SameLine(); + var fmt = (int)ExportFormat; + if (ImGui.RadioButton(HellionStrings.Export_Format_Markdown, ref fmt, (int)ExportFormat.Markdown)) + ExportFormat = ExportFormat.Markdown; + ImGui.SameLine(); + if (ImGui.RadioButton(HellionStrings.Export_Format_Json, ref fmt, (int)ExportFormat.Json)) + ExportFormat = ExportFormat.Json; + ImGui.SameLine(); + if (ImGui.RadioButton(HellionStrings.Export_Format_Csv, ref fmt, (int)ExportFormat.Csv)) + ExportFormat = ExportFormat.Csv; + + ImGui.Spacing(); + + using (ImRaii.Disabled(ExportRunning)) + { + if (ImGui.Button(HellionStrings.Export_Button)) + PromptExport(); + } + + if (ExportRunning) + ImGuiUtil.HelpText(HellionStrings.Export_Running); + } + } + + private void PromptExport() + { + var defaultName = $"hellion-chat-export-{DateTimeOffset.Now:yyyyMMdd-HHmm}"; + var ext = ExportFormat.Extension(); + + Plugin.FileDialogManager.SaveFileDialog( + HellionStrings.Export_Dialog_Title, + ExportFormat.Filter(), + defaultName, + ext, + (success, path) => + { + if (!success || string.IsNullOrWhiteSpace(path)) + return; + StartExport(path); + }); + } + + private void StartExport(string path) + { + if (ExportRunning) + return; + ExportRunning = true; + + var types = ExportSelectedChannels.Count > 0 + ? ExportSelectedChannels.Select(t => (int)(ushort)t).ToList() + : null; + + DateTimeOffset? from = ExportRangeDays > 0 + ? DateTimeOffset.UtcNow.AddDays(-ExportRangeDays) + : null; + + var senderSubstring = string.IsNullOrWhiteSpace(ExportSenderSubstring) ? null : ExportSenderSubstring.Trim(); + var format = ExportFormat; + var filterDesc = new MessageExporter.FilterDescription(types, from, null, senderSubstring); + + new Thread(() => + { + try + { + using var enumerator = Plugin.MessageManager.Store.StreamForExport(types, from, null); + var written = MessageExporter.ExportToFile(path, format, enumerator, filterDesc); + + if (written > 0) + WrapperUtil.AddNotification(string.Format(HellionStrings.Export_Success, written, path), NotificationType.Success); + else + WrapperUtil.AddNotification(HellionStrings.Export_Empty, NotificationType.Info); + } + catch (Exception e) + { + Plugin.Log.Error(e, "Export failed"); + WrapperUtil.AddNotification(HellionStrings.Export_Error, NotificationType.Error); + } + finally + { + ExportRunning = false; + } + }) { IsBackground = true }.Start(); } private void DrawRetentionSection()