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.