Merge branch 'feature/v1.4.8'
Security / scan (push) Successful in 23s
Build / Build (Release) (push) Successful in 29s
Forge Announce / Post changelog to Hellion Forge (push) Successful in 7s
Release / Build and attach release ZIP (push) Successful in 37s

This commit is contained in:
2026-05-14 12:12:06 +02:00
17 changed files with 1149 additions and 309 deletions
+21
View File
@@ -0,0 +1,21 @@
---
subtitle: Hook-Layer und Polish-Quick-Wins
versionsnatur: Polish-Patch
---
- DbViewer Volltext-Suche: optionaler FTS5-Index über die ganze Chat-Historie.
Wird beim ersten v1.4.8-Start asynchron im Hintergrund gebaut, Progress als
Toast. Lokale Page-Suche bleibt Default. Such-Eingaben werden als exakte
Wortfolge gematcht; mehrere Wörter werden nur gefunden, wenn sie zusammen
und in der Reihenfolge stehen. Wer rohe FTS5-MATCH-Syntax nutzen will, setzt
eigene Anführungszeichen um den Suchbegriff.
- Custom-Theme-Files laden sich beim Speichern automatisch neu, wenn das Theme
aktiv ist. Kein Picker-Klick mehr nötig.
- Retention-Sweep blockt nicht mehr den Framework-Thread. Der Mini-Hitch von
~194ms pro Sweep ist weg.
- Statusleiste rendert sauber bei Windows-Skalierung über 100%.
- Receive-Suppressed-Tells-Routing wurde in diesem Cycle untersucht und auf
v1.5.x verschoben: wenn andere Plugins Tells via CheckMessageHandled
unterdrücken, überspringt FFXIVs Chat-Pipeline den RaptureLogModule-Resolver
und HellionChats Tab-Routing verliert den Tell-Partner. Der Fix liegt
architektonisch neben dem geplanten Ad-Block-Hook-Layer und kommt dort mit.
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Dalamud.NET.Sdk/15.0.0">
<PropertyGroup>
<!-- Independent versioning; see yaml changelog for upstream Chat 2 base -->
<Version>1.4.7</Version>
<Version>1.4.8</Version>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Use lock file to pin exact versions -->
+34 -20
View File
@@ -35,6 +35,40 @@ tags:
- Replacement
- Privacy
changelog: |-
**v1.4.8 — Hook-Layer and Polish Quick-Wins (2026-05-14)**
Ninth sub-patch of the v1.4.x polish-sweep series. Hook-layer
cluster (DbViewer FTS5 full-text search, ad-block foundation
investigation) plus three polish quick-wins.
- DbViewer full-text search: optional FTS5 index across the full
chat history. Built asynchronously on first load after the
update with a progress toast. The local page-filter remains
available as the default mode. Queries match as exact phrases
-- multi-word terms must appear together in order; advanced
users can opt into raw FTS5 MATCH syntax by wrapping their own
double-quotes.
- Custom theme files now auto-reload when edited while the theme
is active -- no need to re-click the theme in the picker.
- Retention sweep no longer blocks the framework thread, removing
the ~194ms mini-hitch per sweep.
- Status bar renders correctly at Windows display scaling > 100%.
- Receive-suppressed-tells routing investigated this cycle and
postponed to v1.5.x: when other plugins suppress tells via
CheckMessageHandled, the FFXIV chat pipeline skips the
RaptureLogModule.AddMsgSourceEntry path so HellionChat's
ContentIdResolverHook does not fire and tell-partner
identification breaks. The fix belongs next to the planned
ad-block hook layer where the same patch surface comes up.
- Internal: messages.Id is declared BLOB but stored as TEXT
(Microsoft.Data.Sqlite Guid binding). FTS bulk insert and
LoadByGuids match the TEXT storage form on both sides.
Migration v17 stays (no schema bump).
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
---
**v1.4.7 — Backlog Cleanup and Mid-Features (2026-05-13)**
Eighth sub-patch of the v1.4.x polish-sweep series. First
@@ -140,24 +174,4 @@ changelog: |-
---
**v1.4.4 — Threading and IPC safety polish (2026-05-12)**
Fifth sub-patch of the v1.4.x polish-sweep series. Threading
assumptions are documented per-method, a hot-path lock falls
away, and the privacy filter speaks up when an unknown ChatType
shows up.
- AutoTellTabs hot-path getter uses an Interlocked counter
instead of taking the lock on every read
- Honorific integration: per-method threading banners, plus
Warning-level log on unsubscribe failure
- AutoTranslate warmup thread marked IsBackground so plugin
unload doesn't wait for it
- PrivacyFilter logs once per unknown ChatType so a future
patch's added channel doesn't drop off the radar
- New installs persist unknown channels by default; existing
configs keep their explicit choice
---
Full history: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases
+423 -39
View File
@@ -126,7 +126,12 @@ internal class MessageStore : IDisposable
private const int MessageQueryLimit = 10_000;
private string DbPath { get; }
private SqliteConnection Connection { get; set; }
// Internal so the Build-Suite tests can verify Migrate4's CREATE VIRTUAL
// TABLE result via a one-off PRAGMA without exposing a dedicated helper
// for each schema invariant. Setter stays private; the ctor is the only
// place that assigns.
internal SqliteConnection Connection { get; private set; }
internal static readonly MessagePackSerializerOptions MsgPackOptions =
MessagePackSerializerOptions.Standard.WithResolver(
@@ -136,9 +141,62 @@ internal class MessageStore : IDisposable
)
);
// Pure deserialisation of one messages-row at the reader's current position.
// Shared between the MessageEnumerator load path and the upcoming v1.4.8
// LoadByGuids FTS-join path so both stay in lockstep when the column layout
// moves. Throws on row-level errors; the caller decides whether to skip+log
// (enumerator) or fail-fast (bulk lookup).
internal static Message ReadMessageRow(DbDataReader reader)
{
return new Message(
reader.GetGuid(0),
(ulong)reader.GetInt64(1),
(ulong)reader.GetInt64(2),
DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(3)),
new ChatCode(
(byte)reader.GetInt32(4),
(byte)reader.GetInt32(5),
(byte)reader.GetInt32(6)
),
MessagePackSerializer.Deserialize<List<Chunk>>(
reader.GetFieldValue<byte[]>(7),
MsgPackOptions
),
MessagePackSerializer.Deserialize<List<Chunk>>(
reader.GetFieldValue<byte[]>(8),
MsgPackOptions
),
MessagePackSerializer.Deserialize<SeString>(
reader.GetFieldValue<byte[]>(9),
MsgPackOptions
),
MessagePackSerializer.Deserialize<SeString>(
reader.GetFieldValue<byte[]>(10),
MsgPackOptions
),
reader.GetGuid(11)
);
}
private readonly IPlatformUtil _platformUtil;
private readonly IPluginLogProxy _logger;
// Readiness gate for the FTS5 full-text index. Volatile so the DbViewer's
// per-frame IsFtsIndexBuilt read sees the flip the moment the bulk-insert
// worker calls MarkFtsIndexBuilt(). Set in the ctor by InitFtsReadyCache:
// true when the index already has rows (no rebuild needed) or when the
// messages table itself is empty (nothing to index yet); false otherwise.
private volatile bool _ftsReady;
public bool IsFtsIndexBuilt => _ftsReady;
// Serialises read/write access to the primary Connection so the DbViewer
// filter-worker (Task.Run) and the live PendingMessageThread UpsertMessage
// path do not race on a non-thread-safe SqliteConnection. Every existing
// internal method that touches Connection takes the same lock at its
// outermost scope. RebuildFtsIndex stays outside the lock -- it owns its
// own SqliteConnection via OpenSecondaryConnection.
private readonly object _readLock = new();
internal MessageStore(string dbPath, IPlatformUtil platformUtil, IPluginLogProxy logger)
{
DbPath = dbPath;
@@ -146,6 +204,7 @@ internal class MessageStore : IDisposable
_logger = logger;
Connection = Connect();
Migrate();
InitFtsReadyCache();
}
public void Dispose()
@@ -156,22 +215,31 @@ internal class MessageStore : IDisposable
Connection.Dispose();
}
private SqliteConnection Connect()
private static string BuildConnectionString(string dbPath)
{
var uriBuilder = new SqliteConnectionStringBuilder
{
DataSource = DbPath,
DataSource = dbPath,
DefaultTimeout = 5,
Pooling = false,
Mode = SqliteOpenMode.ReadWriteCreate,
};
return uriBuilder.ToString();
}
var conn = new SqliteConnection(uriBuilder.ToString());
conn.Open();
private void ApplyPragmas(SqliteConnection conn)
{
conn.Execute(@"PRAGMA journal_mode=WAL;");
conn.Execute(@"PRAGMA synchronous=NORMAL;");
if (_platformUtil.IsWine)
conn.Execute(@"PRAGMA cache_size = 32768;");
}
private SqliteConnection Connect()
{
var conn = new SqliteConnection(BuildConnectionString(DbPath));
conn.Open();
ApplyPragmas(conn);
return conn;
}
@@ -190,13 +258,19 @@ internal class MessageStore : IDisposable
migrationsToDo.Add(Migrate1);
migrationsToDo.Add(Migrate2);
migrationsToDo.Add(Migrate3);
migrationsToDo.Add(Migrate4);
break;
case 1:
migrationsToDo.Add(Migrate2);
migrationsToDo.Add(Migrate3);
migrationsToDo.Add(Migrate4);
break;
case 2:
migrationsToDo.Add(Migrate3);
migrationsToDo.Add(Migrate4);
break;
case 3:
migrationsToDo.Add(Migrate4);
break;
}
@@ -307,6 +381,30 @@ internal class MessageStore : IDisposable
SetMigrationVersion(3);
}
private void Migrate4()
{
_logger.Information("Running migration 4: Add FTS5 virtual table for full-text search");
// Standalone FTS5 table (no content='messages' linking, no content_rowid).
// messages.Id is BLOB-PK (Guid), which is incompatible with FTS5's
// content_rowid requirement of an INTEGER rowid alias. We store the
// GUID as a hex TEXT column (UNINDEXED so the tokenizer skips it) and
// FTS5 manages its own internal INTEGER rowid. LoadByGuids joins back
// via WHERE Id IN (... unhex(message_guid)) when the search returns.
using var cmd = Connection.CreateCommand();
cmd.CommandText = """
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
message_guid UNINDEXED,
sender_text,
content_text,
tokenize='unicode61 remove_diacritics 2'
);
""";
cmd.ExecuteNonQuery();
SetMigrationVersion(4);
}
private void SetMigrationVersion(int version)
{
_logger.Information($"Setting version {version}");
@@ -318,14 +416,19 @@ internal class MessageStore : IDisposable
}
internal void ClearMessages()
{
lock (_readLock)
{
Connection.Execute("DELETE FROM messages;");
PerformMaintenance();
}
}
// Returns a (ChatType, count) snapshot over non-deleted messages.
// Used by the Privacy tab to preview retroactive cleanup impact.
internal Dictionary<int, long> GetMessageCountsByChatType()
{
lock (_readLock)
{
var result = new Dictionary<int, long>();
using var cmd = Connection.CreateCommand();
@@ -341,6 +444,7 @@ internal class MessageStore : IDisposable
}
return result;
}
}
// Deletes messages older than the per-channel retention window, with a global
// default for unmapped channels. Runs VACUUM only if rows were removed.
@@ -367,6 +471,8 @@ internal class MessageStore : IDisposable
if (chatTypeDaysMap.Count == 0 && defaultDays <= 0)
return 0;
lock (_readLock)
{
long deleted;
using (var cmd = Connection.CreateCommand())
{
@@ -410,6 +516,7 @@ internal class MessageStore : IDisposable
PerformMaintenance();
return deleted;
}
}
// Hard-deletes every message whose ChatType is not in the allowlist,
// then VACUUMs. Returns the number of rows deleted.
@@ -420,6 +527,8 @@ internal class MessageStore : IDisposable
"CleanupRetainOnly requires at least one allowed ChatType. Use ClearMessages for a full wipe."
);
lock (_readLock)
{
long deleted;
using (var cmd = Connection.CreateCommand())
{
@@ -431,8 +540,11 @@ internal class MessageStore : IDisposable
PerformMaintenance();
return deleted;
}
}
internal void PerformMaintenance()
{
lock (_readLock)
{
Connection.Execute(
@"
@@ -442,6 +554,7 @@ internal class MessageStore : IDisposable
"
);
}
}
private string LogPath => DbPath + "-wal";
@@ -450,11 +563,259 @@ internal class MessageStore : IDisposable
internal long DatabaseLogSize() => !File.Exists(LogPath) ? 0 : new FileInfo(LogPath).Length;
internal int MessageCount()
{
lock (_readLock)
{
using var cmd = Connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM messages;";
return Convert.ToInt32(cmd.ExecuteScalar());
}
}
// Schema probe for the v1.4.8 FTS5 virtual table. Used by the Build-Suite
// tests to verify Migrate4's CREATE VIRTUAL TABLE actually landed without
// duplicating PRAGMA glue in each test body.
internal bool HasMessagesFtsTable()
{
using var cmd = Connection.CreateCommand();
cmd.CommandText = "SELECT count(*) FROM sqlite_master WHERE name='messages_fts';";
return (long)(cmd.ExecuteScalar() ?? 0L) > 0;
}
// Decides whether the FTS index already covers the messages table. Called
// once after Migrate -- empty messages-table is "ready" because there is
// nothing to index yet; a populated fts-table is "ready" because some
// previous run filled it. A populated messages-table with an empty
// fts-table is the "needs rebuild" case the worker (Plugin.cs LoadAsync)
// picks up.
internal void InitFtsReadyCache()
{
using (var cmd = Connection.CreateCommand())
{
cmd.CommandText = "SELECT count(*) FROM messages_fts;";
var ftsRows = (long)(cmd.ExecuteScalar() ?? 0L);
if (ftsRows > 0)
{
_ftsReady = true;
return;
}
}
using var cmd2 = Connection.CreateCommand();
cmd2.CommandText = "SELECT count(*) FROM messages;";
var messageRows = (long)(cmd2.ExecuteScalar() ?? 0L);
_ftsReady = messageRows == 0;
}
// Opens a worker-owned SqliteConnection on the same db path. Used by the
// FTS rebuild worker so the bulk-insert writer stream does not contend
// with the live UpsertMessage path on the primary Connection (WAL allows
// N readers + 1 writer; two writer sessions on the same connection are
// not safe per Microsoft.Data.Sqlite). Caller closes+disposes the
// returned connection and only then calls MarkFtsIndexBuilt() -- the
// DbViewer never sees IsFtsIndexBuilt=true while the worker connection
// is still alive.
internal SqliteConnection OpenSecondaryConnection()
{
var conn = new SqliteConnection(BuildConnectionString(DbPath));
conn.Open();
ApplyPragmas(conn);
return conn;
}
// Worker-only mutator. The bulk-insert worker is the single legitimate
// caller; the flag flips after the worker has closed its own connection.
internal void MarkFtsIndexBuilt() => _ftsReady = true;
// Builds the FTS5 index from scratch on a worker-owned SqliteConnection.
// Chunked-commit (every 500 rows + 5ms sleep) releases the WAL writer
// lock between transactions so the live PendingMessageThread UpsertMessage
// path on the primary Connection does not hit "database is locked" after
// DefaultTimeout=5s. The Thread.Sleep is intentional: it gives the live
// writer a deterministic window to acquire the lock before we re-take
// it for the next chunk.
//
// Cancellation: checked at the top of each row and again after each
// chunk commit, so a Dispose-during-rebuild collapses on the next row
// without trashing the half-built index (DELETE FROM messages_fts at
// the start makes the next run idempotent).
public long RebuildFtsIndex(
SqliteConnection conn,
IProgress<long> progress,
CancellationToken ct
)
{
const int ChunkSize = 500;
using (var clear = conn.CreateCommand())
{
clear.CommandText = "DELETE FROM messages_fts;";
clear.ExecuteNonQuery();
}
long total;
using (var totalCmd = conn.CreateCommand())
{
totalCmd.CommandText = "SELECT count(*) FROM messages;";
total = (long)(totalCmd.ExecuteScalar() ?? 0L);
}
long done = 0;
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT Id, Sender, Content FROM messages ORDER BY Id;";
using var reader = cmd.ExecuteReader();
using var insert = conn.CreateCommand();
insert.CommandText =
"INSERT INTO messages_fts(message_guid, sender_text, content_text) VALUES ($g, $s, $c);";
var pG = insert.CreateParameter();
pG.ParameterName = "$g";
insert.Parameters.Add(pG);
var pS = insert.CreateParameter();
pS.ParameterName = "$s";
insert.Parameters.Add(pS);
var pC = insert.CreateParameter();
pC.ParameterName = "$c";
insert.Parameters.Add(pC);
// Nullable so the finally can dispose exactly once whether the loop
// ends normally, via cancellation between Dispose and BeginTransaction,
// or via an exception in the body.
SqliteTransaction? transaction = conn.BeginTransaction();
insert.Transaction = transaction;
try
{
while (reader.Read())
{
ct.ThrowIfCancellationRequested();
// messages.Id is BLOB-typed in the schema but stored as TEXT
// because Microsoft.Data.Sqlite binds Guid parameters as UUID
// strings by default (UpsertMessage uses AddWithValue("$Id",
// message.Id)). reader.GetValue(0) therefore returns string,
// not byte[]; GetGuid parses the TEXT form regardless.
var idGuid = reader.GetGuid(0);
var senderChunks = MessagePackSerializer.Deserialize<List<Chunk>>(
reader.GetFieldValue<byte[]>(1),
MsgPackOptions
);
var contentChunks = MessagePackSerializer.Deserialize<List<Chunk>>(
reader.GetFieldValue<byte[]>(2),
MsgPackOptions
);
pG.Value = idGuid.ToString();
pS.Value = ChunkUtil.ToRawString(senderChunks);
pC.Value = ChunkUtil.ToRawString(contentChunks);
insert.ExecuteNonQuery();
done++;
if (done % ChunkSize == 0)
{
transaction.Commit();
transaction.Dispose();
transaction = null;
progress.Report(done);
Thread.Sleep(5);
ct.ThrowIfCancellationRequested();
transaction = conn.BeginTransaction();
insert.Transaction = transaction;
}
}
transaction?.Commit();
}
finally
{
transaction?.Dispose();
}
progress.Report(done);
return total;
}
// FTS5 full-text search across the entire messages_fts index. Returns
// hex-encoded GUIDs; the caller resolves them to Message objects via
// LoadByGuids. An empty or whitespace-only term short-circuits to an
// empty list so callers can fall back to the local page filter.
public IReadOnlyList<string> FullTextSearch(string term, int limit = 1000)
{
if (string.IsNullOrWhiteSpace(term))
return Array.Empty<string>();
lock (_readLock)
{
var hexIds = new List<string>(capacity: 256);
using var cmd = Connection.CreateCommand();
cmd.CommandText = """
SELECT message_guid FROM messages_fts
WHERE messages_fts MATCH $term
ORDER BY rank
LIMIT $limit;
""";
cmd.Parameters.AddWithValue("$term", EscapeFtsTerm(term));
cmd.Parameters.AddWithValue("$limit", limit);
using var reader = cmd.ExecuteReader();
while (reader.Read())
hexIds.Add(reader.GetString(0));
return hexIds;
}
}
// Joins UUID strings from FullTextSearch back to Message rows. messages.Id
// is BLOB-declared in the schema but actually stored as TEXT (UUID form)
// because Microsoft.Data.Sqlite serialises Guid parameters as strings by
// default. Binding the lookup parameters as Guid keeps the same TEXT
// storage form on both sides so the IN(...) compare matches. SQLite has a
// hard parameter limit of 999 in default builds, so we chunk the input --
// a 1000-hit FTS query never explodes the SELECT. Result ordering is not
// guaranteed; callers re-sort (e.g. DbViewer sorts by Date descending in
// Sub-Task 4.4).
public IReadOnlyList<Message> LoadByGuids(IReadOnlyList<string> guidStrings)
{
if (guidStrings.Count == 0)
return Array.Empty<Message>();
lock (_readLock)
{
var result = new List<Message>(guidStrings.Count);
const int chunkSize = 500;
for (var offset = 0; offset < guidStrings.Count; offset += chunkSize)
{
var batch = guidStrings.Skip(offset).Take(chunkSize).ToList();
using var cmd = Connection.CreateCommand();
var placeholders = string.Join(",", batch.Select((_, i) => $"$id{i}"));
cmd.CommandText = $"""
SELECT Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind,
Sender, Content, SenderSource, ContentSource, ExtraChatChannel
FROM messages
WHERE Id IN ({placeholders}) AND Deleted = false;
""";
for (var i = 0; i < batch.Count; i++)
cmd.Parameters.AddWithValue($"$id{i}", Guid.Parse(batch[i]));
using var reader = cmd.ExecuteReader();
while (reader.Read())
result.Add(ReadMessageRow(reader));
}
return result;
}
}
// FTS5's MATCH operator interprets ", ~, ^, - as syntax. Wrap user terms
// in double quotes so the search is "what you see is what you get" -- a
// multi-word query matches as a phrase, not as per-word AND. Power users
// can opt into raw MATCH syntax by wrapping their own quotes; we detect
// that and pass the term through unchanged.
internal static string EscapeFtsTerm(string term)
{
if (term.Contains('"'))
return term;
return $"\"{term.Replace("\"", "\"\"")}\"";
}
internal void UpsertMessage(Message message)
{
@@ -465,6 +826,8 @@ internal class MessageStore : IDisposable
return;
}
lock (_readLock)
{
using var cmd = Connection.CreateCommand();
cmd.CommandText =
@"
@@ -517,15 +880,24 @@ internal class MessageStore : IDisposable
cmd.ExecuteNonQuery();
}
}
// 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.
internal MessageEnumerator StreamForExport(
IReadOnlyCollection<int>? chatTypes,
DateTimeOffset? from,
DateTimeOffset? to
)
{
lock (_readLock)
{
var cmd = Connection.CreateCommand();
@@ -556,11 +928,13 @@ internal class MessageStore : IDisposable
return new MessageEnumerator(cmd.ExecuteReader(), _logger);
}
}
// Returns the most recent messages, oldest-first.
// receiver: filter by receiver ContentId (null = no filter)
// since: only include messages after this date (null = no filter)
// count: max rows to return, defaults to 10,000
// Lock caveat: same lazy-enumerator note as StreamForExport.
internal MessageEnumerator GetMostRecentMessages(
ulong? receiver = null,
DateTimeOffset? since = null,
@@ -575,6 +949,8 @@ internal class MessageStore : IDisposable
var whereClause = "WHERE " + string.Join(" AND ", whereClauses);
lock (_readLock)
{
var cmd = Connection.CreateCommand();
// Select last N by date DESC, then reverse to ascending order.
cmd.CommandText =
@@ -604,6 +980,7 @@ internal class MessageStore : IDisposable
return new MessageEnumerator(cmd.ExecuteReader(), _logger);
}
}
// Returns up to limit tells exchanged with the named player, oldest-first.
// SQL narrows by Receiver + ChatType (indexed); client does the final
@@ -620,6 +997,8 @@ internal class MessageStore : IDisposable
if (limit <= 0)
return [];
lock (_readLock)
{
using var cmd = Connection.CreateCommand();
cmd.CommandText =
@"
@@ -655,15 +1034,19 @@ internal class MessageStore : IDisposable
collected.Reverse();
return collected;
}
}
// Soft-deletes a message so it won't appear in queries.
internal void DeleteMessage(Guid id)
{
lock (_readLock)
{
using var cmd = Connection.CreateCommand();
cmd.CommandText = "UPDATE messages SET Deleted = true WHERE Id = $Id;";
cmd.Parameters.AddWithValue("$Id", id);
cmd.ExecuteNonQuery();
}
}
internal long CountDateRange(
DateTime after,
@@ -671,6 +1054,8 @@ internal class MessageStore : IDisposable
IEnumerable<byte> channels,
ulong? receiver = null
)
{
lock (_readLock)
{
using var cmd = Connection.CreateCommand();
@@ -679,7 +1064,9 @@ internal class MessageStore : IDisposable
whereClauses.Add("Receiver = $Receiver");
whereClauses.Add("Date BETWEEN $After AND $Before");
whereClauses.Add($"ChatType IN ({BindIntList(cmd, "cdr", channels.Select(c => (int)c))})");
whereClauses.Add(
$"ChatType IN ({BindIntList(cmd, "cdr", channels.Select(c => (int)c))})"
);
var whereClause = "WHERE " + string.Join(" AND ", whereClauses);
@@ -693,18 +1080,25 @@ internal class MessageStore : IDisposable
cmd.Parameters.AddWithValue("$Receiver", receiver);
cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset)before).ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue(
"$Before",
((DateTimeOffset)before).ToUnixTimeMilliseconds()
);
cmd.CommandTimeout = 120;
return (long)cmd.ExecuteScalar()!;
}
}
// Lock caveat: same lazy-enumerator note as StreamForExport.
internal MessageEnumerator GetDateRange(
DateTime after,
DateTime before,
IEnumerable<byte> channels,
ulong? receiver = null
)
{
lock (_readLock)
{
var cmd = Connection.CreateCommand();
@@ -713,7 +1107,9 @@ internal class MessageStore : IDisposable
whereClauses.Add("Receiver = $Receiver");
whereClauses.Add("Date BETWEEN $After AND $Before");
whereClauses.Add($"ChatType IN ({BindIntList(cmd, "gdr", channels.Select(c => (int)c))})");
whereClauses.Add(
$"ChatType IN ({BindIntList(cmd, "gdr", channels.Select(c => (int)c))})"
);
var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}";
@@ -730,11 +1126,16 @@ internal class MessageStore : IDisposable
cmd.Parameters.AddWithValue("$Receiver", receiver);
cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset)before).ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue(
"$Before",
((DateTimeOffset)before).ToUnixTimeMilliseconds()
);
return new MessageEnumerator(cmd.ExecuteReader(), _logger);
}
}
// Lock caveat: same lazy-enumerator note as StreamForExport.
internal MessageEnumerator GetPagedDateRange(
DateTime after,
DateTime before,
@@ -742,6 +1143,8 @@ internal class MessageStore : IDisposable
ulong? receiver = null,
int page = 0
)
{
lock (_readLock)
{
var cmd = Connection.CreateCommand();
@@ -750,7 +1153,9 @@ internal class MessageStore : IDisposable
whereClauses.Add("Receiver = $Receiver");
whereClauses.Add("Date BETWEEN $After AND $Before");
whereClauses.Add($"ChatType IN ({BindIntList(cmd, "pdr", channels.Select(c => (int)c))})");
whereClauses.Add(
$"ChatType IN ({BindIntList(cmd, "pdr", channels.Select(c => (int)c))})"
);
var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}";
@@ -772,12 +1177,16 @@ internal class MessageStore : IDisposable
cmd.Parameters.AddWithValue("$Receiver", receiver);
cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset)before).ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue(
"$Before",
((DateTimeOffset)before).ToUnixTimeMilliseconds()
);
cmd.Parameters.AddWithValue("$Offset", DbViewer.RowPerPage * page);
cmd.Parameters.AddWithValue("$OffsetCount", DbViewer.RowPerPage);
return new MessageEnumerator(cmd.ExecuteReader(), _logger);
}
}
// Builds a "$prefix0,$prefix1,..." placeholder list and binds values to the command.
// SQLite has no native array parameter, so placeholders are generated per entry.
@@ -816,35 +1225,10 @@ internal class MessageEnumerator(DbDataReader reader, IPluginLogProxy logger)
Message msg;
try
{
// GetGuid up-front so we have an id for the failure log even
// when the rest of the deserialisation throws downstream.
id = reader.GetGuid(0);
msg = new Message(
id,
(ulong)reader.GetInt64(1),
(ulong)reader.GetInt64(2),
DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(3)),
new ChatCode(
(byte)reader.GetInt32(4),
(byte)reader.GetInt32(5),
(byte)reader.GetInt32(6)
),
MessagePackSerializer.Deserialize<List<Chunk>>(
reader.GetFieldValue<byte[]>(7),
MessageStore.MsgPackOptions
),
MessagePackSerializer.Deserialize<List<Chunk>>(
reader.GetFieldValue<byte[]>(8),
MessageStore.MsgPackOptions
),
MessagePackSerializer.Deserialize<SeString>(
reader.GetFieldValue<byte[]>(9),
MessageStore.MsgPackOptions
),
MessagePackSerializer.Deserialize<SeString>(
reader.GetFieldValue<byte[]>(10),
MessageStore.MsgPackOptions
),
reader.GetGuid(11)
);
msg = MessageStore.ReadMessageRow(reader);
}
catch (Exception e)
{
+175 -8
View File
@@ -14,6 +14,7 @@ using HellionChat.Ipc;
using HellionChat.Resources;
using HellionChat.Ui;
using HellionChat.Util;
using Microsoft.Data.Sqlite;
namespace HellionChat;
@@ -125,8 +126,20 @@ public sealed class Plugin : IAsyncDalamudPlugin
// Idempotency guard — Dalamud may fire DisposeAsync twice in a reload race.
private int _disposeStarted;
// Set in the first DisposeAsync statement so async callbacks scheduled
// via Framework.RunOnTick (v1.4.8 B3 retention sweep) can early-bail
// before they touch state that has already been torn down. Volatile
// because the tick reads it from a different thread than the writer.
private volatile bool _isDisposing;
internal int DeferredSaveFrames = -1;
// Cancels the v1.4.8 FTS5 bulk-insert worker on plugin teardown. The
// worker runs off the framework thread on its own SqliteConnection, so a
// Dispose mid-rebuild must signal cancellation before MessageManager
// tears down (the worker logs "rebuild failed" via Log on error paths).
private CancellationTokenSource? _ftsRebuildCts;
// Serialises retention sweeps so a manual trigger and the 24h auto-sweep
// can't run in parallel. Volatile because the ImGui thread reads it outside
// the lock to gate the manual button.
@@ -176,8 +189,8 @@ public sealed class Plugin : IAsyncDalamudPlugin
if (Config.Version < 16)
{
throw new InvalidOperationException(
$"HellionChat v1.4.7 requires config schema v16, got v{Config.Version}. "
+ "Please install v1.4.2 first to migrate the configuration, then upgrade to v1.4.7."
$"HellionChat v1.4.8 requires config schema v16, got v{Config.Version}. "
+ "Please install v1.4.2 first to migrate the configuration, then upgrade to v1.4.8."
);
}
Config.Version = 17;
@@ -221,6 +234,12 @@ public sealed class Plugin : IAsyncDalamudPlugin
Directory.CreateDirectory(customThemesDir);
SeedExampleThemeIfEmpty(customThemesDir);
ThemeRegistry = new Themes.ThemeRegistry(customThemesDir);
// Warm up the custom-theme cache before the first Switch.
// LoadCustomBySlug is a reverse-lookup over _customCache; on a
// cold cache a Config.Theme that points at a custom slug would
// fall through to the built-in default. AllCustom is a lazy
// enumerable, so iterate it explicitly to materialise the cache.
foreach (var _ in ThemeRegistry.AllCustom()) { }
ThemeRegistry.Switch(Config.Theme);
cancellationToken.ThrowIfCancellationRequested();
@@ -282,6 +301,113 @@ public sealed class Plugin : IAsyncDalamudPlugin
if (Interface.Reason is not PluginLoadReason.Boot)
MessageManager.FilterAllTabsAsync();
// Kick the FTS5 rebuild worker if Migrate4 just added the schema or
// a previous run was cut short (InitFtsReadyCache leaves _ftsReady
// false in that case). Runs off the framework thread on its own
// SqliteConnection so the live UpsertMessage path keeps flowing
// through the chunked-commit windows.
_ftsRebuildCts = new CancellationTokenSource();
if (!MessageManager.Store.IsFtsIndexBuilt)
{
var token = _ftsRebuildCts.Token;
_ = Task.Run(
async () =>
{
// FQN: Plugin.Notification (Z.74) shadows the type name.
Dalamud.Interface.ImGuiNotification.IActiveNotification? notif = null;
try
{
notif = Notification.AddNotification(
new Dalamud.Interface.ImGuiNotification.Notification
{
Title = "Hellion Chat",
Content = "Indexing chat history for full-text search...",
Type = Dalamud
.Interface
.ImGuiNotification
.NotificationType
.Info,
Minimized = false,
InitialDuration = TimeSpan.FromMinutes(10),
}
);
// Progress<T> raises this callback on the captured
// sync-context (Task.Run worker pool). IActiveNotification
// is ImGui-backed and mutates the UI, so marshal the
// mutation onto the framework thread via RunOnTick.
var progress = new Progress<long>(done =>
{
Framework.RunOnTick(() =>
{
if (notif is { } n)
n.Content = $"Indexing chat history: {done:N0} messages...";
});
});
// Worker-owned connection. Closed+disposed before we
// flip the readiness flag so the DbViewer never sees
// IsFtsIndexBuilt=true while the worker connection
// is still alive.
SqliteConnection? workerConn = null;
try
{
workerConn = MessageManager.Store.OpenSecondaryConnection();
var total = await Task.Run(
() =>
MessageManager.Store.RebuildFtsIndex(
workerConn,
progress,
token
),
token
)
.ConfigureAwait(false);
workerConn.Close();
workerConn.Dispose();
workerConn = null;
MessageManager.Store.MarkFtsIndexBuilt();
if (notif is { } final)
{
final.Content = $"Indexed {total:N0} messages.";
final.Type = Dalamud
.Interface
.ImGuiNotification
.NotificationType
.Success;
final.InitialDuration = TimeSpan.FromSeconds(5);
}
}
finally
{
workerConn?.Dispose();
}
}
catch (OperationCanceledException)
{
notif?.DismissNow();
}
catch (Exception ex)
{
Log.Error(ex, "FTS index rebuild failed");
if (notif is { } err)
{
err.Content =
"Full-text indexing failed -- search will use local filter only.";
err.Type = Dalamud
.Interface
.ImGuiNotification
.NotificationType
.Error;
}
}
},
_ftsRebuildCts.Token
);
}
Interface.UiBuilder.DisableCutsceneUiHide = true;
Interface.UiBuilder.DisableGposeUiHide = true;
@@ -320,6 +446,12 @@ public sealed class Plugin : IAsyncDalamudPlugin
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
return;
// Set before any cleanup so deferred Framework.RunOnTick callbacks
// (B3 retention sweep) see the flag and bail out before they touch
// MessageManager / Log / static fields that the rest of this method
// is about to tear down.
_isDisposing = true;
Exception? failure = null;
// Unsubscribe hooks first — mirrors the hooks-last subscribe order in LoadAsync.
@@ -328,6 +460,19 @@ public sealed class Plugin : IAsyncDalamudPlugin
failure = CaptureFailure(failure, () => Interface.UiBuilder.Draw -= Draw);
failure = CaptureFailure(failure, () => Framework.Update -= FrameworkUpdate);
// Signal the FTS rebuild worker to bail. Runs before MessageManager
// tears down so the worker's "rebuild failed" log path still finds
// a live Log static. Worker owns its own SqliteConnection and disposes
// it itself; we only flip the cancellation flag here.
failure = CaptureFailure(
failure,
() =>
{
_ftsRebuildCts?.Cancel();
_ftsRebuildCts?.Dispose();
}
);
// Flush a pending DeferredSave — FrameworkUpdate won't fire it anymore.
failure = CaptureFailure(
failure,
@@ -578,15 +723,31 @@ public sealed class Plugin : IAsyncDalamudPlugin
if (deleted > 0)
{
Log.Information($"Retention sweep deleted {deleted} expired messages.");
// Run clear+refilter on the framework thread — FilterAllTabsAsync
// is fire-and-forget and would race the next sweep cycle.
Framework
.Run(() =>
// Schedule on the next framework tick to avoid the ~194ms
// hitch from blocking with .Wait() while the framework
// finishes the current frame. Tabs-list mutation must
// stay on the framework thread because Plugin.Config.Tabs
// (Configuration.cs:222) is not lock-protected and
// AutoTellTabsService can mutate it from background paths.
// Pattern reference: SimpleTweaks
// Tweaks/Chat/CaseInsensitiveCommands.cs:45.
Framework.RunOnTick(() =>
{
// 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();
})
.Wait();
}
catch (Exception ex)
{
Log.Error(ex, "Retention sweep clear+refilter failed");
}
});
}
else
{
@@ -610,6 +771,12 @@ public sealed class Plugin : IAsyncDalamudPlugin
private void Draw()
{
// v1.4.8 B2: pick up external edits of the active custom theme JSON
// without forcing the user to re-click the picker. The disk-stat is
// 1Hz-throttled inside RefreshActiveIfStale, so this is essentially
// free on built-in themes and ~1 stat/second on custom themes.
ThemeRegistry.RefreshActiveIfStale();
// Theme engine is always active; Classic is a theme, not a disabled state.
using IDisposable _style = HellionStyle.PushGlobal(
ThemeRegistry.Active,
+5
View File
@@ -402,4 +402,9 @@ internal class HellionStrings
// Hellion Chat — v1.3.0 Honorific title slot tooltip
internal static string ChatHeader_HonorificTitle_Tooltip => Get(nameof(ChatHeader_HonorificTitle_Tooltip));
// Hellion Chat — v1.4.8 DbViewer full-text search toggle
internal static string DbViewer_FullTextToggle => Get(nameof(DbViewer_FullTextToggle));
internal static string DbViewer_FullTextToggle_Hint_Indexing => Get(nameof(DbViewer_FullTextToggle_Hint_Indexing));
internal static string DbViewer_FullTextToggle_Hint_PhraseMode => Get(nameof(DbViewer_FullTextToggle_Hint_PhraseMode));
}
@@ -917,4 +917,13 @@
<data name="ChatHeader_HonorificTitle_Tooltip" xml:space="preserve">
<value>Custom-Titel von Honorific</value>
</data>
<data name="DbViewer_FullTextToggle" xml:space="preserve">
<value>Volltext-Suche</value>
</data>
<data name="DbViewer_FullTextToggle_Hint_Indexing" xml:space="preserve">
<value>Der Volltext-Index wird noch gebaut. Die lokale Suche bleibt verfügbar.</value>
</data>
<data name="DbViewer_FullTextToggle_Hint_PhraseMode" xml:space="preserve">
<value>Sucht nach der exakten Wortfolge. Mehrere Wörter werden nur gefunden, wenn sie zusammen und in dieser Reihenfolge stehen. Wer rohe FTS5-MATCH-Syntax nutzen will, setzt eigene Anführungszeichen um den Suchbegriff.</value>
</data>
</root>
@@ -917,4 +917,13 @@
<data name="ChatHeader_HonorificTitle_Tooltip" xml:space="preserve">
<value>Custom title from Honorific</value>
</data>
<data name="DbViewer_FullTextToggle" xml:space="preserve">
<value>Full-text search</value>
</data>
<data name="DbViewer_FullTextToggle_Hint_Indexing" xml:space="preserve">
<value>The full-text index is still being built. The local filter remains available.</value>
</data>
<data name="DbViewer_FullTextToggle_Hint_PhraseMode" xml:space="preserve">
<value>Searches for the exact phrase. Multi-word queries match only when the words appear together in order. To use raw FTS5 MATCH syntax, wrap your term in double quotes yourself.</value>
</data>
</root>
+98 -10
View File
@@ -6,6 +6,13 @@ public sealed class ThemeRegistry
{
public const string DefaultSlug = HellionArctic.Slug;
// 1Hz throttle for the v1.4.8 B2 auto-refresh-on-active path. The
// Plugin.Draw hook calls RefreshActiveIfStale every frame, but the
// actual File.GetLastWriteTimeUtc disk-stat only runs once per second
// -- 60fps would otherwise mean 3600 stats/min on the same path (more
// on Wine). Same idiom as the StatusBar 1Hz cache.
private const long ActiveStampPollIntervalMs = 1000;
private readonly Dictionary<string, Theme> _builtIns;
private readonly Dictionary<string, (Theme Theme, DateTime Stamp)> _customCache = new(
StringComparer.OrdinalIgnoreCase
@@ -13,6 +20,15 @@ public sealed class ThemeRegistry
private readonly string? _customThemesDir;
private Theme _active;
// v1.4.8 B2: source path of the currently active custom theme. Captured
// at Switch() time so RefreshActiveIfStale does not have to reconstruct
// a filename from the slug -- custom theme filenames are not required
// to match the slug they declare in the JSON body. Null when the active
// theme is built-in or no custom-themes directory is configured.
private string? _activeCustomPath;
private long _lastActiveStampCheckMs = -ActiveStampPollIntervalMs;
private DateTime _lastActiveStamp = DateTime.MinValue;
public ThemeRegistry(string? customThemesDir = null)
{
// Insertion order drives the Theme-Picker grid layout (3 columns).
@@ -48,7 +64,9 @@ public sealed class ThemeRegistry
if (_builtIns.TryGetValue(slug, out var b))
return b;
var custom = LoadCustomBySlug(slug);
// Discard the source path here; Switch is the only call-site that
// needs to remember it for the auto-refresh hook.
var custom = LoadCustomBySlug(slug, out _);
if (custom != null)
return custom;
@@ -59,12 +77,70 @@ public sealed class ThemeRegistry
public IEnumerable<Theme> AllCustom() => RefreshCustomCache();
// Built-in-first to match Get(slug)'s lookup order. A user theme JSON
// that declares the same slug as a built-in is ignored deliberately --
// having Switch prefer custom and Get prefer built-in would produce
// a state where _active and Get(_active.Slug) disagree.
public void Switch(string slug)
{
var theme = Get(slug);
if (_builtIns.TryGetValue(slug, out var builtin))
{
_active = builtin;
_active.RecomputeAbgrCache();
_activeCustomPath = null;
return;
}
var customTheme = LoadCustomBySlug(slug, out var customPath);
if (customTheme is not null)
{
_active = customTheme;
// Defensive — ensures any future theme source always gets a populated cache.
theme.RecomputeAbgrCache();
_active = theme;
_active.RecomputeAbgrCache();
_activeCustomPath = customPath;
// Force a first-tick reload-check after the switch so the stamp
// baseline is established on the next RefreshActiveIfStale call.
_lastActiveStamp = DateTime.MinValue;
return;
}
// Fallback: neither built-in nor custom matched. Drop to default
// and clear the active custom path so RefreshActiveIfStale stays idle.
_active = _builtIns[DefaultSlug];
_active.RecomputeAbgrCache();
_activeCustomPath = null;
}
// 1Hz-throttled disk-stat on the currently active custom theme file.
// When the file's LastWriteTime moves forward (editor save), reload the
// theme via Get() so the user sees the edit immediately without
// re-selecting in the picker. Built-in themes short-circuit; custom
// themes without an _activeCustomPath (e.g. Switch fell to default)
// short-circuit too.
public void RefreshActiveIfStale()
{
var now = Environment.TickCount64;
if (now - _lastActiveStampCheckMs < ActiveStampPollIntervalMs)
return;
_lastActiveStampCheckMs = now;
if (_active.IsBuiltIn)
return;
var path = _activeCustomPath;
if (path is null || !File.Exists(path))
return;
var stamp = File.GetLastWriteTimeUtc(path);
if (!ThemeStampDiff.IsStale(_lastActiveStamp, stamp))
return;
_lastActiveStamp = stamp;
// Get() re-runs RefreshCustomCache which picks up the new content
// (the cache keys by path + LastWriteTime, so a mtime bump invalidates).
// RecomputeAbgrCache happens inside RefreshCustomCache on cache miss.
var reloaded = Get(_active.Slug);
_active = reloaded;
}
// 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION.
@@ -77,18 +153,30 @@ public sealed class ThemeRegistry
return code == 0x80070020u || code == 0x80070021u;
}
// Custom themes are loaded lazily, cached by LastWriteTime.
// A changed JSON is reloaded on the next lookup.
private Theme? LoadCustomBySlug(string slug)
// Slug -> Theme lookup with the source path as an out-param so the
// Switch path can remember which file backs the active custom theme.
// Pure reverse-lookup over the existing _customCache: that cache is
// already Path -> (Theme, Stamp), so iterating it costs nothing,
// avoids a re-parse of every JSON, and keeps the parse logic (and
// the recoverable-file-lock recovery) confined to RefreshCustomCache.
// The cache must be warm before this runs; Plugin.LoadAsync triggers
// a one-time warm-up via AllCustom() before the first Switch call.
private Theme? LoadCustomBySlug(string slug, out string? sourcePath)
{
sourcePath = null;
if (_customThemesDir is null)
return null;
if (!Directory.Exists(_customThemesDir))
return null;
foreach (var theme in RefreshCustomCache())
if (string.Equals(theme.Slug, slug, StringComparison.OrdinalIgnoreCase))
return theme;
foreach (var kvp in _customCache)
{
if (string.Equals(kvp.Value.Theme.Slug, slug, StringComparison.OrdinalIgnoreCase))
{
sourcePath = kvp.Key;
return kvp.Value.Theme;
}
}
return null;
}
+20
View File
@@ -0,0 +1,20 @@
namespace HellionChat.Themes;
// Pure stale-check for the v1.4.8 B2 theme-auto-refresh-on-active path.
// Lives in a free helper class so the Build-Suite can exercise the diff
// rules without instantiating ThemeRegistry (which touches the Dalamud
// log proxy and the filesystem). The rules:
// - DateTime.MinValue on the current stat means we could not read the
// file -- hold the last known good (return false).
// - Equal stamps mean no change since we last saw it.
// - Any other difference, including the first observation where lastSeen
// is MinValue, counts as stale and triggers a reload.
internal static class ThemeStampDiff
{
public static bool IsStale(System.DateTime lastSeen, System.DateTime current)
{
if (current == System.DateTime.MinValue)
return false;
return current != lastSeen;
}
}
+3 -2
View File
@@ -419,8 +419,9 @@ public sealed class ChatLogWindow : Window
// The hint banner renders before this block so ImGui already accounts for it.
height -= ImGui.GetFrameHeightWithSpacing();
// Status bar at the window bottom reserves 22px + 2px spacing.
height -= StatusBar.Height + 2;
// StatusBar.Height now bakes in its own DPI-aware 2px spacer, so the
// window reservation is just Height -- no extra +2 (v1.4.8 B1).
height -= StatusBar.Height;
return height;
}
+72 -1
View File
@@ -2,6 +2,7 @@
using System.Globalization;
using System.Numerics;
using System.Text;
using System.Threading;
using Dalamud.Bindings.ImGui;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface;
@@ -33,11 +34,21 @@ public class DbViewer : Window
private int CurrentPage = 1;
private string SimpleSearchTerm = "";
// v1.4.8 H2: opt-in full-text search across the whole DB via FTS5.
// Transient UI state (per-session), not persisted -- users opt in fresh
// every time so they always see the page-filter as the default mode.
private bool UseFullTextSearch;
private bool OnlyCurrentCharacter = true;
private readonly Dictionary<ChatType, (ChatSource, ChatSource)> SelectedChannels;
private bool IsProcessing;
private long ProcessingStart = Environment.TickCount64;
// Bumped per trigger so a late worker drops itself instead of overwriting
// a newer result.
private long _ftsFilterSeq;
private (DateTime Min, DateTime Max, int Page, bool Local, int ChannelCount) LastProcessed;
private string MinDateString = "";
@@ -233,6 +244,24 @@ public class DbViewer : Window
tooltipRight: Language.Page_ArrowRight_Tooltip
);
// Full-text search toggle (v1.4.8 H2). IsFtsIndexBuilt is a cached
// volatile bool in MessageStore -- single field read per frame, no
// SELECT count(*). ImRaii.Disabled blocks any click while the index
// is still being built, so no defensive force-off branch needed
// inside the if-body. UseFullTextSearch is transient UI state, so we
// do not call SaveConfig here.
var ftsReady = Plugin.MessageManager.Store.IsFtsIndexBuilt;
using (ImRaii.Disabled(!ftsReady))
{
if (ImGui.Checkbox(HellionStrings.DbViewer_FullTextToggle, ref UseFullTextSearch))
TriggerFilterRefresh();
}
ImGuiUtil.HelpMarker(
ftsReady
? HellionStrings.DbViewer_FullTextToggle_Hint_PhraseMode
: HellionStrings.DbViewer_FullTextToggle_Hint_Indexing
);
ImGui.SameLine(ImGui.GetContentRegionMax().X - width);
ImGui.SetNextItemWidth(width);
if (
@@ -243,7 +272,7 @@ public class DbViewer : Window
30
)
)
Filtered = Filter(Messages);
TriggerFilterRefresh();
// Third row
@@ -447,11 +476,53 @@ public class DbViewer : Window
}
}
// FTS path hits SQLite per keystroke -- dispatch to a worker, drop stale
// results via _ftsFilterSeq. Page-filter path is in-memory LINQ, stays
// inline.
private void TriggerFilterRefresh()
{
if (!UseFullTextSearch || !Plugin.MessageManager.Store.IsFtsIndexBuilt)
{
Filtered = Filter(Messages);
return;
}
var snapshot = Messages;
var mySeq = Interlocked.Increment(ref _ftsFilterSeq);
Task.Run(() =>
{
try
{
var result = Filter(snapshot);
if (Interlocked.Read(ref _ftsFilterSeq) == mySeq)
Filtered = result;
}
catch (Exception ex)
{
Plugin.LogProxy.Error(ex, "FTS filter worker failed");
}
});
}
private ConcurrentStack<Message> Filter(Message[] messages)
{
if (SimpleSearchTerm == "")
return new ConcurrentStack<Message>(messages.Reverse().OrderByDescending(m => m.Date));
// Full-text mode bypasses the page-bounded messages array and queries
// the FTS5 index across the whole DB. IsFtsIndexBuilt re-check guards
// against the (rare) case of the toggle being on while the index is
// mid-rebuild -- ImRaii.Disabled prevents the user from flipping it,
// but a Dispose-and-reopen during indexing could leave UseFullTextSearch
// true while ftsReady flipped back to false; the local fallback below
// still serves the page.
if (UseFullTextSearch && Plugin.MessageManager.Store.IsFtsIndexBuilt)
{
var guidHits = Plugin.MessageManager.Store.FullTextSearch(SimpleSearchTerm);
var resolved = Plugin.MessageManager.Store.LoadByGuids(guidHits);
return new ConcurrentStack<Message>(resolved.OrderByDescending(m => m.Date));
}
return new ConcurrentStack<Message>(
messages
.Reverse()
+13 -4
View File
@@ -2,6 +2,7 @@ using System.Globalization;
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using HellionChat.Code;
using HellionChat.Resources;
@@ -9,12 +10,20 @@ using HellionChat.Util;
namespace HellionChat.Ui;
// Bottom status bar, 22px tall. Slots left to right: channel indicator,
// privacy badge, counts, tells (hidden at 0), version (right-aligned).
// Updates at 1Hz; format strings are cached between updates.
// Bottom status bar. Slots left to right: channel indicator, privacy badge,
// counts, tells (hidden at 0), version (right-aligned). Updates at 1Hz;
// format strings are cached between updates.
internal sealed class StatusBar
{
public const float Height = 22f;
// DPI-aware bar height. The previous fixed 22px constant clipped on
// Windows display-scaling >100% because ImGui renders the font bigger
// than the reservation. GetTextLineHeightWithSpacing scales with the
// current ImGui font; the 2px spacer is GlobalScale-rounded to stay
// on integer pixel boundaries (same idiom as v1.4.6 F7.2 underline-pill
// in ChatLogWindow.cs:1639-1653).
public static float Height =>
ImGui.GetTextLineHeightWithSpacing() + MathF.Round(2f * ImGuiHelpers.GlobalScale);
private const long UpdateIntervalMs = 1000;
// Initially outdated so the first frame always computes fresh.
+15 -19
View File
@@ -2,7 +2,7 @@
[![Build](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/actions/workflows/build.yml/badge.svg?branch=main)](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/actions/workflows/build.yml)
[![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE)
[![Latest release](https://img.shields.io/badge/release-v1.4.7-brightgreen)](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/latest)
[![Latest release](https://img.shields.io/badge/release-v1.4.8-brightgreen)](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/latest)
[![Dalamud API](https://img.shields.io/badge/Dalamud-API_15-purple)](https://github.com/goatcorp/Dalamud)
[![.NET](https://img.shields.io/badge/.NET-10.0-512BD4)](https://dotnet.microsoft.com/)
[![FFXIV](https://img.shields.io/badge/FFXIV-Dawntrail-c3a37f)](https://www.finalfantasyxiv.com/)
@@ -11,7 +11,7 @@
<img src="docs/images/hellion-forge.png" alt="Hellion Forge" width="180" />
</p>
**Version 1.4.7** — Privacy-first chat plugin for FINAL FANTASY XIV / Dalamud, built on
**Version 1.4.8** — Privacy-first chat plugin for FINAL FANTASY XIV / Dalamud, built on
[Chat 2](https://github.com/Infiziert90/ChatTwo) (EUPL-1.2).
Hellion Chat is a privacy-first plugin built on the Chat 2 foundation. The majority of the engine comes from Chat 2
@@ -286,23 +286,19 @@ An optional submission to the Dalamud main plugin repo (in addition to the custo
## Project Status
**Version 1.4.7**Backlog cleanup and the first user-visible feature bundle since v1.4.5. TempTell tabs can now be
pinned via right-click; pinned tabs survive relog, keep their conversation history (loaded on demand from the message
store), and stay bound to the same `/tell` partner. A hard cap of 5 pinned tabs lives in a pool separate from the 15-tab
auto-tell pool, so the total ceiling is 20 tabs. The sidebar groups pinned tabs into their own section with its own
divider header. Honorific glow outlines now render when the title carries a Glow colour — opt-in via **Settings →
Integrations → Render glow outlines (Honorific)**, default off, so v1.4.6 visuals stay untouched for users who don't
care and the per-frame DrawList overhead is skipped on low-end hardware. Honorific gradient (Color3 / GradientColourSet
/ Wave / Pulse) is parsed and stashed for a later cycle, but currently renders as the primary colour. Sidebar width is
configurable in **Theme & Layout** between 44 and 160 px; default stays icon-only so existing users see no layout
change. `Configuration.UpdateFrom` now preserves the runtime `CurrentChannel` across the persistent-tab merge, and
`TabSwitched` deep-clones the seeded channel — together they fix a Settings-Save regression where the chat input could
pop back to `/tell <pinned-partner>` after touching settings while on a Party or Linkshell tab. Internal items:
`IPluginLogProxy` indirection over Dalamud's `IPluginLog` routes all ~91 `Plugin.Log` call sites through a testable
proxy, closing the test-isolation gap F12.1 left in v1.4.6 (`MessageStore.Migrate0` now runs in xUnit without loading
`Dalamud.dll`). `Util/ImGuiUtil.cs`'s `DrawArrows` IconButton id gets explicit parentheses on the increment. Migration
v16 → v17 is additive (new `Tab.IsPinned` flag, default false). Eighth sub-patch of the v1.4.x polish sweep series (as
of 2026-05-13).
**Version 1.4.8**Hook-Layer and Polish Quick-Wins. The Database Viewer now has an optional FTS5 full-text search
across the entire chat history. Toggle "Full-text search" next to the search bar; the index is built asynchronously on
first run after the update with a progress toast, and the toggle stays disabled until the build completes. Multi-word
terms match as exact phrases by default; power users can opt into raw FTS5 `MATCH` syntax by wrapping their own
double-quotes. Custom theme files auto-reload when edited while the theme is active — save the JSON in your editor and
the live render picks up the change within a second, no picker click. Retention sweep no longer blocks the framework
thread (`Framework.Run(...).Wait()` replaced by `Framework.RunOnTick(...)`), removing the ~194 ms hitch per sweep. Status
bar height is now derived from `GetTextLineHeightWithSpacing()` plus a DPI-aware spacer so the bar renders correctly at
Windows display scaling above 100 %. Receive-suppressed-tells routing is postponed to v1.5.x; the investigation in this
cycle showed that the FFXIV `ContentIdResolverHook` does not fire when other plugins suppress tells via
`CheckMessageHandled`, which means tell-partner identification breaks for AutoTellTab routing — the fix lives next to
the planned ad-block hook layer where the same `RaptureLogModule` patch surface comes up anyway. Migration v17 stays
(no schema bump). Ninth sub-patch of the v1.4.x polish sweep series (as of 2026-05-14).
Hellion Chat is a standalone plugin, no longer a fork in the repository sense. Fully completed:
+29
View File
@@ -10,6 +10,35 @@ to the release pages for details.
---
## Hellion Chat 1.4.8 — Hook-Layer and Polish Quick-Wins (2026-05-14)
Ninth sub-patch of the v1.4.x polish-sweep series. Hook-layer cluster (FTS5 full-text search, ad-block foundation
investigation) plus three polish quick-wins.
- DbViewer full-text search: optional FTS5 index across the full chat history. Built asynchronously on first run after
the update with a progress toast (UI stays responsive, the toggle is disabled until the build completes). The local
page-filter remains the default mode. Multi-word queries match as exact phrases; power users can opt into raw FTS5
`MATCH` syntax by wrapping their own double-quotes around the term.
- Custom theme files now auto-reload when edited while the theme is active. Save the JSON in your editor and the live
render picks up the change within a second — no need to re-click the theme in the picker. Disk-stat is throttled to
1 Hz so per-frame cost stays free.
- Retention sweep no longer blocks the framework thread. `Framework.Run(...).Wait()` is replaced by
`Framework.RunOnTick(...)`, which removes the ~194 ms hitch the sweep used to add per run.
- Status bar height is derived from `GetTextLineHeightWithSpacing()` plus a DPI-aware spacer so the bar renders
correctly at Windows display scaling above 100 %. Linux/Wayland default of 100 % is unaffected.
- Receive-suppressed-tells routing was investigated this cycle and **postponed to v1.5.x**. When other plugins suppress
tells via `CheckMessageHandled`, FFXIV's chat pipeline skips the `RaptureLogModule.AddMsgSourceEntry` path, which means
HellionChat's `ContentIdResolverHook` does not fire and tell-partner identification breaks for AutoTellTab routing.
The proper fix sits next to the planned ad-block hook layer (`RaptureLogModule.ShowMiniTalkPlayer` and friends) where
the same patch surface comes up anyway.
- Internal: storage form of `messages.Id` clarified (declared BLOB but Microsoft.Data.Sqlite stores Guid parameters as
TEXT). FTS bulk insert and `LoadByGuids` join now match the TEXT storage form on both sides. Migration v17 stays
(no schema bump).
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
---
## Hellion Chat 1.4.7 — Backlog Cleanup and Mid-Features (2026-05-13)
Eighth sub-patch of the v1.4.x polish-sweep series. First user-visible feature bundle since v1.4.5 — pinned tell tabs
+21 -4
View File
@@ -10,11 +10,28 @@ the plugin's privacy-first scope during brainstorming.
---
## Next Cycle (v1.4.8)
## Next Cycle (v1.4.9)
**Hook-Layer Cycle.** Receive-suppressed-tells toggle (cross-reference XIVIM #73 bubble-layer sub-task), Database Viewer
full-text search via SQLite FTS5, plus preparation for the later Ad-Block cycle. Hook-layer investigation is shared
across these items so they cluster naturally in one sub-patch.
**Plugin-Load Render Polish.** Erststart-Frame-Hitch (~110 ms UiBuilder) and the related Font-Atlas + Auto-Translate
warmup costs surface every load and are reproducible in `/xlstats`. The cycle also unblocks the lazy-window refactor
sketched in `feedback_lazy_window_dalamud` and the slash-command centralisation that comes with it.
---
## v1.4.8 — Hook-Layer and Polish Quick-Wins (released 2026-05-14)
Ninth sub-patch of the v1.4.x Polish Sweep series. Database Viewer gains an optional FTS5 full-text search across the
full chat history, built asynchronously on first run after the update with a progress toast; the local page-filter
remains the default mode. Custom theme files auto-reload when edited while the theme is active (1 Hz disk-stat throttle,
so per-frame cost is free). Retention sweep no longer blocks the framework thread — `Framework.Run(...).Wait()` is
replaced by `Framework.RunOnTick(...)`, removing the ~194 ms hitch per sweep. Status-bar height is now derived from
`GetTextLineHeightWithSpacing()` plus a DPI-aware spacer so the bar renders correctly at Windows display scaling above
100 %. Receive-suppressed-tells routing was investigated and **postponed to v1.5.x**: when other plugins suppress tells
via `CheckMessageHandled`, FFXIV's chat-pipeline skips the `RaptureLogModule.AddMsgSourceEntry` path, which means the
`ContentIdResolverHook` does not fire and tell-partner identification breaks. The fix belongs next to the planned ad-block
hook layer where the same patch surface comes up anyway. Migration v17 stays (no schema bump). H3 leaves a foundation
note in the Vault (`Projekte/FFXIV/Hellion Chat/v1.5.x Ad-Block Foundation.md`) covering the NoSoliciting filter +
bubble-layer hook pattern as a ready-made template for the v1.5.x cycle.
---
+6 -6
View File
File diff suppressed because one or more lines are too long