diff --git a/HellionChat/Export/MessageExporter.cs b/HellionChat/Export/MessageExporter.cs index 3f33006..c6ddb47 100644 --- a/HellionChat/Export/MessageExporter.cs +++ b/HellionChat/Export/MessageExporter.cs @@ -92,6 +92,18 @@ internal static class MessageExporter }; } + // An export that matched nothing does not replace anything. The + // file still has a header and a footer, so moving it would put a + // near-empty file where the user's previous export was -- and then + // report "no message matched the filter", which reads as "nothing + // happened". Dalamud's save dialog has no overwrite confirmation to + // fall back on. + if (written == 0) + { + TryDeleteTemp(temp); + return 0; + } + File.Move(temp, path, overwrite: true); return written; } @@ -225,8 +237,13 @@ internal static class MessageExporter w.Write($",\"date\":\"{m.Date.ToString("O", CultureInfo.InvariantCulture)}\""); w.Write($",\"chat_type\":{(int)m.Code.Type}"); w.Write($",\"chat_type_name\":\"{chatType}\""); - w.Write($",\"source_kind\":{m.Code.Source}"); - w.Write($",\"target_kind\":{m.Code.Target}"); + // Cast, not interpolate. These are XivChatRelationKind, and string + // interpolation of an enum writes the member name -- so every + // message with a recognised relation produced + // "source_kind":LocalPlayer, which no parser accepts. This is the + // file an access request goes out on. + w.Write($",\"source_kind\":{(int)m.Code.Source}"); + w.Write($",\"target_kind\":{(int)m.Code.Target}"); w.Write($",\"receiver\":{m.Receiver}"); w.Write($",\"content_id\":{m.ContentId}"); w.Write($",\"sender\":{JsonString(SenderText(m))}"); @@ -313,8 +330,17 @@ internal static class MessageExporter private static string CsvString(string s) { + // Leading =, +, - and @ make a spreadsheet treat the cell as a formula. + // Every value here is text somebody else typed into a chat channel, and + // this file exists to be opened in Excel, so a prefixed apostrophe goes + // in front. It is the standard defence and it costs one character that + // spreadsheets hide. + if (s.Length > 0 && s[0] is '=' or '+' or '-' or '@' or '\t' or '\r') + s = "'" + s; + if (s.IndexOfAny(['"', ',', '\n', '\r']) < 0) return s; + return "\"" + s.Replace("\"", "\"\"") + "\""; } } diff --git a/HellionChat/MessageStore.cs b/HellionChat/MessageStore.cs index 0f390be..b0c6f5a 100644 --- a/HellionChat/MessageStore.cs +++ b/HellionChat/MessageStore.cs @@ -493,7 +493,7 @@ internal class MessageStore : IDisposable { Connection.Execute("DELETE FROM messages;"); InvalidateFtsIndex(); - PerformMaintenance(); + TryPerformMaintenance(); } } @@ -595,7 +595,7 @@ internal class MessageStore : IDisposable if (deleted > 0) { InvalidateFtsIndex(); - PerformMaintenance(); + TryPerformMaintenance(); } return deleted; @@ -628,7 +628,7 @@ internal class MessageStore : IDisposable if (deleted > 0) { InvalidateFtsIndex(); - PerformMaintenance(); + TryPerformMaintenance(); } return deleted; @@ -663,7 +663,7 @@ internal class MessageStore : IDisposable if (deleted > 0) { InvalidateFtsIndex(); - PerformMaintenance(); + TryPerformMaintenance(); } return deleted; @@ -684,6 +684,33 @@ internal class MessageStore : IDisposable } } + // Runs maintenance and swallows a failure, for the delete paths only. + // + // VACUUM needs the database to itself, and a lazily consumed reader on the + // primary connection -- which GetMostRecentMessages hands out and the + // refilter walks outside the lock -- makes it fail immediately with "cannot + // VACUUM - SQL statements in progress". That happens after the DELETE has + // committed, so letting it escape means the caller reports "nothing was + // removed" about a wipe that emptied the database. + // + // The rows are gone either way. An uncompacted file is a housekeeping + // problem; telling somebody their history is still there when it is not is + // a different kind of problem. + private void TryPerformMaintenance() + { + try + { + PerformMaintenance(); + } + catch (Exception e) + { + _logger.LogWarning( + e, + "Maintenance after a delete failed; the rows are gone but the file was not compacted." + ); + } + } + private string LogPath => DbPath + "-wal"; internal long DatabaseSize() => !File.Exists(DbPath) ? 0 : new FileInfo(DbPath).Length; diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 91c9397..85e9965 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -1064,9 +1064,23 @@ public sealed class Plugin : IAsyncDalamudPlugin return false; // Snapshot the policy so the user can edit settings while the sweep runs. + // + // Seeded from the spec defaults only when the global limit is not "keep + // forever". The slider is labelled "0 = never", and pre-filling 31 + // channels with 365- and 90-day windows made that label a lie: setting + // it to zero still lost free company, linkshell and party history after + // ninety days, and the short-circuit in DeleteByRetentionPolicy could + // never be reached because the map was never empty. + // + // Explicit per-channel overrides still apply. Somebody who typed a + // number for one channel meant that number. var policy = new Dictionary(); - foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays) - policy[(int)(ushort)type] = days; + if (Config.RetentionDefaultDays > 0) + { + foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays) + policy[(int)(ushort)type] = days; + } + // This is the enumerator the wizard's Clear() cuts short. Reading under the // same lock the writers take keeps the policy snapshot whole. lock (ConfigMapsLock) @@ -1086,7 +1100,18 @@ public sealed class Plugin : IAsyncDalamudPlugin try { if (!DbOperations.TryBegin(Util.DbOperation.RetentionSweep)) + { + // A run the user pressed a button for has to say something. + // The pre-check in StartRetentionSweep only covers a gate + // that was already busy; losing the race here is the same + // outcome and used to be silent. + if (notify) + NotifySweep( + Resources.HellionStrings.Retention_Error, + Dalamud.Interface.ImGuiNotification.NotificationType.Warning + ); return; + } try { @@ -1142,7 +1167,7 @@ public sealed class Plugin : IAsyncDalamudPlugin { Log.Error(e, "Retention sweep failed"); if (notify) - Util.WrapperUtil.AddNotification( + NotifySweep( Resources.HellionStrings.Retention_Error, Dalamud.Interface.ImGuiNotification.NotificationType.Error ); @@ -1170,6 +1195,20 @@ public sealed class Plugin : IAsyncDalamudPlugin } } + // The sweep is a background thread that can outlive an unload, same as the + // settings-tab workers. A notification filed against a plugin that is gone + // belongs to nobody. + private void NotifySweep( + string message, + Dalamud.Interface.ImGuiNotification.NotificationType type + ) + { + if (_isDisposing) + return; + + Util.WrapperUtil.AddNotification(message, type); + } + // Read by the settings tab every frame so the manual button can say a run is // in progress. The gate itself cannot answer that: it goes busy only once // the worker reaches TryBegin, which is after Start returns. diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index 887fb2f..8f70b61 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -277,6 +277,8 @@ internal class HellionStrings 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)); + internal static string Settings_Database_Op_Preview => Get(nameof(Settings_Database_Op_Preview)); + internal static string Settings_Database_Op_Maintenance => Get(nameof(Settings_Database_Op_Maintenance)); // 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 7354097..52114ac 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1328,4 +1328,10 @@ Duplica + + vista prèvia + + + manteniment + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index aaaa3fa..73e8050 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1327,4 +1327,10 @@ Duplikovat + + náhled + + + údržba + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index d13777b..521f41c 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1327,4 +1327,10 @@ Duplikér + + forhåndsvisning + + + vedligeholdelse + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index e2760fa..b030897 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -1322,4 +1322,10 @@ Duplizieren + + Vorschau + + + Wartung + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index f35e762..5ac707e 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1327,4 +1327,10 @@ Αντιγραφή + + προεπισκόπηση + + + συντήρηση + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index 105a5c7..46acd7d 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1328,4 +1328,10 @@ Duplicar + + vista previa + + + mantenimiento + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index 0c02e98..d80898d 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1327,4 +1327,10 @@ Kahdenna + + esikatselu + + + ylläpito + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index 93aeb61..2f17062 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -1328,4 +1328,10 @@ Dupliquer + + aperçu + + + maintenance + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index ed40eb9..43e6153 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1327,4 +1327,10 @@ Másolat + + előnézet + + + karbantartás + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index 98a745c..8880a1f 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1328,4 +1328,10 @@ Duplica + + anteprima + + + manutenzione + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index 9b23c70..f1beb26 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -1328,4 +1328,10 @@ 複製 + + プレビュー + + + メンテナンス + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index 47cc108..3fb0e96 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1328,4 +1328,10 @@ 복제 + + 미리보기 + + + 유지 관리 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index f3824e9..e1654d5 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1327,4 +1327,10 @@ Dupliser + + forhåndsvisning + + + vedlikehold + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index 8e5fed6..59bc189 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1328,4 +1328,10 @@ Dupliceren + + voorbeeld + + + onderhoud + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index 8164f55..7a74f13 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1327,4 +1327,10 @@ Duplikuj + + podgląd + + + konserwacja + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index 4c418ea..89fd3dd 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1328,4 +1328,10 @@ Duplicar + + pré-visualização + + + manutenção + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index 361a83f..8087376 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1327,4 +1327,10 @@ Duplicar + + pré-visualização + + + manutenção + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index 087d394..2f352d2 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1339,4 +1339,10 @@ Duplicate + + preview + + + maintenance + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index cc06371..bc6d3eb 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1328,4 +1328,10 @@ Duplică + + previzualizare + + + întreținere + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index de4d70d..891dae5 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1328,4 +1328,10 @@ Дублировать + + предпросмотр + + + обслуживание + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index c77e20a..2db63e2 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1328,4 +1328,10 @@ Duplicera + + förhandsgranskning + + + underhåll + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index 7d95d2c..eeeb1c6 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1327,4 +1327,10 @@ Çoğalt + + önizleme + + + bakım + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index 403ad43..74744c5 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1327,4 +1327,10 @@ Дублювати + + попередній перегляд + + + обслуговування + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index a21a232..f5ab8e6 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -1328,4 +1328,10 @@ 复制 + + 预览 + + + 维护 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index f4026a6..357b246 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -1328,4 +1328,10 @@ 複製 + + 預覽 + + + 維護 + \ No newline at end of file diff --git a/HellionChat/SelfTests/ExportRoundTripStep.cs b/HellionChat/SelfTests/ExportRoundTripStep.cs index c7e7731..be1ac17 100644 --- a/HellionChat/SelfTests/ExportRoundTripStep.cs +++ b/HellionChat/SelfTests/ExportRoundTripStep.cs @@ -125,6 +125,23 @@ internal sealed class ExportRoundTripStep : ISelfTestStep failures.Add("export produced no file"); if (File.Exists(path + ".part")) failures.Add("temporary file left behind after a successful export"); + + // Parsed, not merely counted. The writer builds JSON by hand, and it + // shipped a build where the chat relation kinds were interpolated as + // enum names -- "source_kind":LocalPlayer -- which every parser + // rejects. A test that only checks the file exists would have passed. + try + { + using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(path)); + if (!doc.RootElement.TryGetProperty("messages", out var messages)) + failures.Add("export JSON has no messages array"); + else if (messages.GetArrayLength() != 1) + failures.Add($"export JSON holds {messages.GetArrayLength()} messages, expected 1"); + } + catch (System.Text.Json.JsonException e) + { + failures.Add($"export JSON does not parse: {e.Message}"); + } } // Built with empty SeStrings on purpose: the whole point is that the text diff --git a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs index e7ea970..8bf389f 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs @@ -140,10 +140,11 @@ internal sealed class DataPrivacyTab v => Plugin.Config.RetentionEnabled = v ); - // Down to 0, which DeleteByRetentionPolicy reads as "keep forever" - // for channels without an override. The slider started at 1, so the - // one value that means "never delete anything" was unreachable - // through the UI while the label promised it. + // Down to 0, which the sweep reads as "keep forever" for every + // channel without an explicit override. That only became true in + // v1.12.0: the sweep used to seed the spec defaults unconditionally, + // so zero still deleted free company and linkshell history after + // ninety days while this label said otherwise. _w.SliderIntRow( ImGui.GetID("privacy.logging.default"u8), HellionStrings.Retention_Default_Label, @@ -474,7 +475,7 @@ internal sealed class DataPrivacyTab // MessageCount holds _readLock, and a VACUUM starting under it // is exactly the collision this gate was written for -- being a // read rather than a write does not exempt it. - if (!_plugin.DbOperations.TryBegin(DbOperation.Cleanup)) + if (!_plugin.DbOperations.TryBegin(DbOperation.Preview)) return; try @@ -487,7 +488,7 @@ internal sealed class DataPrivacyTab } finally { - _plugin.DbOperations.End(DbOperation.Cleanup); + _plugin.DbOperations.End(DbOperation.Preview); } } catch (Exception e) @@ -692,7 +693,7 @@ internal sealed class DataPrivacyTab { try { - if (!_plugin.DbOperations.TryBegin(DbOperation.Cleanup)) + if (!_plugin.DbOperations.TryBegin(DbOperation.Maintenance)) { // Said out loud, like every other refusal. A developer tool // that silently does nothing is how you end up debugging the @@ -707,7 +708,7 @@ internal sealed class DataPrivacyTab } finally { - _plugin.DbOperations.End(DbOperation.Cleanup); + _plugin.DbOperations.End(DbOperation.Maintenance); } } catch (Exception e) @@ -880,7 +881,7 @@ internal sealed class DataPrivacyTab { try { - if (!_plugin.DbOperations.TryBegin(DbOperation.Cleanup)) + if (!_plugin.DbOperations.TryBegin(DbOperation.Preview)) { NotifyBusy(); return; @@ -892,7 +893,7 @@ internal sealed class DataPrivacyTab } finally { - _plugin.DbOperations.End(DbOperation.Cleanup); + _plugin.DbOperations.End(DbOperation.Preview); } } catch (Exception e) @@ -1235,6 +1236,8 @@ internal sealed class DataPrivacyTab DbOperation.Export => HellionStrings.Settings_Database_Op_Export, DbOperation.Cleanup => HellionStrings.Settings_Database_Op_Cleanup, DbOperation.Clear => HellionStrings.Settings_Database_Op_Clear, + DbOperation.Preview => HellionStrings.Settings_Database_Op_Preview, + DbOperation.Maintenance => HellionStrings.Settings_Database_Op_Maintenance, _ => string.Empty, }; diff --git a/HellionChat/Util/DbOperationGate.cs b/HellionChat/Util/DbOperationGate.cs index ffa585a..4fc3516 100644 --- a/HellionChat/Util/DbOperationGate.cs +++ b/HellionChat/Util/DbOperationGate.cs @@ -8,6 +8,12 @@ internal enum DbOperation Export, Cleanup, Clear, + + // Read-only, but they hold the store long enough to matter: the preview + // scans every row, the metadata read takes the read lock, and maintenance + // rewrites the file without touching a single row. + Preview, + Maintenance, } // One gate for every operation that holds the message store for longer than a @@ -47,6 +53,16 @@ internal sealed class DbOperationGate internal long Revision => Interlocked.Read(ref _revision); + // Which operations can change what a preview counted. Getting this wrong in + // the permissive direction only costs a needless recount; getting it wrong + // the other way lets somebody confirm a number that is no longer true. + // + // The preview itself must not be in here, and that is not a detail: it takes + // the gate, so counting its own release would mark every preview stale the + // instant it finished and the apply button would never appear. + private static bool Mutates(DbOperation operation) => + operation is DbOperation.RetentionSweep or DbOperation.Cleanup or DbOperation.Clear; + internal bool IsBusy => _current != DbOperation.None; // False when another operation already owns the store. Callers must not @@ -84,7 +100,7 @@ internal sealed class DbOperationGate return; _current = DbOperation.None; - if (operation != DbOperation.Export) + if (Mutates(operation)) Interlocked.Increment(ref _revision); } }