From d59ee6222328076525127e85ff8b910d95471263 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 21:25:10 +0200 Subject: [PATCH] feat(privacy): reconnect the message export The exporter has worked since v1.4.8. The form that drives it went out with the old settings window in May, which left PRIVACY.md promising an access request the plugin had no way to answer. New section in the data and privacy tab: time range, sender substring, channel groups, format, and a save dialog. Form state lives in the tab, not the config -- a filter describes one action, and a stale "last 7 days, sender Mira" reappearing weeks later is a worse start than an empty form. StreamForExport now takes a caller-owned connection. The reader stays open for as long as the file is written, seconds to minutes on a large history, and chat keeps arriving throughout -- so the primary connection would be read here and written by UpsertMessage at once, and SqliteConnection is not thread-safe. Holding the read lock instead would trade that for freezing the game. ChannelGroups lifts the eight groups out of the deleted tab and finishes them: 37 of 89 channels belonged to no group and were therefore unreachable in the UI. Game Master channels follow ChatTypeExt.Parent(), so GmTell sits with the other tells rather than under system traffic -- an access request that quietly drops part of what it promises is the dangerous kind of gap. Also here: - OpenSecondaryConnection disposes on a failing pragma. Open can succeed and journal_mode=WAL still time out, and with Pooling=false the connection then survives until a finalizer reaches it. Affects the full-text rebuild worker too. - StreamForExport builds its logger before the reader, so a throwing CreateLogger cannot leave a reader nobody owns. - The export thread takes the gate itself instead of the caller taking it first. Acquiring before Start would strand the gate for the session if thread creation failed, and the gate also holds back the sweep. - Notifications are skipped once teardown has started. The thread has no cancellation path and finishing the file is right, but reporting it to a plugin that is gone is not. - Transient widget rows that return their value instead of saving it. Writing the config file on every keystroke of a sender filter would be both pointless and slow. - Five translated keys for "another database operation is running", in all 25 languages. Two of the four operation names have no trigger yet; they arrive with the cleanup and maintenance sections. --- HellionChat/MessageStore.cs | 84 +++-- HellionChat/Plugin.cs | 5 + HellionChat/PluginHostFactory.cs | 3 +- HellionChat/Privacy/ChannelGroups.cs | 156 +++++++++ .../Resources/HellionStrings.Designer.cs | 5 + HellionChat/Resources/HellionStrings.ca.resx | 17 +- HellionChat/Resources/HellionStrings.cs.resx | 17 +- HellionChat/Resources/HellionStrings.da.resx | 17 +- HellionChat/Resources/HellionStrings.de.resx | 17 +- HellionChat/Resources/HellionStrings.el.resx | 17 +- HellionChat/Resources/HellionStrings.es.resx | 17 +- HellionChat/Resources/HellionStrings.fi.resx | 17 +- HellionChat/Resources/HellionStrings.fr.resx | 17 +- HellionChat/Resources/HellionStrings.hu.resx | 17 +- HellionChat/Resources/HellionStrings.it.resx | 17 +- HellionChat/Resources/HellionStrings.ja.resx | 17 +- HellionChat/Resources/HellionStrings.ko.resx | 17 +- HellionChat/Resources/HellionStrings.nb.resx | 17 +- HellionChat/Resources/HellionStrings.nl.resx | 17 +- HellionChat/Resources/HellionStrings.pl.resx | 17 +- .../Resources/HellionStrings.pt-BR.resx | 17 +- .../Resources/HellionStrings.pt-PT.resx | 17 +- HellionChat/Resources/HellionStrings.resx | 17 +- HellionChat/Resources/HellionStrings.ro.resx | 17 +- HellionChat/Resources/HellionStrings.ru.resx | 17 +- HellionChat/Resources/HellionStrings.sv.resx | 17 +- HellionChat/Resources/HellionStrings.tr.resx | 17 +- HellionChat/Resources/HellionStrings.uk.resx | 17 +- .../Resources/HellionStrings.zh-Hans.resx | 17 +- .../Resources/HellionStrings.zh-Hant.resx | 17 +- .../Ui/Components/Settings/SettingsWidgets.cs | 114 +++++++ .../Settings/Tabs/DataPrivacyTab.cs | 315 +++++++++++++++++- HellionChat/Util/DbOperationGate.cs | 20 +- 33 files changed, 1056 insertions(+), 71 deletions(-) create mode 100644 HellionChat/Privacy/ChannelGroups.cs diff --git a/HellionChat/MessageStore.cs b/HellionChat/MessageStore.cs index b77701e..21c3bd0 100644 --- a/HellionChat/MessageStore.cs +++ b/HellionChat/MessageStore.cs @@ -710,9 +710,21 @@ internal class MessageStore : IDisposable internal SqliteConnection OpenSecondaryConnection() { var conn = new SqliteConnection(BuildConnectionString(DbPath)); - conn.Open(); - ApplyPragmas(conn); - return conn; + try + { + conn.Open(); + ApplyPragmas(conn); + return conn; + } + catch + { + // Open can succeed and ApplyPragmas still throw: journal_mode=WAL + // needs a lock and gives up after DefaultTimeout. Without this the + // connection is neither returned nor closed, and with Pooling=false + // it survives until a finalizer gets to it. + conn.Dispose(); + throw; + } } // Worker-only mutator. The bulk-insert worker is the single legitimate @@ -976,53 +988,57 @@ internal class MessageStore : IDisposable // Streams messages for export, sorted ascending by Date, excluding soft-deleted rows. // Optional filters: chatTypes, from/to inclusive date range. - // Caller is responsible for disposing the enumerator. - // Lock caveat: lock guards command setup and ExecuteReader; the returned - // MessageEnumerator is iterated lazily by the caller outside the lock. - // Acceptable for v1.4.8 -- DbViewer iterates on its filter-worker Task and - // any clash with UpsertMessage on the primary Connection is rare and - // serialised by SQLite's own connection-level lock. v1.5.x DI cycle should - // address this with a snapshot-to-list or connection pool. + // Caller is responsible for disposing the enumerator and the connection. + // + // Takes a caller-owned connection from OpenSecondaryConnection rather than + // using the primary one, and therefore takes no lock. The reader stays open + // for as long as the export writes, which is seconds to minutes on a large + // history, and chat keeps arriving throughout -- so the primary connection + // would be read here and written by UpsertMessage at the same time, and + // SqliteConnection is not thread-safe. Holding _readLock for the whole + // export would trade that for freezing the game instead. + // + // WAL gives readers their own snapshot, so a live write cannot tear the + // export mid-file either. internal MessageEnumerator StreamForExport( + SqliteConnection conn, IReadOnlyCollection? chatTypes, DateTimeOffset? from, DateTimeOffset? to ) { - lock (_readLock) - { - var cmd = Connection.CreateCommand(); + var cmd = conn.CreateCommand(); - var clauses = new List { "deleted = false" }; - if (chatTypes is { Count: > 0 }) - clauses.Add($"ChatType IN ({BindIntList(cmd, "exct", chatTypes)})"); - if (from is not null) - clauses.Add("Date >= $From"); - if (to is not null) - clauses.Add("Date <= $To"); + var clauses = new List { "deleted = false" }; + if (chatTypes is { Count: > 0 }) + clauses.Add($"ChatType IN ({BindIntList(cmd, "exct", chatTypes)})"); + if (from is not null) + clauses.Add("Date >= $From"); + if (to is not null) + clauses.Add("Date <= $To"); - cmd.CommandText = - @" + cmd.CommandText = + @" SELECT Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind, Sender, Content, SenderSource, ContentSource, ExtraChatChannel FROM messages WHERE " - + string.Join(" AND ", clauses) - + @" + + string.Join(" AND ", clauses) + + @" ORDER BY Date ASC;"; - cmd.CommandTimeout = 600; + 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()); + 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(), - _loggerFactory.CreateLogger() - ); - } + // Logger first: an argument list evaluates left to right, so a throwing + // CreateLogger -- which is what a disposed host gives you -- would leave + // an open reader that no MessageEnumerator owns. + var logger = _loggerFactory.CreateLogger(); + return new MessageEnumerator(cmd.ExecuteReader(), logger); } // Returns the most recent messages, oldest-first. diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 868a543..d77c654 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -169,6 +169,11 @@ public sealed class Plugin : IAsyncDalamudPlugin // because the tick reads it from a different thread than the writer. private volatile bool _isDisposing; + // Read by background workers that outlive a teardown -- the export thread + // finishes its file either way, but a notification for a plugin the user + // just unloaded belongs to nobody. + internal bool IsDisposing => _isDisposing; + // v1.9.0 B5: last full Draw() wall-time in ms, written once per frame at // the end of the UiBuilder.Draw handler. Covers the GlobalStyleScope push // and the font push (§7.5 First-Frame-HITCH must include atlas/style diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index d36659c..eee2b8d 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -234,7 +234,8 @@ internal static class PluginHostFactory )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.DataPrivacyTab( sp.GetRequiredService(), - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService>() )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.AboutTab( sp.GetRequiredService(), diff --git a/HellionChat/Privacy/ChannelGroups.cs b/HellionChat/Privacy/ChannelGroups.cs new file mode 100644 index 0000000..1adae18 --- /dev/null +++ b/HellionChat/Privacy/ChannelGroups.cs @@ -0,0 +1,156 @@ +using HellionChat.Code; +using HellionChat.Resources; + +namespace HellionChat.Privacy; + +// The eight buckets the privacy surface sorts channels into. Sixty checkboxes in +// one flat list is not a choice anybody makes; eight named groups is. +// +// Headings are functions, not strings, so a language switch at runtime relabels +// them on the next frame. A captured string would keep the language the window +// happened to be opened in. +// +// Lives here rather than in a tab because the retention and cleanup screens +// will need the same grouping, and three copies would drift the moment a patch +// adds a channel. Only the export uses it so far. +// +// Game Master channels follow ChatTypeExt.Parent(), which already pairs each of +// them with its player counterpart. Filing them all under system traffic reads +// tidier but puts GmTell -- a private two-person conversation -- outside the +// direct-messages group, and an access request that quietly drops part of what +// it promises is the dangerous kind of gap. +// +// Every ChatType belongs to exactly one group, and a build-suite test pins that. +// A channel in no group cannot be picked in any of these screens, which reads as +// a missing checkbox rather than as an omission. +internal static class ChannelGroups +{ + internal static readonly (Func Heading, ChatType[] Types)[] All = + [ + ( + () => HellionStrings.Privacy_Group_DirectMessages, + [ChatType.TellIncoming, ChatType.TellOutgoing, ChatType.GmTell] + ), + ( + () => HellionStrings.Privacy_Group_PartyAlliance, + [ + ChatType.Party, + ChatType.CrossParty, + ChatType.Alliance, + ChatType.PvpTeam, + ChatType.PvpTeamAnnouncement, + ChatType.PvpTeamLoginLogout, + ChatType.GmParty, + ] + ), + ( + () => HellionStrings.Privacy_Group_FreeCompany, + [ + ChatType.FreeCompany, + ChatType.FreeCompanyAnnouncement, + ChatType.FreeCompanyLoginLogout, + ChatType.GmFreeCompany, + ] + ), + ( + () => HellionStrings.Privacy_Group_Linkshells, + [ + ChatType.Linkshell1, + ChatType.Linkshell2, + ChatType.Linkshell3, + ChatType.Linkshell4, + ChatType.Linkshell5, + ChatType.Linkshell6, + ChatType.Linkshell7, + ChatType.Linkshell8, + ChatType.GmLinkshell1, + ChatType.GmLinkshell2, + ChatType.GmLinkshell3, + ChatType.GmLinkshell4, + ChatType.GmLinkshell5, + ChatType.GmLinkshell6, + ChatType.GmLinkshell7, + ChatType.GmLinkshell8, + ] + ), + ( + () => HellionStrings.Privacy_Group_CrossLinkshells, + [ + ChatType.CrossLinkshell1, + ChatType.CrossLinkshell2, + ChatType.CrossLinkshell3, + ChatType.CrossLinkshell4, + ChatType.CrossLinkshell5, + ChatType.CrossLinkshell6, + ChatType.CrossLinkshell7, + ChatType.CrossLinkshell8, + ] + ), + ( + () => HellionStrings.Privacy_Group_ExtraChat, + [ + ChatType.ExtraChatLinkshell1, + ChatType.ExtraChatLinkshell2, + ChatType.ExtraChatLinkshell3, + ChatType.ExtraChatLinkshell4, + ChatType.ExtraChatLinkshell5, + ChatType.ExtraChatLinkshell6, + ChatType.ExtraChatLinkshell7, + ChatType.ExtraChatLinkshell8, + ] + ), + ( + () => HellionStrings.Privacy_Group_PublicChat, + [ + ChatType.Say, + ChatType.Shout, + ChatType.Yell, + ChatType.NoviceNetwork, + ChatType.NoviceNetworkSystem, + ChatType.CustomEmote, + ChatType.StandardEmote, + ChatType.GmSay, + ChatType.GmShout, + ChatType.GmYell, + ChatType.GmNoviceNetwork, + ] + ), + ( + () => HellionStrings.Privacy_Group_SystemLogs, + [ + ChatType.System, + ChatType.Notice, + ChatType.Urgent, + ChatType.Echo, + ChatType.NpcDialogue, + ChatType.NpcAnnouncement, + ChatType.LootNotice, + ChatType.LootRoll, + ChatType.RetainerSale, + ChatType.Crafting, + ChatType.Gathering, + ChatType.Sign, + ChatType.RandomNumber, + ChatType.MessageBook, + ChatType.Alarm, + ChatType.Orchestrion, + ChatType.GlamourNotifications, + ChatType.PeriodicRecruitmentNotification, + ChatType.GatheringSystem, + ChatType.Progress, + ChatType.Debug, + ChatType.Error, + ChatType.Item, + ChatType.Action, + ChatType.BattleSystem, + ChatType.Damage, + ChatType.Healing, + ChatType.Miss, + ChatType.GainBuff, + ChatType.GainDebuff, + ChatType.LoseBuff, + ChatType.LoseDebuff, + ] + ), + ]; +} diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index f2956b1..c57dec5 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -293,6 +293,11 @@ internal class HellionStrings internal static string Settings_Database_Storage_Heading => Get(nameof(Settings_Database_Storage_Heading)); internal static string Settings_Database_Viewer_Heading => Get(nameof(Settings_Database_Viewer_Heading)); internal static string Settings_Database_Stats_Heading => Get(nameof(Settings_Database_Stats_Heading)); + internal static string Settings_Database_Busy => Get(nameof(Settings_Database_Busy)); + internal static string Settings_Database_Op_RetentionSweep => Get(nameof(Settings_Database_Op_RetentionSweep)); + internal static string Settings_Database_Op_Export => Get(nameof(Settings_Database_Op_Export)); + internal static string Settings_Database_Op_Cleanup => Get(nameof(Settings_Database_Op_Cleanup)); + internal static string Settings_Database_Op_Clear => Get(nameof(Settings_Database_Op_Clear)); // Hellion Chat — Default tab presets (channel-themed) internal static string Tabs_Presets_System => Get(nameof(Tabs_Presets_System)); diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index f3dab9a..73d9adb 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1157,4 +1157,19 @@ Retorna aquesta pestanya a la finestra principal - \ No newline at end of file + + Hi ha una altra operació de base de dades en curs: {0} + + + neteja de retenció + + + exportació + + + neteja + + + esborrat de l'historial + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index cadaf6d..08fa793 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1156,4 +1156,19 @@ Vrátit tuto kartu do hlavního okna - \ No newline at end of file + + Právě probíhá jiná operace s databází: {0} + + + úklid podle doby uchování + + + export + + + úklid + + + mazání historie + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index 4adc356..9f028c9 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1156,4 +1156,19 @@ Send denne fane tilbage til hovedvinduet - \ No newline at end of file + + En anden databasehandling kører: {0} + + + oprydning efter opbevaringsregler + + + eksport + + + oprydning + + + sletning af historikken + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index c6dff2a..59b1471 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -1151,4 +1151,19 @@ Diesen Tab ins Hauptfenster zurückholen - \ No newline at end of file + + Es läuft gerade eine andere Datenbankoperation: {0} + + + Aufbewahrungslauf + + + Export + + + Bereinigung + + + Löschen des Verlaufs + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index d282972..35e5dfb 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1156,4 +1156,19 @@ Επιστροφή αυτής της καρτέλας στο κύριο παράθυρο - \ No newline at end of file + + Εκτελείται ήδη μια άλλη λειτουργία βάσης δεδομένων: {0} + + + εκκαθάριση διατήρησης + + + εξαγωγή + + + εκκαθάριση + + + διαγραφή ιστορικού + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index bd5ae8a..ed433c9 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1157,4 +1157,19 @@ Devolver esta pestaña a la ventana principal - \ No newline at end of file + + Ya se está ejecutando otra operación de base de datos: {0} + + + limpieza de retención + + + exportación + + + limpieza + + + borrado del historial + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index cb9aeb9..f28a7ee 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1156,4 +1156,19 @@ Palauta tämä välilehti pääikkunaan - \ No newline at end of file + + Toinen tietokantatoiminto on käynnissä: {0} + + + säilytysajo + + + vienti + + + siivous + + + historian tyhjennys + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index 76947ba..f6a634f 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -1157,4 +1157,19 @@ Renvoyer cet onglet vers la fenêtre principale - \ No newline at end of file + + Une autre opération de base de données est en cours : {0} + + + nettoyage de rétention + + + export + + + nettoyage + + + effacement de l'historique + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index e333950..2583443 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1156,4 +1156,19 @@ Lap visszahelyezése a főablakba - \ No newline at end of file + + Már fut egy másik adatbázisművelet: {0} + + + megőrzési takarítás + + + exportálás + + + takarítás + + + az előzmények törlése + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index 9d685db..a55e756 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1157,4 +1157,19 @@ Riporta questa scheda nella finestra principale - \ No newline at end of file + + È già in corso un'altra operazione sul database: {0} + + + pulizia di conservazione + + + esportazione + + + pulizia + + + cancellazione della cronologia + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index d5c382e..4fda299 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -1157,4 +1157,19 @@ このタブをメインウィンドウに戻す - \ No newline at end of file + + 別のデータベース処理を実行中です: {0} + + + 保存期間の整理 + + + エクスポート + + + クリーンアップ + + + 履歴の削除 + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index 136321f..ce3f4cf 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1157,4 +1157,19 @@ 이 탭을 기본 창으로 되돌리기 - \ No newline at end of file + + 다른 데이터베이스 작업이 실행 중입니다: {0} + + + 보존 기간 정리 + + + 내보내기 + + + 정리 + + + 기록 삭제 + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index 4f8d7df..b4e659c 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1156,4 +1156,19 @@ Send denne fanen tilbake til hovedvinduet - \ No newline at end of file + + En annen databaseoperasjon kjører: {0} + + + oppbevaringsopprydding + + + eksport + + + opprydding + + + sletting av historikken + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index cc90d4e..13ec0ac 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1157,4 +1157,19 @@ Dit tabblad terugzetten in het hoofdvenster - \ No newline at end of file + + Er wordt al een andere databasebewerking uitgevoerd: {0} + + + bewaartermijnopschoning + + + export + + + opschoning + + + wissen van de geschiedenis + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index b4a318d..783f642 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1156,4 +1156,19 @@ Przywróć tę kartę do okna głównego - \ No newline at end of file + + Trwa już inna operacja na bazie danych: {0} + + + porządkowanie według czasu przechowywania + + + eksport + + + porządkowanie + + + usuwanie historii + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index be06fb1..4f23d63 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1157,4 +1157,19 @@ Devolver esta aba à janela principal - \ No newline at end of file + + Outra operação de banco de dados está em andamento: {0} + + + limpeza de retenção + + + exportação + + + limpeza + + + exclusão do histórico + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index 2fb94f0..07a382b 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1156,4 +1156,19 @@ Devolver este separador à janela principal - \ No newline at end of file + + Está em curso outra operação de base de dados: {0} + + + limpeza de retenção + + + exportação + + + limpeza + + + eliminação do histórico + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index 3346cf6..fc01aec 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1177,4 +1177,19 @@ Return this tab to the main window - \ No newline at end of file + + Another database operation is running: {0} + + + retention sweep + + + export + + + cleanup + + + clearing the history + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index dbf9ea2..fc18940 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1157,4 +1157,19 @@ Readu această filă în fereastra principală - \ No newline at end of file + + Rulează deja o altă operațiune pe baza de date: {0} + + + curățarea după perioada de păstrare + + + export + + + curățare + + + ștergerea istoricului + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index 4519c3e..2ea927c 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1157,4 +1157,19 @@ Вернуть эту вкладку в главное окно - \ No newline at end of file + + Уже выполняется другая операция с базой данных: {0} + + + очистка по сроку хранения + + + экспорт + + + очистка + + + удаление истории + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index c2fffbc..3c3e042 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1157,4 +1157,19 @@ Återför den här fliken till huvudfönstret - \ No newline at end of file + + En annan databasåtgärd pågår: {0} + + + gallring enligt lagringstid + + + export + + + rensning + + + radering av historiken + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index fff6706..587bd23 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1156,4 +1156,19 @@ Bu sekmeyi ana pencereye geri al - \ No newline at end of file + + Başka bir veritabanı işlemi çalışıyor: {0} + + + saklama temizliği + + + dışa aktarma + + + temizlik + + + geçmişin silinmesi + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index d4e3acf..06c1328 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1156,4 +1156,19 @@ Повернути цю вкладку до головного вікна - \ No newline at end of file + + Уже виконується інша операція з базою даних: {0} + + + очищення за строком зберігання + + + експорт + + + очищення + + + видалення історії + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index fddbc58..c74a6dc 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -1157,4 +1157,19 @@ 将此标签页放回主窗口 - \ No newline at end of file + + 另一项数据库操作正在进行:{0} + + + 保留期清理 + + + 导出 + + + 清理 + + + 清空历史记录 + + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index 87f88c3..ee01999 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -1157,4 +1157,19 @@ 將此分頁移回主視窗 - \ No newline at end of file + + 另一項資料庫作業正在進行:{0} + + + 保留期清理 + + + 匯出 + + + 清理 + + + 清除歷史紀錄 + + \ No newline at end of file diff --git a/HellionChat/Ui/Components/Settings/SettingsWidgets.cs b/HellionChat/Ui/Components/Settings/SettingsWidgets.cs index 826ec2d..bb15801 100644 --- a/HellionChat/Ui/Components/Settings/SettingsWidgets.cs +++ b/HellionChat/Ui/Components/Settings/SettingsWidgets.cs @@ -1,5 +1,6 @@ using System.Numerics; using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Utility.Raii; using HellionChat.Themes; using HellionChat.Ui.StyleEngine; using HellionChat.Ui.StyleEngine.Widgets; @@ -242,6 +243,119 @@ internal sealed class SettingsWidgets _plugin.SaveConfig(); } + // Transient rows for form state that never reaches the config: an export + // filter, a cleanup preview. They hand the value back instead of taking a + // setter, and they do not call SaveConfig -- there is nothing to save, and + // writing the config file on every keystroke of a sender filter would be + // both pointless and slow. + internal bool ToggleRow(uint id, string label, string? description, bool value) + { + EnsureFrame(); + var hit = false; + + var rowClicked = SettingRow.Draw( + id, + label, + description, + _row, + ctx => + { + var size = ToggleSwitch.CalcSize(); + var pos = ctx.AlignRight(size); + ImGui.SetCursorScreenPos(pos); + if (ImGui.InvisibleButton($"##hc-sw-{id}", size)) + hit = true; + ToggleSwitch.Draw(id, pos, value, _toggle); + } + ); + + return rowClicked || hit ? !value : value; + } + + internal string TextRow(uint id, string label, string? description, string value) + { + EnsureFrame(); + var current = value; + SettingRow.Draw( + id, + label, + description, + _row, + ctx => + { + // PushId rather than an interpolated label: the binding only + // offers a ref-string InputText for a literal label, and the ID + // stack separates the rows just as well. RAII because a throw + // inside InputText would otherwise leave the stack unbalanced + // and trip the assert in End(). + using var scope = ImRaii.PushId((int)id); + ImGui.SetNextItemWidth(ctx.ControlWidth); + // 511, not 512: the binding reserves maxLength + 1 and rents from + // the array pool once that reaches 512. + ImGui.InputText("##hc-tr", ref current, 511); + } + ); + return current; + } + + internal int SliderIntRow( + uint id, + string label, + string? description, + int value, + int min, + int max + ) + { + EnsureFrame(); + var current = value; + SettingRow.Draw( + id, + label, + description, + _row, + ctx => + { + ImGui.SetNextItemWidth(ctx.ControlWidth); + ImGui.SliderInt($"##hc-si-{id}", ref current, min, max, "%d"); + } + ); + return current; + } + + internal int SegmentRow( + uint id, + string label, + string? description, + string[] labels, + int selected + ) + { + EnsureFrame(); + + // Clamped rather than trusted: the generic overload derives the index + // from the value and falls back to 0, this one takes whatever the caller + // passes. Array.IndexOf returns -1 on a miss, and the caller then indexes + // its value array with the result. + var current = Math.Clamp(selected, 0, Math.Max(0, labels.Length - 1)); + var picked = current; + var colors = _segmented; + + SettingRow.Draw( + id, + label, + description, + _row, + ctx => + { + ImGui.SetCursorScreenPos(new Vector2(ctx.ControlOrigin.X, ctx.ControlOrigin.Y)); + picked = SegmentedControl.Draw(id, ctx.ControlWidth, labels, current, colors); + } + ); + + return picked; + } + internal void Toggle(string label, Func get, Action set) { var current = get(); diff --git a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs index 53e2d94..25578fd 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs @@ -1,18 +1,52 @@ using Dalamud.Bindings.ImGui; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.Utility.Raii; using HellionChat.Code; +using HellionChat.Export; +using HellionChat.Privacy; using HellionChat.Resources; using HellionChat.Util; +using Microsoft.Extensions.Logging; namespace HellionChat.Ui.Components.Settings.Tabs; internal sealed class DataPrivacyTab { private readonly Plugin _plugin; + private readonly ILogger _logger; private readonly SettingsWidgets _w; - public DataPrivacyTab(Plugin plugin, Ui.StyleEngine.TokenResolver resolver) + // Export form state, deliberately not in the config. A filter describes one + // action, not a preference; "last 7 days, sender Mira" reappearing three + // weeks later is a worse starting point than an empty form. + private int _exportRangeDays = 30; + private string _exportSender = string.Empty; + private readonly HashSet _exportChannels = []; + private ExportFormat _exportFormat = ExportFormat.Markdown; + + // Covers the file dialog too, not just the worker: without it a second click + // opens a second dialog, and the two workers then race for the gate so one + // of them reports a failure the user did not cause. + private bool _exportDialogOpen; + + // Written by the export thread, read by the draw thread every frame. + private volatile bool _exportRunning; + + private static readonly ExportFormat[] FormatValues = EnumValues.All; + + // Five years. The old form had no upper bound at all, and retention caps at + // 365 days, but a history kept forever outlives that by a lot. Ctrl+Click on + // the slider still types an exact value. + private const int MaxExportRangeDays = 1825; + + public DataPrivacyTab( + Plugin plugin, + Ui.StyleEngine.TokenResolver resolver, + ILogger logger + ) { _plugin = plugin; + _logger = logger; _w = new SettingsWidgets(plugin, new SettingsPalette(resolver)); } @@ -80,6 +114,17 @@ internal sealed class DataPrivacyTab DrawPrivacyPersistChannelsGrid(); } + if ( + _w.Section( + ImGui.GetID("privacy.export"u8), + HellionStrings.Settings_Section_Export, + open: false + ) + ) + { + DrawExportSection(); + } + if (_w.Section(ImGui.GetID("privacy.telemetry"u8), "Telemetry", open: false)) { // Read-only placeholder; no telemetry is wired in v1.7.0. Do not @@ -135,6 +180,274 @@ internal sealed class DataPrivacyTab } } + // GDPR Art. 15. The backend has been able to do this since v1.4.8; the form + // that drives it was removed with the old settings window in May, which left + // the promise in PRIVACY.md without a way to keep it. + private void DrawExportSection() + { + ImGuiUtil.HelpText(HellionStrings.Export_Help); + + _exportRangeDays = _w.SliderIntRow( + ImGui.GetID("privacy.export.range"u8), + HellionStrings.Export_Range_Label, + null, + _exportRangeDays, + 0, + MaxExportRangeDays + ); + + _exportSender = _w.TextRow( + ImGui.GetID("privacy.export.sender"u8), + HellionStrings.Export_Sender_Label, + null, + _exportSender + ); + + var picked = _w.SegmentRow( + ImGui.GetID("privacy.export.format"u8), + HellionStrings.Export_Format_Label, + null, + Array.ConvertAll(FormatValues, FormatLabel), + Array.IndexOf(FormatValues, _exportFormat) + ); + _exportFormat = FormatValues[picked]; + + DrawExportChannels(); + + // Read once: IsBusy and Current are two reads of the same volatile + // field, and between them the operation can finish -- which would print + // "another operation is running: " with nothing after the colon. + var current = _plugin.DbOperations.Current; + var blocked = _exportDialogOpen || _exportRunning || current != DbOperation.None; + + ImGui.Spacing(); + using (ImRaii.Disabled(blocked)) + { + if (ImGui.Button(HellionStrings.Export_Button)) + PromptExport(); + } + + if (_exportRunning) + ImGuiUtil.HelpText(HellionStrings.Export_Running); + else if (current != DbOperation.None) + ImGuiUtil.HelpText( + string.Format(HellionStrings.Settings_Database_Busy, OperationName(current)) + ); + } + + // Whole groups rather than sixty individual channels. Nobody makes a + // sixty-way choice, and the eight groups are the same ones the privacy + // wizard already uses, so the two screens describe the world the same way. + private void DrawExportChannels() + { + ImGui.Spacing(); + ImGui.TextUnformatted(HellionStrings.Export_Channels_Heading); + ImGuiUtil.HelpText(HellionStrings.Export_Channels_AllOff); + + for (var i = 0; i < ChannelGroups.All.Length; i++) + { + var (heading, types) = ChannelGroups.All[i]; + + // foreach rather than types.All(_exportChannels.Contains): an + // instance method group allocates a delegate on every frame. + // + // Invariant this relies on: the set only ever changes in whole + // groups, below. A half-filled group would read as off here and the + // toggle could then only complete it, never clear it. + var selected = true; + foreach (var type in types) + { + if (_exportChannels.Contains(type)) + continue; + selected = false; + break; + } + + var next = _w.ToggleRow( + ImGui.GetID($"privacy.export.group.{i}"), + heading(), + Preview(types), + selected + ); + + if (next == selected) + continue; + + foreach (var type in types) + { + if (next) + _exportChannels.Add(type); + else + _exportChannels.Remove(type); + } + } + } + + // First few channel names so a group heading is not the only thing a user + // has to go on. Names are localised and the language can change at runtime, + // so this is built per frame rather than cached. + private static string Preview(ChatType[] types) + { + const int Shown = 3; + var names = string.Join(", ", types.Take(Shown).Select(t => t.Name())); + return types.Length > Shown ? names + ", …" : names; + } + + // Mapped per value rather than by position: a segmented control takes its + // labels as an array, and pairing them by index would relabel every segment + // the day somebody reorders the enum. + private static string FormatLabel(ExportFormat format) => + format switch + { + ExportFormat.Markdown => HellionStrings.Export_Format_Markdown, + ExportFormat.Json => HellionStrings.Export_Format_Json, + ExportFormat.Csv => HellionStrings.Export_Format_Csv, + _ => format.ToString(), + }; + + private static string OperationName(DbOperation op) => + op switch + { + DbOperation.RetentionSweep => HellionStrings.Settings_Database_Op_RetentionSweep, + DbOperation.Export => HellionStrings.Settings_Database_Op_Export, + DbOperation.Cleanup => HellionStrings.Settings_Database_Op_Cleanup, + DbOperation.Clear => HellionStrings.Settings_Database_Op_Clear, + _ => string.Empty, + }; + + // The whole filter is captured here, not read again in the callback. The + // dialog is modal to itself but not to the settings window, so the format + // segment and the channel toggles stay live while it is open. + private void PromptExport() + { + var format = _exportFormat; + var types = + _exportChannels.Count > 0 ? _exportChannels.Select(t => (int)(ushort)t).ToList() : null; + DateTimeOffset? from = + _exportRangeDays > 0 ? DateTimeOffset.UtcNow.AddDays(-_exportRangeDays) : null; + var sender = string.IsNullOrWhiteSpace(_exportSender) ? null : _exportSender.Trim(); + + _exportDialogOpen = true; + + Plugin.FileDialogManager.SaveFileDialog( + HellionStrings.Export_Dialog_Title, + format.Filter(), + $"hellion-chat-export-{DateTimeOffset.Now:yyyyMMdd-HHmm}", + format.Extension(), + (ok, path) => + { + _exportDialogOpen = false; + if (ok && !string.IsNullOrWhiteSpace(path)) + StartExport(path, format, types, from, sender); + }, + null, + isModal: true + ); + } + + private void StartExport( + string path, + ExportFormat format, + List? types, + DateTimeOffset? from, + string? sender + ) + { + _exportRunning = true; + var filter = new MessageExporter.FilterDescription(types, from, null, sender); + + var worker = new Thread(() => + { + try + { + // Taken inside the thread, the way the retention sweep does it. + // Acquiring before Start would strand the gate for the rest of + // the session if thread creation failed, and the gate also holds + // back the unattended sweep. + // + // Refused rather than queued: by the time a sweep finishes, an + // export the user started minutes ago and forgot about would + // write a file nobody is waiting for any more. + if (!_plugin.DbOperations.TryBegin(DbOperation.Export)) + { + Notify( + string.Format( + HellionStrings.Settings_Database_Busy, + OperationName(_plugin.DbOperations.Current) + ), + NotificationType.Warning + ); + return; + } + + try + { + // Own connection, so the reader can stay open for the length + // of the write without sharing the primary one with + // UpsertMessage. + using var conn = _plugin.MessageManager.Store.OpenSecondaryConnection(); + using var rows = _plugin.MessageManager.Store.StreamForExport( + conn, + types, + from, + null + ); + + var written = MessageExporter.ExportToFile(path, format, rows, filter); + + if (written > 0) + Notify( + string.Format(HellionStrings.Export_Success, written, path), + NotificationType.Success + ); + else + Notify(HellionStrings.Export_Empty, NotificationType.Info); + } + finally + { + _plugin.DbOperations.End(); + } + } + catch (Exception e) + { + _logger.LogError(e, "Export failed"); + Notify(HellionStrings.Export_Error, NotificationType.Error); + } + finally + { + _exportRunning = false; + } + }) + { + IsBackground = true, + Name = "HellionChat Export", + }; + + try + { + worker.Start(); + } + catch (Exception e) + { + // The thread never ran, so nothing will clear the flag for us. + _exportRunning = false; + _logger.LogError(e, "Could not start the export thread"); + WrapperUtil.AddNotification(HellionStrings.Export_Error, NotificationType.Error); + } + } + + // The export thread outlives a plugin teardown -- it is a background thread + // with no cancellation path, and finishing the file the user asked for is + // the right call. Reporting it afterwards is not: the notification would be + // filed against a plugin that is already gone. + private void Notify(string message, NotificationType type) + { + if (_plugin.IsDisposing) + return; + + WrapperUtil.AddNotification(message, type); + } + private void DrawPrivacyPersistChannelsGrid() { // HashSet: iterate Enum.GetValues() for stable diff --git a/HellionChat/Util/DbOperationGate.cs b/HellionChat/Util/DbOperationGate.cs index da720aa..744f354 100644 --- a/HellionChat/Util/DbOperationGate.cs +++ b/HellionChat/Util/DbOperationGate.cs @@ -14,17 +14,17 @@ internal enum DbOperation // frame. Generalises the retention-sweep lock, which already did exactly this // for a single case. // -// The reason it has to cover all of them together, not one each: an export can -// leave a reader open on the primary connection outside _readLock, because -// StreamForExport hands back an enumerator its caller consumes lazily. A VACUUM -// starting while that reader lives fails immediately with "cannot VACUUM - SQL -// statements in progress". +// The reason it has to cover all of them together, not one each: an export holds +// a reader open for as long as it writes, and VACUUM needs the database to +// itself. Since v1.12.0 that reader sits on its own connection, so the clash +// surfaces as SQLITE_BUSY and a five-second timeout rather than the immediate +// SQLITE_ERROR a shared connection produced -- but a VACUUM that gives up after +// five seconds still fails, and it fails after the DELETE has committed. The +// rows are gone and the file is not compacted. PerformMaintenance runs VACUUM, +// REINDEX and ANALYZE as one batch, so the latter two never run either. // -// That is SQLITE_ERROR, not SQLITE_BUSY, so no timeout applies and no retry -// helps -- busy handling only covers contention between different connections. -// And it fails after the DELETE has committed, so the rows are gone and the file -// is not compacted. PerformMaintenance runs VACUUM, REINDEX and ANALYZE as one -// batch, so the latter two never run either. +// Serialising the operations removes the question instead of tuning timeouts +// around it. // // Pure state machine, no ImGui and no database, so the build suite can pin the // transitions without standing up either.