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()