ChatTwo/ChatTwo/Store.cs

442 lines
16 KiB
C#
Executable File

using System.Collections.Concurrent;
using System.Diagnostics;
using System.Numerics;
using ChatTwo.Code;
using ChatTwo.Resources;
using ChatTwo.Util;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Plugin.Services;
using ImGuiNET;
using LiteDB;
using Lumina.Excel.GeneratedSheets;
namespace ChatTwo;
internal class Store : IDisposable {
internal const int MessagesLimit = 10_000;
private Plugin Plugin { get; }
private ConcurrentQueue<(uint, Message)> Pending { get; } = new();
private Stopwatch CheckpointTimer { get; } = new();
internal ILiteDatabase Database { get; private set; }
private ILiteCollection<Message> Messages => this.Database.GetCollection<Message>("messages");
private Dictionary<ChatType, NameFormatting> Formats { get; } = new();
private ulong LastContentId { get; set; }
private ulong CurrentContentId {
get {
var contentId = this.Plugin.ClientState.LocalContentId;
return contentId == 0 ? this.LastContentId : contentId;
}
}
internal Store(Plugin plugin) {
this.Plugin = plugin;
this.CheckpointTimer.Start();
BsonMapper.Global = new BsonMapper {
IncludeNonPublic = true,
TrimWhitespace = false,
// EnumAsInteger = true,
};
if (this.Plugin.Config.DatabaseMigration == 0) {
BsonMapper.Global.Entity<Message>()
.Id(msg => msg.Id)
.Ctor(doc => new Message(
doc["_id"].AsObjectId,
(ulong) doc["Receiver"].AsInt64,
(ulong) doc["ContentId"].AsInt64,
DateTime.UnixEpoch.AddMilliseconds(doc["Date"].AsInt64),
doc["Code"].AsDocument,
doc["Sender"].AsArray,
doc["Content"].AsArray,
doc["SenderSource"],
doc["ContentSource"],
doc["SortCode"].AsDocument
));
} else {
BsonMapper.Global.Entity<Message>()
.Id(msg => msg.Id)
.Ctor(doc => new Message(
doc["_id"].AsObjectId,
(ulong) doc["Receiver"].AsInt64,
(ulong) doc["ContentId"].AsInt64,
DateTime.UnixEpoch.AddMilliseconds(doc["Date"].AsInt64),
doc["Code"].AsDocument,
doc["Sender"].AsArray,
doc["Content"].AsArray,
doc["SenderSource"],
doc["ContentSource"],
doc["SortCode"].AsDocument,
doc["ExtraChatChannel"]
));
}
BsonMapper.Global.RegisterType<Payload?>(
payload => {
switch (payload) {
case AchievementPayload achievement:
return new BsonDocument(new Dictionary<string, BsonValue> {
["Type"] = new("Achievement"),
["Id"] = new(achievement.Id),
});
case PartyFinderPayload partyFinder:
return new BsonDocument(new Dictionary<string, BsonValue> {
["Type"] = new("PartyFinder"),
["Id"] = new(partyFinder.Id),
});
}
return payload?.Encode();
},
bson => {
if (bson.IsNull) {
return null;
}
if (bson.IsDocument) {
return bson["Type"].AsString switch {
"Achievement" => new AchievementPayload((uint) bson["Id"].AsInt64),
"PartyFinder" => new PartyFinderPayload((uint) bson["Id"].AsInt64),
_ => null,
};
}
return Payload.Decode(new BinaryReader(new MemoryStream(bson.AsBinary)));
});
BsonMapper.Global.RegisterType<SeString?>(
seString => seString == null
? null
: new BsonArray(seString.Payloads.Select(payload => new BsonValue(payload.Encode()))),
bson => {
if (bson.IsNull) {
return null;
}
var array = bson.IsArray ? bson.AsArray : bson["Payloads"].AsArray;
var payloads = array
.Select(payload => Payload.Decode(new BinaryReader(new MemoryStream(payload.AsBinary))))
.ToList();
return new SeString(payloads);
}
);
BsonMapper.Global.RegisterType(
type => (int) type,
bson => (ChatType) bson.AsInt32
);
BsonMapper.Global.RegisterType(
source => (int) source,
bson => (ChatSource) bson.AsInt32
);
BsonMapper.Global.RegisterType(
dateTime => dateTime.Subtract(DateTime.UnixEpoch).TotalMilliseconds,
bson => DateTime.UnixEpoch.AddMilliseconds(bson.AsInt64)
);
this.Database = this.Connect();
this.Messages.EnsureIndex(msg => msg.Date);
this.Messages.EnsureIndex(msg => msg.SortCode);
this.Messages.EnsureIndex(msg => msg.ExtraChatChannel);
this.MigrateWrapper();
this.Plugin.ChatGui.ChatMessageUnhandled += this.ChatMessage;
this.Plugin.Framework.Update += this.GetMessageInfo;
this.Plugin.Framework.Update += this.UpdateReceiver;
this.Plugin.ClientState.Logout += this.Logout;
}
public void Dispose() {
this.Plugin.ClientState.Logout -= this.Logout;
this.Plugin.Framework.Update -= this.UpdateReceiver;
this.Plugin.Framework.Update -= this.GetMessageInfo;
this.Plugin.ChatGui.ChatMessageUnhandled -= this.ChatMessage;
this.Database.Dispose();
}
private ILiteDatabase Connect() {
var dir = this.Plugin.Interface.ConfigDirectory;
dir.Create();
var dbPath = Path.Join(dir.FullName, "chat.db");
var connection = this.Plugin.Config.SharedMode ? "shared" : "direct";
var connString = $"Filename='{dbPath}';Connection={connection}";
return new LiteDatabase(connString, BsonMapper.Global) {
CheckpointSize = 1_000,
Timeout = TimeSpan.FromSeconds(1),
};
}
internal void Reconnect() {
this.Database.Dispose();
this.Database = this.Connect();
}
private void Logout() {
this.LastContentId = 0;
}
private void UpdateReceiver(IFramework framework) {
var contentId = this.Plugin.ClientState.LocalContentId;
if (contentId != 0) {
this.LastContentId = contentId;
}
}
private void GetMessageInfo(IFramework framework) {
if (this.CheckpointTimer.Elapsed > TimeSpan.FromMinutes(5)) {
this.CheckpointTimer.Restart();
new Thread(() => this.Database.Checkpoint()).Start();
}
if (!this.Pending.TryDequeue(out var entry)) {
return;
}
var contentId = this.Plugin.Functions.Chat.GetContentIdForEntry(entry.Item1);
entry.Item2.ContentId = contentId ?? 0;
if (this.Plugin.Config.DatabaseBattleMessages || !entry.Item2.Code.IsBattle()) {
this.Messages.Update(entry.Item2);
}
}
private long _migrateCurrent;
private long _migrateMax;
private void MigrateDraw() {
ImGui.SetNextWindowSizeConstraints(new Vector2(450, 0), new Vector2(450, float.MaxValue));
if (!ImGui.Begin($"{Plugin.Name}##migration-window", ImGuiWindowFlags.AlwaysAutoResize)) {
ImGui.End();
return;
}
ImGui.PushTextWrapPos();
ImGui.TextUnformatted(string.Format(Language.Migration_Line1, Plugin.PluginName));
ImGui.TextUnformatted(string.Format(Language.Migration_Line2, Plugin.PluginName));
ImGui.TextUnformatted(Language.Migration_Line3);
ImGui.TextUnformatted(Language.Migration_Line4);
ImGui.PopTextWrapPos();
ImGui.Spacing();
ImGui.ProgressBar((float) this._migrateCurrent / this._migrateMax, new Vector2(-1, 0), $"{this._migrateCurrent} / {this._migrateMax}");
ImGui.End();
}
internal void MigrateWrapper() {
if (this.Plugin.Config.DatabaseMigration < Configuration.LatestDbVersion) {
this.Plugin.Interface.UiBuilder.Draw += this.MigrateDraw;
}
try {
this.Migrate();
} finally {
this.Plugin.Interface.UiBuilder.Draw -= this.MigrateDraw;
}
}
internal void Migrate() {
// re-save all messages, which will add the ExtraChat channel
if (this.Plugin.Config.DatabaseMigration == 0) {
var total = (float) this.Messages.LongCount() / 10_000.0;
var rounds = (long) Math.Ceiling(total);
this._migrateMax = rounds;
var lastId = ObjectId.Empty;
for (var i = 0; i < rounds; i++) {
this._migrateCurrent = i + 1;
Plugin.Log.Info($"Update round {i + 1}/{rounds}");
var messages = this.Messages.Query()
.OrderBy(msg => msg.Id)
.Where(msg => msg.Id > lastId)
.Limit(10_000)
.ToArray();
foreach (var message in messages) {
this.Messages.Update(message);
lastId = message.Id;
}
}
this.Database.Checkpoint();
BsonMapper.Global.Entity<Message>()
.Id(msg => msg.Id)
.Ctor(doc => new Message(
doc["_id"].AsObjectId,
(ulong) doc["Receiver"].AsInt64,
(ulong) doc["ContentId"].AsInt64,
DateTime.UnixEpoch.AddMilliseconds(doc["Date"].AsInt64),
doc["Code"].AsDocument,
doc["Sender"].AsArray,
doc["Content"].AsArray,
doc["SenderSource"],
doc["ContentSource"],
doc["SortCode"].AsDocument,
doc["ExtraChatChannel"]
));
this.Plugin.Config.DatabaseMigration = 1;
this.Plugin.SaveConfig();
}
}
internal void AddMessage(Message message, Tab? currentTab) {
if (this.Plugin.Config.DatabaseBattleMessages || !message.Code.IsBattle()) {
this.Messages.Insert(message);
}
var currentMatches = currentTab?.Matches(message) ?? false;
foreach (var tab in this.Plugin.Config.Tabs) {
var unread = !(tab.UnreadMode == UnreadMode.Unseen && currentTab != tab && currentMatches);
if (tab.Matches(message)) {
tab.AddMessage(message, unread);
}
}
}
internal void FilterAllTabs(bool unread = true) {
foreach (var tab in this.Plugin.Config.Tabs) {
this.FilterTab(tab, unread);
}
}
internal void FilterTab(Tab tab, bool unread) {
var sortCodes = new List<SortCode>();
foreach (var (type, sources) in tab.ChatCodes) {
sortCodes.Add(new SortCode(type, 0));
sortCodes.Add(new SortCode(type, (ChatSource) 1));
if (type.HasSource()) {
foreach (var source in Enum.GetValues<ChatSource>()) {
if (sources.HasFlag(source)) {
sortCodes.Add(new SortCode(type, source));
}
}
}
}
var query = this.Messages
.Query()
.OrderByDescending(msg => msg.Date)
.Where(msg => sortCodes.Contains(msg.SortCode) || msg.ExtraChatChannel != Guid.Empty)
.Where(msg => msg.Receiver == this.CurrentContentId);
if (!this.Plugin.Config.FilterIncludePreviousSessions) {
query = query.Where(msg => msg.Date >= this.Plugin.GameStarted);
}
var messages = query
.Limit(MessagesLimit)
.ToEnumerable()
.Reverse();
foreach (var message in messages) {
// redundant matches check for extrachat
if (tab.Matches(message)) {
tab.AddMessage(message, unread);
}
}
}
private void ChatMessage(XivChatType type, uint senderId, SeString sender, SeString message) {
var chatCode = new ChatCode((ushort) type);
NameFormatting? formatting = null;
if (sender.Payloads.Count > 0) {
formatting = this.FormatFor(chatCode.Type);
}
var senderChunks = new List<Chunk>();
if (formatting is { IsPresent: true }) {
senderChunks.Add(new TextChunk(ChunkSource.None, null, formatting.Before) {
FallbackColour = chatCode.Type,
});
senderChunks.AddRange(ChunkUtil.ToChunks(sender, ChunkSource.Sender, chatCode.Type));
senderChunks.Add(new TextChunk(ChunkSource.None, null, formatting.After) {
FallbackColour = chatCode.Type,
});
}
var messageChunks = ChunkUtil.ToChunks(message, ChunkSource.Content, chatCode.Type).ToList();
var msg = new Message(this.CurrentContentId, chatCode, senderChunks, messageChunks, sender, message);
this.AddMessage(msg, this.Plugin.Ui.CurrentTab);
var idx = this.Plugin.Functions.GetCurrentChatLogEntryIndex();
if (idx != null) {
this.Pending.Enqueue((idx.Value - 1, msg));
}
}
internal class NameFormatting {
internal string Before { get; private set; } = string.Empty;
internal string After { get; private set; } = string.Empty;
internal bool IsPresent { get; private set; } = true;
internal static NameFormatting Empty() {
return new() {
IsPresent = false,
};
}
internal static NameFormatting Of(string before, string after) {
return new() {
Before = before,
After = after,
};
}
}
private NameFormatting? FormatFor(ChatType type) {
if (this.Formats.TryGetValue(type, out var cached)) {
return cached;
}
var logKind = this.Plugin.DataManager.GetExcelSheet<LogKind>()!.GetRow((ushort) type);
if (logKind == null) {
return null;
}
var format = (SeString) logKind.Format;
static bool IsStringParam(Payload payload, byte num) {
var data = payload.Encode();
return data.Length >= 5 && data[1] == 0x29 && data[4] == num + 1;
}
var firstStringParam = format.Payloads.FindIndex(payload => IsStringParam(payload, 1));
var secondStringParam = format.Payloads.FindIndex(payload => IsStringParam(payload, 2));
if (firstStringParam == -1 || secondStringParam == -1) {
return NameFormatting.Empty();
}
var before = format.Payloads
.GetRange(0, firstStringParam)
.Where(payload => payload is ITextProvider)
.Cast<ITextProvider>()
.Select(text => text.Text);
var after = format.Payloads
.GetRange(firstStringParam + 1, secondStringParam - firstStringParam)
.Where(payload => payload is ITextProvider)
.Cast<ITextProvider>()
.Select(text => text.Text);
var nameFormatting = NameFormatting.Of(
string.Join("", before),
string.Join("", after)
);
this.Formats[type] = nameFormatting;
return nameFormatting;
}
}