Merge branch 'feature/v1.4.8'
This commit is contained in:
@@ -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,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 -->
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/actions/workflows/build.yml)
|
||||
[](LICENSE)
|
||||
[](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/latest)
|
||||
[](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/latest)
|
||||
[](https://github.com/goatcorp/Dalamud)
|
||||
[](https://dotnet.microsoft.com/)
|
||||
[](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:
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user