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.
This commit is contained in:
2026-08-18 21:25:10 +02:00
parent 90bf986f76
commit d59ee62223
33 changed files with 1056 additions and 71 deletions
+50 -34
View File
@@ -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<int>? chatTypes,
DateTimeOffset? from,
DateTimeOffset? to
)
{
lock (_readLock)
{
var cmd = Connection.CreateCommand();
var cmd = conn.CreateCommand();
var clauses = new List<string> { "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<string> { "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<MessageEnumerator>()
);
}
// 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<MessageEnumerator>();
return new MessageEnumerator(cmd.ExecuteReader(), logger);
}
// Returns the most recent messages, oldest-first.
+5
View File
@@ -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
+2 -1
View File
@@ -234,7 +234,8 @@ internal static class PluginHostFactory
));
services.AddSingleton(sp => new Ui.Components.Settings.Tabs.DataPrivacyTab(
sp.GetRequiredService<Plugin>(),
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>()
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>(),
sp.GetRequiredService<ILogger<Ui.Components.Settings.Tabs.DataPrivacyTab>>()
));
services.AddSingleton(sp => new Ui.Components.Settings.Tabs.AboutTab(
sp.GetRequiredService<FontManager>(),
+156
View File
@@ -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<string> 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,
]
),
];
}
+5
View File
@@ -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));
+16 -1
View File
@@ -1157,4 +1157,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Retorna aquesta pestanya a la finestra principal</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>Hi ha una altra operació de base de dades en curs: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>neteja de retenció</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>exportació</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>neteja</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>esborrat de l'historial</value>
</data>
</root>
+16 -1
View File
@@ -1156,4 +1156,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Vrátit tuto kartu do hlavního okna</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>Právě probíhá jiná operace s databází: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>úklid podle doby uchování</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>export</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>úklid</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>mazání historie</value>
</data>
</root>
+16 -1
View File
@@ -1156,4 +1156,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Send denne fane tilbage til hovedvinduet</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>En anden databasehandling kører: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>oprydning efter opbevaringsregler</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>eksport</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>oprydning</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>sletning af historikken</value>
</data>
</root>
+16 -1
View File
@@ -1151,4 +1151,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Diesen Tab ins Hauptfenster zurückholen</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>Es läuft gerade eine andere Datenbankoperation: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>Aufbewahrungslauf</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>Export</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>Bereinigung</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>Löschen des Verlaufs</value>
</data>
</root>
+16 -1
View File
@@ -1156,4 +1156,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Επιστροφή αυτής της καρτέλας στο κύριο παράθυρο</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>Εκτελείται ήδη μια άλλη λειτουργία βάσης δεδομένων: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>εκκαθάριση διατήρησης</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>εξαγωγή</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>εκκαθάριση</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>διαγραφή ιστορικού</value>
</data>
</root>
+16 -1
View File
@@ -1157,4 +1157,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Devolver esta pestaña a la ventana principal</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>Ya se está ejecutando otra operación de base de datos: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>limpieza de retención</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>exportación</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>limpieza</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>borrado del historial</value>
</data>
</root>
+16 -1
View File
@@ -1156,4 +1156,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Palauta tämä välilehti pääikkunaan</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>Toinen tietokantatoiminto on käynnissä: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>säilytysajo</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>vienti</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>siivous</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>historian tyhjennys</value>
</data>
</root>
+16 -1
View File
@@ -1157,4 +1157,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Renvoyer cet onglet vers la fenêtre principale</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>Une autre opération de base de données est en cours : {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>nettoyage de rétention</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>export</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>nettoyage</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>effacement de l'historique</value>
</data>
</root>
+16 -1
View File
@@ -1156,4 +1156,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Lap visszahelyezése a főablakba</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>Már fut egy másik adatbázisművelet: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>megőrzési takarítás</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>exportálás</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>takarítás</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>az előzmények törlése</value>
</data>
</root>
+16 -1
View File
@@ -1157,4 +1157,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Riporta questa scheda nella finestra principale</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>È già in corso un'altra operazione sul database: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>pulizia di conservazione</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>esportazione</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>pulizia</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>cancellazione della cronologia</value>
</data>
</root>
+16 -1
View File
@@ -1157,4 +1157,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>このタブをメインウィンドウに戻す</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>別のデータベース処理を実行中です: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>保存期間の整理</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>エクスポート</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>クリーンアップ</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>履歴の削除</value>
</data>
</root>
+16 -1
View File
@@ -1157,4 +1157,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>이 탭을 기본 창으로 되돌리기</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>다른 데이터베이스 작업이 실행 중입니다: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>보존 기간 정리</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>내보내기</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>정리</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>기록 삭제</value>
</data>
</root>
+16 -1
View File
@@ -1156,4 +1156,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Send denne fanen tilbake til hovedvinduet</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>En annen databaseoperasjon kjører: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>oppbevaringsopprydding</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>eksport</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>opprydding</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>sletting av historikken</value>
</data>
</root>
+16 -1
View File
@@ -1157,4 +1157,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Dit tabblad terugzetten in het hoofdvenster</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>Er wordt al een andere databasebewerking uitgevoerd: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>bewaartermijnopschoning</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>export</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>opschoning</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>wissen van de geschiedenis</value>
</data>
</root>
+16 -1
View File
@@ -1156,4 +1156,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Przywróć tę kartę do okna głównego</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>Trwa już inna operacja na bazie danych: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>porządkowanie według czasu przechowywania</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>eksport</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>porządkowanie</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>usuwanie historii</value>
</data>
</root>
@@ -1157,4 +1157,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Devolver esta aba à janela principal</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>Outra operação de banco de dados está em andamento: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>limpeza de retenção</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>exportação</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>limpeza</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>exclusão do histórico</value>
</data>
</root>
@@ -1156,4 +1156,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Devolver este separador à janela principal</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>Está em curso outra operação de base de dados: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>limpeza de retenção</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>exportação</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>limpeza</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>eliminação do histórico</value>
</data>
</root>
+16 -1
View File
@@ -1177,4 +1177,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Return this tab to the main window</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>Another database operation is running: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>retention sweep</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>export</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>cleanup</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>clearing the history</value>
</data>
</root>
+16 -1
View File
@@ -1157,4 +1157,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Readu această filă în fereastra principală</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>Rulează deja o altă operațiune pe baza de date: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>curățarea după perioada de păstrare</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>export</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>curățare</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>ștergerea istoricului</value>
</data>
</root>
+16 -1
View File
@@ -1157,4 +1157,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Вернуть эту вкладку в главное окно</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>Уже выполняется другая операция с базой данных: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>очистка по сроку хранения</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>экспорт</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>очистка</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>удаление истории</value>
</data>
</root>
+16 -1
View File
@@ -1157,4 +1157,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Återför den här fliken till huvudfönstret</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>En annan databasåtgärd pågår: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>gallring enligt lagringstid</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>export</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>rensning</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>radering av historiken</value>
</data>
</root>
+16 -1
View File
@@ -1156,4 +1156,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Bu sekmeyi ana pencereye geri al</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>Başka bir veritabanı işlemi çalışıyor: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>saklama temizliği</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>dışa aktarma</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>temizlik</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>geçmişin silinmesi</value>
</data>
</root>
+16 -1
View File
@@ -1156,4 +1156,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>Повернути цю вкладку до головного вікна</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>Уже виконується інша операція з базою даних: {0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>очищення за строком зберігання</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>експорт</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>очищення</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>видалення історії</value>
</data>
</root>
@@ -1157,4 +1157,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>将此标签页放回主窗口</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>另一项数据库操作正在进行:{0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>保留期清理</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>导出</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>清理</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>清空历史记录</value>
</data>
</root>
@@ -1157,4 +1157,19 @@
</data>
<data name="InputBar_PopIn_Tooltip" xml:space="preserve"><value>將此分頁移回主視窗</value>
</data>
</root>
<data name="Settings_Database_Busy" xml:space="preserve">
<value>另一項資料庫作業正在進行:{0}</value>
</data>
<data name="Settings_Database_Op_RetentionSweep" xml:space="preserve">
<value>保留期清理</value>
</data>
<data name="Settings_Database_Op_Export" xml:space="preserve">
<value>匯出</value>
</data>
<data name="Settings_Database_Op_Cleanup" xml:space="preserve">
<value>清理</value>
</data>
<data name="Settings_Database_Op_Clear" xml:space="preserve">
<value>清除歷史紀錄</value>
</data>
</root>
@@ -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<bool> get, Action<bool> set)
{
var current = get();
@@ -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<DataPrivacyTab> _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<ChatType> _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<ExportFormat>.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<DataPrivacyTab> 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<int>? 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<ChatType>: iterate Enum.GetValues<ChatType>() for stable
+10 -10
View File
@@ -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.