feat(privacy): reconnect database maintenance and the manual retention run

Two sections that had backends and no buttons.

Database: path, size, WAL size, message count, and a clear button. The
numbers refresh at most every five seconds and not at all while a long
operation owns the store -- MessageCount takes the read lock, and asking
for it during a VACUUM means waiting for the whole file to be rewritten,
on the draw thread. The old version called ClearMessages straight from
the draw thread, VACUUM included; it runs on a worker now.

One line beyond the old layout sits above the clear button: how many
messages are stored, and that exporting keeps a copy. Whoever is about
to throw the history away should be told there is a way not to.

The legacy Chat 2 files only get a block when they are actually on disk,
and the advanced tools only appear when the section is expanded with
Shift held. The message injector is not back: it was deleted with the
tab and writing 10,000 fake messages into a user's real database is not
something to rebuild on the way past.

Retention: an "apply now" button, the running hint, and the last-run
line, which v1.11.0 shipped as an English literal while both strings sat
translated in all 25 languages. Plus reset-to-spec next to the existing
clear-overrides, since the two answer different questions and both were
already translated.

Retention_Apply_Tooltip stays unused and gets a replacement. It ends
with "Save your changes first", and the window it was written for had a
Save button.

Also here, found while wiring the manual trigger:

DbOperationGate.End now takes the operation it releases. It used to
reset blindly, on the reasoning that a worker must be able to release
from a finally without knowing whether it acquired. That is backwards: a
worker whose TryBegin was refused also runs its finally, and a blind
reset there hands away the lock of whichever operation actually holds
it. Worse than no gate, because the refused worker walks off believing
it did nothing while a VACUUM starts under somebody's open reader.
This commit is contained in:
2026-08-18 21:47:50 +02:00
parent 1ab7ba8377
commit e24ea79302
29 changed files with 646 additions and 48 deletions
+91 -35
View File
@@ -1010,6 +1010,22 @@ public sealed class Plugin : IAsyncDalamudPlugin
if (DateTimeOffset.UtcNow - Config.RetentionLastRunAt < TimeSpan.FromHours(24))
return;
StartRetentionSweep(notify: false);
}
// Shared by the daily check above and the manual button in settings.
//
// notify: the unattended sweep stays quiet, because a notification for
// something the user did not ask for at a moment they did not choose is
// noise. A run they pressed a button for reports back.
//
// Returns false when the store is already busy, so the caller can say so
// instead of leaving the user waiting for a run that never started.
internal bool StartRetentionSweep(bool notify)
{
if (DbOperations.IsBusy)
return false;
// Snapshot the policy so the user can edit settings while the sweep runs.
var policy = new Dictionary<int, int>();
foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays)
@@ -1023,67 +1039,107 @@ public sealed class Plugin : IAsyncDalamudPlugin
}
var defaultDays = Config.RetentionDefaultDays;
_retentionSweepRunning = true;
// IsBackground = true so a stuck sweep never blocks plugin unload.
new Thread(() =>
var worker = new Thread(() =>
{
// Bails when anything else already owns the store, not only another
// sweep: a user-triggered export or cleanup counts too.
if (!DbOperations.TryBegin(Util.DbOperation.RetentionSweep))
return;
try
{
var deleted = MessageManager.Store.DeleteByRetentionPolicy(policy, defaultDays);
Config.RetentionLastRunAt = DateTimeOffset.UtcNow;
SaveConfig();
if (!DbOperations.TryBegin(Util.DbOperation.RetentionSweep))
return;
if (deleted > 0)
try
{
Log.Information($"Retention sweep deleted {deleted} expired messages.");
// Schedule on the next framework tick to avoid the ~194ms
// hitch from blocking with .Wait() while the frame finishes.
// The Config.Tabs enumeration in ClearAllTabs/FilterAllTabs is
// now guarded by the shared Plugin.TabsListLock (B3), so this
// tick scheduling is purely hitch-avoidance, not safety.
// Pattern reference: SimpleTweaks
// Tweaks/Chat/CaseInsensitiveCommands.cs:45.
Framework.RunOnTick(() =>
var deleted = MessageManager.Store.DeleteByRetentionPolicy(policy, defaultDays);
Config.RetentionLastRunAt = DateTimeOffset.UtcNow;
SaveConfig();
if (notify)
Util.WrapperUtil.AddNotification(
string.Format(Resources.HellionStrings.Retention_Success, deleted),
Dalamud.Interface.ImGuiNotification.NotificationType.Success
);
if (deleted > 0)
{
// The retention thread is IsBackground=true so plugin
// unload can fire while a scheduled tick is still
// pending; bail before touching anything torn down.
if (_isDisposing)
return;
try
Log.Information($"Retention sweep deleted {deleted} expired messages.");
// Schedule on the next framework tick to avoid the ~194ms
// hitch from blocking with .Wait() while the frame finishes.
// The Config.Tabs enumeration in ClearAllTabs/FilterAllTabs is
// now guarded by the shared Plugin.TabsListLock (B3), so this
// tick scheduling is purely hitch-avoidance, not safety.
// Pattern reference: SimpleTweaks
// Tweaks/Chat/CaseInsensitiveCommands.cs:45.
Framework.RunOnTick(() =>
{
MessageManager.ClearAllTabs();
MessageManager.FilterAllTabs();
}
catch (Exception ex)
{
Log.Error(ex, "Retention sweep clear+refilter failed");
}
});
// The retention thread is IsBackground=true so plugin
// unload can fire while a scheduled tick is still
// pending; bail before touching anything torn down.
if (_isDisposing)
return;
try
{
MessageManager.ClearAllTabs();
MessageManager.FilterAllTabs();
}
catch (Exception ex)
{
Log.Error(ex, "Retention sweep clear+refilter failed");
}
});
}
else
{
Log.Information("Retention sweep ran, nothing expired.");
}
}
else
finally
{
Log.Information("Retention sweep ran, nothing expired.");
DbOperations.End(Util.DbOperation.RetentionSweep);
}
}
catch (Exception e)
{
Log.Error(e, "Retention sweep failed");
if (notify)
Util.WrapperUtil.AddNotification(
Resources.HellionStrings.Retention_Error,
Dalamud.Interface.ImGuiNotification.NotificationType.Error
);
}
finally
{
DbOperations.End();
_retentionSweepRunning = false;
}
})
{
IsBackground = true,
}.Start();
};
try
{
worker.Start();
return true;
}
catch (Exception e)
{
// The thread never ran, so nothing will clear the flag for us.
_retentionSweepRunning = false;
Log.Error(e, "Could not start the retention sweep thread");
return false;
}
}
// 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.
private volatile bool _retentionSweepRunning;
internal bool RetentionSweepRunning => _retentionSweepRunning;
private void Draw()
{
// v1.9.0 B5: time the whole handler (style + font prologue included).
+2
View File
@@ -96,6 +96,7 @@ internal class HellionStrings
internal static string Retention_Apply_Label => Get(nameof(Retention_Apply_Label));
internal static string Retention_Apply_Tooltip => Get(nameof(Retention_Apply_Tooltip));
internal static string Retention_Running => Get(nameof(Retention_Running));
internal static string Retention_RunNow_Tooltip => Get(nameof(Retention_RunNow_Tooltip));
internal static string Retention_LastRun_Never => Get(nameof(Retention_LastRun_Never));
internal static string Retention_LastRun_At => Get(nameof(Retention_LastRun_At));
internal static string Retention_Success => Get(nameof(Retention_Success));
@@ -296,6 +297,7 @@ internal class HellionStrings
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_ClearHint => Get(nameof(Settings_Database_ClearHint));
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));
@@ -1178,4 +1178,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>No hi ha cap canal seleccionat, així que una neteja esborraria tot l'historial. Tria els canals que vols conservar, o fes servir el botó d'esborrar si de debò ho vols eliminar tot.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>Hi ha {0:N0} missatges desats. Si vols conservar-ne una còpia, exporta'ls abans d'esborrar.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Maj: executa la neteja de retenció ara mateix en comptes d'esperar el pas diari. Esborra els missatges més antics que els límits de dalt.</value>
</data>
</root>
@@ -1177,4 +1177,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>Není vybrán žádný kanál, takže úklid by smazal celou historii. Vyberte kanály, které chcete zachovat, nebo použijte tlačítko pro vymazání, pokud opravdu chcete smazat vše.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>Uloženo je {0:N0} zpráv. Pokud si chcete ponechat kopii, před vymazáním je exportujte.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: spustí úklid podle doby uchování hned, místo čekání na denní běh. Smaže zprávy starší než limity výše.</value>
</data>
</root>
@@ -1177,4 +1177,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>Ingen kanal er valgt, så en oprydning ville slette hele historikken. Vælg de kanaler, du vil beholde, eller brug sletteknappen, hvis du virkelig vil af med alt.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>Der er gemt {0:N0} beskeder. Vil du beholde en kopi, så eksportér dem før du sletter.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: kører oprydningen med det samme i stedet for at vente på det daglige gennemløb. Sletter beskeder ældre end grænserne ovenfor.</value>
</data>
</root>
@@ -1172,4 +1172,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>Es ist kein Kanal ausgewählt, eine Bereinigung würde also den gesamten Verlauf löschen. Wähle die Kanäle aus, die du behalten willst, oder nimm den Löschen-Knopf, wenn wirklich alles weg soll.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>Es sind {0:N0} Nachrichten gespeichert. Wenn du eine Kopie behalten willst, exportiere sie vor dem Löschen.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Strg+Umschalt: führt den Aufbewahrungslauf sofort aus, statt auf den täglichen Durchlauf zu warten. Löscht Nachrichten, die älter sind als die Grenzen oben.</value>
</data>
</root>
@@ -1177,4 +1177,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>Δεν έχει επιλεγεί κανένα κανάλι, οπότε μια εκκαθάριση θα διέγραφε όλο το ιστορικό. Επιλέξτε τα κανάλια που θέλετε να κρατήσετε ή χρησιμοποιήστε το κουμπί διαγραφής αν θέλετε πραγματικά να φύγουν όλα.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>Έχουν αποθηκευτεί {0:N0} μηνύματα. Αν θέλετε να κρατήσετε αντίγραφο, εξαγάγετέ τα πριν τη διαγραφή.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: εκτελεί την εκκαθάριση διατήρησης αμέσως, χωρίς να περιμένει το ημερήσιο πέρασμα. Διαγράφει μηνύματα παλαιότερα από τα παραπάνω όρια.</value>
</data>
</root>
@@ -1178,4 +1178,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>No hay ningún canal seleccionado, así que una limpieza borraría todo el historial. Elige los canales que quieras conservar, o usa el botón de borrar si de verdad quieres eliminarlo todo.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>Hay {0:N0} mensajes guardados. Si quieres conservar una copia, expórtalos antes de borrar.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Mayús: ejecuta la limpieza de retención ahora mismo en lugar de esperar al barrido diario. Borra los mensajes más antiguos que los límites de arriba.</value>
</data>
</root>
@@ -1177,4 +1177,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>Yhtään kanavaa ei ole valittu, joten siivous poistaisi koko historian. Valitse säilytettävät kanavat tai käytä tyhjennyspainiketta, jos haluat todella poistaa kaiken.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>Tallennettuna on {0:N0} viestiä. Jos haluat säilyttää kopion, vie ne ennen tyhjennystä.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Vaihto: suorittaa säilytysajon heti sen sijaan, että odottaisi päivittäistä ajoa. Poistaa yllä olevia rajoja vanhemmat viestit.</value>
</data>
</root>
@@ -1178,4 +1178,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>Aucun canal n'est sélectionné, un nettoyage supprimerait donc tout l'historique. Choisissez les canaux à conserver, ou utilisez le bouton d'effacement si vous voulez vraiment tout supprimer.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>{0:N0} messages sont enregistrés. Si vous voulez en garder une copie, exportez-les avant d'effacer.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Maj : lance le nettoyage de rétention immédiatement au lieu d'attendre le passage quotidien. Supprime les messages plus anciens que les limites ci-dessus.</value>
</data>
</root>
@@ -1177,4 +1177,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>Nincs kiválasztva csatorna, így a takarítás a teljes előzményt törölné. Válaszd ki a megtartandó csatornákat, vagy használd a törlés gombot, ha tényleg mindent el akarsz távolítani.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>{0:N0} üzenet van elmentve. Ha meg akarsz tartani egy másolatot, törlés előtt exportáld őket.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: azonnal lefuttatja a megőrzési takarítást, nem várja meg a napi futást. Törli a fenti korlátoknál régebbi üzeneteket.</value>
</data>
</root>
@@ -1178,4 +1178,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>Nessun canale è selezionato, quindi una pulizia cancellerebbe l'intera cronologia. Scegli i canali da conservare, oppure usa il pulsante di cancellazione se vuoi davvero eliminare tutto.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>Ci sono {0:N0} messaggi salvati. Se vuoi conservarne una copia, esportali prima di cancellare.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Maiusc: esegue subito la pulizia di conservazione invece di aspettare il passaggio giornaliero. Cancella i messaggi più vecchi dei limiti sopra.</value>
</data>
</root>
@@ -1178,4 +1178,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>チャンネルが一つも選ばれていないため、クリーンアップは履歴をすべて削除します。残したいチャンネルを選ぶか、本当にすべて消したい場合は削除ボタンを使ってください。</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>{0:N0} 件のメッセージが保存されています。控えを残したい場合は、削除する前にエクスポートしてください。</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: 毎日の処理を待たずに保存期間の整理をすぐ実行します。上の期限より古いメッセージを削除します。</value>
</data>
</root>
@@ -1178,4 +1178,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>선택된 채널이 없어 정리를 실행하면 기록 전체가 삭제됩니다. 남길 채널을 선택하거나, 정말 모두 지우려면 삭제 버튼을 사용하세요.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>{0:N0}개의 메시지가 저장되어 있습니다. 사본을 남기려면 삭제하기 전에 내보내세요.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: 매일 실행을 기다리지 않고 보존 기간 정리를 지금 실행합니다. 위 기한보다 오래된 메시지를 삭제합니다.</value>
</data>
</root>
@@ -1177,4 +1177,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>Ingen kanal er valgt, så en opprydding ville slette hele historikken. Velg kanalene du vil beholde, eller bruk sletteknappen hvis du virkelig vil fjerne alt.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>{0:N0} meldinger er lagret. Vil du beholde en kopi, eksporter dem før du sletter.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: kjører oppryddingen med en gang i stedet for å vente på det daglige gjennomløpet. Sletter meldinger eldre enn grensene over.</value>
</data>
</root>
@@ -1178,4 +1178,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>Er is geen kanaal geselecteerd, dus een opschoning zou de hele geschiedenis wissen. Kies de kanalen die je wilt bewaren, of gebruik de wisknop als je echt alles kwijt wilt.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>Er zijn {0:N0} berichten opgeslagen. Wil je een kopie houden, exporteer ze dan voordat je wist.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: voert de opschoning nu meteen uit in plaats van te wachten op de dagelijkse ronde. Wist berichten ouder dan de limieten hierboven.</value>
</data>
</root>
@@ -1177,4 +1177,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>Nie wybrano żadnego kanału, więc porządkowanie usunęłoby całą historię. Wybierz kanały, które chcesz zachować, albo użyj przycisku czyszczenia, jeśli naprawdę chcesz usunąć wszystko.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>Zapisanych jest {0:N0} wiadomości. Jeśli chcesz zachować kopię, wyeksportuj je przed wyczyszczeniem.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: uruchamia porządkowanie od razu, zamiast czekać na codzienny przebieg. Usuwa wiadomości starsze niż limity powyżej.</value>
</data>
</root>
@@ -1178,4 +1178,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>Nenhum canal está selecionado, então uma limpeza apagaria todo o histórico. Escolha os canais que quer manter, ou use o botão de apagar se realmente quiser remover tudo.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>Há {0:N0} mensagens salvas. Se quiser manter uma cópia, exporte-as antes de apagar.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: executa a limpeza de retenção agora em vez de esperar a varredura diária. Apaga mensagens mais antigas que os limites acima.</value>
</data>
</root>
@@ -1177,4 +1177,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>Não está selecionado nenhum canal, por isso uma limpeza apagaria todo o histórico. Escolha os canais que quer manter, ou use o botão de apagar se quiser mesmo remover tudo.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>Estão guardadas {0:N0} mensagens. Se quiser manter uma cópia, exporte-as antes de apagar.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: executa a limpeza de retenção agora em vez de esperar a varredura diária. Apaga mensagens mais antigas do que os limites acima.</value>
</data>
</root>
@@ -1198,4 +1198,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>No channel is selected, so a cleanup would delete the entire history. Pick the channels you want to keep, or use the clear button if you really want everything gone.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>{0:N0} messages are stored. If you want to keep a copy, export them before clearing.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: runs the retention cleanup right now instead of waiting for the daily sweep. Deletes messages older than the limits above.</value>
</data>
</root>
@@ -1178,4 +1178,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>Niciun canal nu este selectat, deci o curățare ar șterge tot istoricul. Alege canalele pe care vrei să le păstrezi sau folosește butonul de ștergere dacă chiar vrei să dispară tot.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>Sunt salvate {0:N0} mesaje. Dacă vrei să păstrezi o copie, exportă-le înainte de ștergere.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: rulează curățarea acum, în loc să aștepte trecerea zilnică. Șterge mesajele mai vechi decât limitele de mai sus.</value>
</data>
</root>
@@ -1178,4 +1178,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>Не выбран ни один канал, поэтому очистка удалит всю историю. Выберите каналы, которые хотите сохранить, или воспользуйтесь кнопкой удаления, если действительно хотите стереть всё.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>Сохранено {0:N0} сообщений. Если хотите оставить копию, экспортируйте их перед удалением.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: выполняет очистку по сроку хранения сразу, не дожидаясь ежедневного прохода. Удаляет сообщения старше указанных выше пределов.</value>
</data>
</root>
@@ -1178,4 +1178,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>Ingen kanal är vald, så en rensning skulle radera hela historiken. Välj de kanaler du vill behålla, eller använd raderingsknappen om du verkligen vill ta bort allt.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>{0:N0} meddelanden är sparade. Vill du behålla en kopia, exportera dem innan du raderar.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Skift: kör gallringen direkt i stället för att vänta på den dagliga körningen. Raderar meddelanden äldre än gränserna ovan.</value>
</data>
</root>
@@ -1177,4 +1177,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>Hiçbir kanal seçili değil, bu yüzden temizlik tüm geçmişi siler. Saklamak istediğiniz kanalları seçin veya gerçekten her şeyin gitmesini istiyorsanız silme düğmesini kullanın.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>{0:N0} ileti saklanıyor. Bir kopya tutmak istiyorsanız, silmeden önce dışa aktarın.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: günlük taramayı beklemeden saklama temizliğini hemen çalıştırır. Yukarıdaki sınırlardan eski iletileri siler.</value>
</data>
</root>
@@ -1177,4 +1177,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>Не вибрано жодного каналу, тому очищення видалить усю історію. Виберіть канали, які хочете зберегти, або скористайтеся кнопкою видалення, якщо справді хочете стерти все.</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>Збережено {0:N0} повідомлень. Якщо хочете залишити копію, експортуйте їх перед видаленням.</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: виконує очищення за строком зберігання одразу, не чекаючи щоденного проходу. Видаляє повідомлення, старші за вказані вище межі.</value>
</data>
</root>
@@ -1178,4 +1178,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>未选择任何频道,因此清理会删除全部历史记录。请选择要保留的频道,若确实想全部删除,请使用清空按钮。</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>已保存 {0:N0} 条消息。若想留一份副本,请在清空前先导出。</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift:立即执行保留期清理,无需等待每日运行。删除早于上方期限的消息。</value>
</data>
</root>
@@ -1178,4 +1178,10 @@
<data name="Cleanup_Unavailable_NothingListed" xml:space="preserve">
<value>未選擇任何頻道,因此清理會刪除全部歷史紀錄。請選擇要保留的頻道,若確實想全部刪除,請使用清除按鈕。</value>
</data>
<data name="Settings_Database_ClearHint" xml:space="preserve">
<value>已保存 {0:N0} 則訊息。若想留一份副本,請在清除前先匯出。</value>
</data>
<data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift:立即執行保留期清理,無需等待每日執行。刪除早於上方期限的訊息。</value>
</data>
</root>
@@ -39,6 +39,21 @@ internal sealed class DataPrivacyTab
private volatile bool _cleanupPreviewRunning;
private volatile bool _cleanupRunning;
// Database metadata, refreshed at most every five seconds. MessageCount and
// the file sizes are cheap on their own, but MessageCount takes the read
// lock, and a VACUUM holds that for the length of a full file rewrite. Once
// per frame would not be a stutter, it would be a still image.
private long _dbRefreshedAt;
private long _dbSize;
private long _dbLogSize;
private int _dbMessageCount;
private volatile bool _clearRunning;
// Shift held while expanding the section. Keeps the developer tools out of
// the way without a permanent switch, the way upstream did it.
private bool _dbSectionWasOpen;
private bool _dbShowAdvanced;
// What the preview was computed against. All three inputs decide the outcome,
// so all three decide whether it is still valid.
private sealed record CleanupPreview(
@@ -102,17 +117,24 @@ internal sealed class DataPrivacyTab
);
// RetentionLastRunAt defaults to MinValue on a fresh install, which
// would render as "0001-01-01 00:00" and look like a bug; the "Never"
// would render as "0001-01-01 00:00" and look like a bug; the "never"
// sentinel handles that. Disabling the sweep does NOT reset the
// timestamp — the historical last-run value is kept as informational
// carry-over until the next sweep updates it.
var lastRun =
//
// Both strings were already translated when v1.11.0 shipped this row
// with an English literal.
ImGui.TextDisabled(
Plugin.Config.RetentionLastRunAt == DateTimeOffset.MinValue
? "Never"
: Plugin.Config.RetentionLastRunAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
ImGui.TextDisabled($"Last run: {lastRun}");
? HellionStrings.Retention_LastRun_Never
: string.Format(
HellionStrings.Retention_LastRun_At,
Plugin.Config.RetentionLastRunAt.ToLocalTime()
)
);
DrawRetentionOverrides();
DrawManualRetentionRun();
}
if (
@@ -161,6 +183,17 @@ internal sealed class DataPrivacyTab
DrawExportSection();
}
var dbOpen = _w.Section(
ImGui.GetID("privacy.database"u8),
HellionStrings.Settings_Section_Database,
open: false
);
if (dbOpen && !_dbSectionWasOpen)
_dbShowAdvanced = ImGui.GetIO().KeyShift;
_dbSectionWasOpen = dbOpen;
if (dbOpen)
DrawDatabaseSection();
if (_w.Section(ImGui.GetID("privacy.telemetry"u8), "Telemetry", open: false))
{
// Read-only placeholder; no telemetry is wired in v1.7.0. Do not
@@ -204,7 +237,8 @@ internal sealed class DataPrivacyTab
return;
}
if (overrides.Count > 0 && ImGui.Button(HellionStrings.Retention_Clear_Overrides))
var hasOverrides = overrides.Count > 0;
if (hasOverrides && ImGui.Button(HellionStrings.Retention_Clear_Overrides))
{
// Same lock the sweep takes when it snapshots the policy, so a clear
// cannot cut its enumeration short.
@@ -214,6 +248,358 @@ internal sealed class DataPrivacyTab
}
_plugin.SaveConfig();
}
// SameLine only when there is a button to sit beside; otherwise it would
// attach to the last channel row above.
if (hasOverrides)
ImGui.SameLine();
// Clearing drops every override and falls back to the global default;
// this puts the per-channel spec values back instead. Two different
// answers to "I have made a mess of this", and both were translated
// before either had a button.
if (ImGui.Button(HellionStrings.Retention_Reset_Button))
{
lock (_plugin.ConfigMapsLock)
{
overrides.Clear();
foreach (var (type, days) in PrivacyDefaults.DefaultRetentionDays)
overrides[type] = days;
}
_plugin.SaveConfig();
}
if (ImGui.IsItemHovered())
ImGuiUtil.Tooltip(HellionStrings.Retention_Reset_Spec);
}
// The daily sweep has run unattended since v1.4.8, but only ever on its own
// schedule: change a limit and the effect lands up to 24 hours later, with
// nothing on screen to say so. The lock this needed was already there,
// waiting for a caller.
private void DrawManualRetentionRun()
{
if (!Plugin.Config.RetentionEnabled)
return;
ImGui.Spacing();
var running = _plugin.RetentionSweepRunning;
var current = _plugin.DbOperations.Current;
using (ImRaii.Disabled(running || current != DbOperation.None))
{
if (
ImGuiUtil.CtrlShiftButton(
HellionStrings.Retention_Apply_Label,
HellionStrings.Retention_RunNow_Tooltip
)
)
{
// Refusal is reported here rather than from the worker: the
// sweep bails silently by design, and a button that does nothing
// without saying why is the thing this cycle exists to remove.
if (!_plugin.StartRetentionSweep(notify: true))
WrapperUtil.AddNotification(
string.Format(
HellionStrings.Settings_Database_Busy,
OperationName(_plugin.DbOperations.Current)
),
NotificationType.Warning
);
}
}
if (running)
ImGuiUtil.HelpText(HellionStrings.Retention_Running);
}
private void DrawDatabaseSection()
{
RefreshDatabaseMetadata();
ImGuiUtil.HelpText(
string.Format(Language.Options_Database_Metadata_Path, MessageManager.DatabasePath())
);
if (ImGui.IsItemClicked(ImGuiMouseButton.Left))
{
ImGui.SetClipboardText(Path.GetDirectoryName(MessageManager.DatabasePath()));
WrapperUtil.AddNotification(
Language.Options_Database_Metadata_CopyConfigPathNotification,
NotificationType.Info
);
}
if (ImGui.IsItemHovered())
{
ImGui.SetMouseCursor(ImGuiMouseCursor.Hand);
ImGuiUtil.Tooltip(Language.Options_Database_Metadata_CopyConfigPath);
}
ImGuiUtil.HelpText(
string.Format(
Language.Options_Database_Metadata_Size,
StringUtil.BytesToString(_dbSize)
)
);
ImGuiUtil.HelpText(
string.Format(
Language.Options_Database_Metadata_LogSize,
StringUtil.BytesToString(_dbLogSize)
)
);
ImGuiUtil.HelpText(
string.Format(Language.Options_Database_Metadata_MessageCount, _dbMessageCount)
);
ImGui.Spacing();
// Beyond the old layout: whoever is about to throw the history away
// should see how much of it there is and be told, in the same breath,
// that there is a way to keep a copy.
ImGuiUtil.HelpText(
string.Format(HellionStrings.Settings_Database_ClearHint, _dbMessageCount)
);
var current = _plugin.DbOperations.Current;
var busy = _clearRunning || current != DbOperation.None;
using (ImRaii.Disabled(busy))
{
if (
ImGuiUtil.CtrlShiftButton(
Language.Options_ClearDatabase_Button,
Language.Options_ClearDatabase_Tooltip
)
)
StartClear();
}
if (!_clearRunning && current != DbOperation.None)
ImGuiUtil.HelpText(
string.Format(HellionStrings.Settings_Database_Busy, OperationName(current))
);
DrawLegacyDatabaseBlock();
if (_dbShowAdvanced)
DrawAdvancedDatabaseBlock(busy);
}
// Suspended while a long-running operation owns the store: MessageCount
// takes the read lock, and asking for it during a VACUUM means waiting for
// the whole file to be rewritten -- on the draw thread.
private void RefreshDatabaseMetadata()
{
if (_plugin.DbOperations.IsBusy || _clearRunning)
return;
if (_dbRefreshedAt + 5_000 > Environment.TickCount64)
return;
_dbSize = _plugin.MessageManager.Store.DatabaseSize();
_dbLogSize = _plugin.MessageManager.Store.DatabaseLogSize();
_dbMessageCount = _plugin.MessageManager.Store.MessageCount();
_dbRefreshedAt = Environment.TickCount64;
}
// The old version called ClearMessages straight from the draw thread, VACUUM
// and all. On a large database that is a frozen game for as long as SQLite
// needs to rewrite the file.
private void StartClear()
{
if (_clearRunning)
return;
_clearRunning = true;
var worker = new Thread(() =>
{
try
{
if (!_plugin.DbOperations.TryBegin(DbOperation.Clear))
{
Notify(
string.Format(
HellionStrings.Settings_Database_Busy,
OperationName(_plugin.DbOperations.Current)
),
NotificationType.Warning
);
return;
}
try
{
_logger.LogWarning("Clearing messages from database");
_plugin.MessageManager.Store.ClearMessages();
if (
!Plugin
.Framework.Run(() => _plugin.MessageManager.ClearAllTabs())
.Wait(TimeSpan.FromSeconds(5))
)
{
_logger.LogWarning("Clear: framework refresh timed out after 5s.");
}
Notify(Language.Options_ClearDatabase_Success, NotificationType.Info);
}
finally
{
_plugin.DbOperations.End(DbOperation.Clear);
}
}
catch (Exception e)
{
_logger.LogError(e, "Clearing the database failed");
Notify(Language.Options_ClearDatabase_Success, NotificationType.Error);
}
finally
{
// Both the counters and any cleanup preview describe a database
// that no longer exists.
_dbRefreshedAt = 0;
_cleanupPreview = null;
_clearRunning = false;
}
})
{
IsBackground = true,
Name = "HellionChat Clear",
};
try
{
worker.Start();
}
catch (Exception e)
{
_clearRunning = false;
_logger.LogError(e, "Could not start the clear thread");
}
}
// Chat 2 left these behind on migration. Drawn only when they are actually
// on disk, so the block does not sit there permanently telling most users
// about a file they have never had.
private void DrawLegacyDatabaseBlock()
{
var dir = Plugin.Interface.ConfigDirectory.FullName;
var old = new FileInfo(Path.Join(dir, "chat.db"));
var migrated = new FileInfo(Path.Join(dir, "chat-litedb.db"));
if (!old.Exists && !migrated.Exists)
return;
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
ImGui.TextUnformatted(Language.Options_Database_Old_Heading);
if (
!ImGuiUtil.CtrlShiftButton(
Language.Options_Database_Old_Delete,
Language.Options_Database_Old_Delete_Tooltip
)
)
return;
try
{
if (old.Exists)
old.Delete();
if (migrated.Exists)
migrated.Delete();
WrapperUtil.AddNotification(
Language.Options_Database_Old_Delete_Success,
NotificationType.Success
);
}
catch (Exception e)
{
_logger.LogError(e, "Unable to delete old database");
WrapperUtil.AddNotification(
Language.Options_Database_Old_Delete_Error,
NotificationType.Error
);
}
}
// Untranslated on purpose: these are developer tools, and the labels name
// the methods they call.
private void DrawAdvancedDatabaseBlock(bool busy)
{
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
ImGui.TextUnformatted(Language.Options_Database_Advanced);
using var wrap = ImRaii.TextWrapPos(0.0f);
ImGuiUtil.WarningText(Language.Options_Database_Advanced_Warning);
using (ImRaii.Disabled(busy))
{
if (
ImGuiUtil.CtrlShiftButton(
"Perform maintenance",
"Ctrl+Shift: VACUUM, REINDEX and ANALYZE. Runs in the background."
)
)
StartMaintenance();
}
if (
ImGuiUtil.CtrlShiftButton(
"Reload messages from database",
"Ctrl+Shift: MessageManager.FilterAllTabsAsync()"
)
)
{
_plugin.MessageManager.ClearAllTabs();
_plugin.MessageManager.FilterAllTabsAsync();
}
}
private void StartMaintenance()
{
var worker = new Thread(() =>
{
try
{
if (!_plugin.DbOperations.TryBegin(DbOperation.Cleanup))
return;
try
{
_plugin.MessageManager.Store.PerformMaintenance();
}
finally
{
_plugin.DbOperations.End(DbOperation.Cleanup);
}
}
catch (Exception e)
{
_logger.LogError(e, "Manual maintenance failed");
}
finally
{
_dbRefreshedAt = 0;
}
})
{
IsBackground = true,
Name = "HellionChat Maintenance",
};
try
{
worker.Start();
}
catch (Exception e)
{
_logger.LogError(e, "Could not start the maintenance thread");
}
}
// The privacy filter only decides what gets written from now on. Everything
@@ -483,7 +869,7 @@ internal sealed class DataPrivacyTab
}
finally
{
_plugin.DbOperations.End();
_plugin.DbOperations.End(DbOperation.Cleanup);
}
}
catch (Exception e)
@@ -742,7 +1128,7 @@ internal sealed class DataPrivacyTab
}
finally
{
_plugin.DbOperations.End();
_plugin.DbOperations.End(DbOperation.Export);
}
}
catch (Exception e)
+9 -5
View File
@@ -62,14 +62,18 @@ internal sealed class DbOperationGate
}
}
// Idempotent, and deliberately not checking which operation ends. A worker
// that throws must still be able to release from a finally block without
// knowing whether it ever acquired.
internal void End()
// Releases only what the caller acquired. Idempotent for that caller, and a
// no-op for anyone else -- a worker whose TryBegin was refused still runs its
// finally, and a blind reset there would hand away the lock of whichever
// operation actually holds it. That is worse than no gate: the refused
// worker walks away believing it did nothing while a VACUUM starts under
// somebody's open reader.
internal void End(DbOperation operation)
{
lock (_lock)
{
_current = DbOperation.None;
if (_current == operation)
_current = DbOperation.None;
}
}
}