diff --git a/LightlessSync/Interop/InteropModel/Enums/TerritoryTypeIdHousing.cs b/LightlessSync/Interop/InteropModel/Enums/TerritoryTypeIdHousing.cs new file mode 100644 index 0000000..699f6b6 --- /dev/null +++ b/LightlessSync/Interop/InteropModel/Enums/TerritoryTypeIdHousing.cs @@ -0,0 +1,51 @@ +namespace Lifestream.Enums; + +public enum TerritoryTypeIdHousing +{ + None = -1, + + // Mist (Limsa Lominsa) + Mist = 339, + MistSmall = 282, + MistMedium = 283, + MistLarge = 284, + MistFCRoom = 384, + MistFCWorkshop = 423, + MistApartment = 608, + + // Lavender Beds (Gridania) + Lavender = 340, + LavenderSmall = 342, + LavenderMedium = 343, + LavenderLarge = 344, + LavenderFCRoom = 385, + LavenderFCWorkshop = 425, + LavenderApartment = 609, + + // Goblet (Ul'dah) + Goblet = 341, + GobletSmall = 345, + GobletMedium = 346, + GobletLarge = 347, + GobletFCRoom = 386, + GobletFCWorkshop = 424, + GobletApartment = 610, + + // Shirogane (Kugane) + Shirogane = 641, + ShiroganeSmall = 649, + ShiroganeMedium = 650, + ShiroganeLarge = 651, + ShiroganeFCRoom = 652, + ShiroganeFCWorkshop = 653, + ShiroganeApartment = 655, + + // Empyreum (Ishgard) + Empyream = 979, + EmpyreamSmall = 980, + EmpyreamMedium = 981, + EmpyreamLarge = 982, + EmpyreamFCRoom = 983, + EmpyreamFCWorkshop = 984, + EmpyreamApartment = 999, +} \ No newline at end of file diff --git a/LightlessSync/LightlessConfiguration/ConfigurationMigrator.cs b/LightlessSync/LightlessConfiguration/ConfigurationMigrator.cs index ce2ac17..55fa984 100644 --- a/LightlessSync/LightlessConfiguration/ConfigurationMigrator.cs +++ b/LightlessSync/LightlessConfiguration/ConfigurationMigrator.cs @@ -1,11 +1,17 @@ -using LightlessSync.WebAPI; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using LightlessSync.LightlessConfiguration.Configurations; +using LightlessSync.LightlessConfiguration.Models; +using LightlessSync.WebAPI; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace LightlessSync.LightlessConfiguration; public class ConfigurationMigrator(ILogger logger, TransientConfigService transientConfigService, - ServerConfigService serverConfigService) : IHostedService + ServerConfigService serverConfigService, TempCollectionConfigService tempCollectionConfigService, + LightlessConfigService lightlessConfigService) : IHostedService { private readonly ILogger _logger = logger; @@ -51,6 +57,8 @@ public class ConfigurationMigrator(ILogger logger, Transi serverConfigService.Current.Version = 2; serverConfigService.Save(); } + + MigrateTempCollectionConfig(tempCollectionConfigService, lightlessConfigService); } public Task StartAsync(CancellationToken cancellationToken) @@ -63,4 +71,273 @@ public class ConfigurationMigrator(ILogger logger, Transi { return Task.CompletedTask; } + + private void MigrateTempCollectionConfig(TempCollectionConfigService tempCollectionConfigService, LightlessConfigService lightlessConfigService) + { + var now = DateTime.UtcNow; + TempCollectionConfig tempConfig = tempCollectionConfigService.Current; + var tempChanged = false; + var tempNeedsSave = false; + + if (TryReadTempCollectionData(lightlessConfigService.ConfigurationPath, out var root, out var ids, out var entries)) + { + tempChanged |= MergeTempCollectionData(tempConfig, ids, entries, now); + var removed = root.Remove("OrphanableTempCollections"); + removed |= root.Remove("OrphanableTempCollectionEntries"); + if (removed) + { + try + { + string updatedJson = root.ToJsonString(new JsonSerializerOptions + { + WriteIndented = true + }); + File.WriteAllText(lightlessConfigService.ConfigurationPath, updatedJson); + lightlessConfigService.UpdateLastWriteTime(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to rewrite {config} after temp collection migration", lightlessConfigService.ConfigurationPath); + return; + } + } + + if (ids.Count > 0 || entries.Count > 0) + { + _logger.LogInformation("Migrated {ids} temp collection ids and {entries} entries to {configName}", + ids.Count, entries.Count, tempCollectionConfigService.ConfigurationName); + } + } + + if (TryReadTempCollectionData(tempCollectionConfigService.ConfigurationPath, out var tempRoot, out var tempIds, out var tempEntries)) + { + tempChanged |= MergeTempCollectionData(tempConfig, tempIds, tempEntries, now); + if (tempRoot.Remove("OrphanableTempCollections")) + { + tempNeedsSave = true; + } + } + + if (tempChanged || tempNeedsSave) + { + tempCollectionConfigService.Save(); + } + } + + private bool TryReadTempCollectionData(string configPath, out JsonObject root, out HashSet ids, out List entries) + { + root = new JsonObject(); + ids = []; + entries = []; + + if (!File.Exists(configPath)) + { + return false; + } + + try + { + root = JsonNode.Parse(File.ReadAllText(configPath)) as JsonObject ?? new JsonObject(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read temp collection data from {config}", configPath); + return false; + } + + root.TryGetPropertyValue("OrphanableTempCollections", out JsonNode? idsNode); + root.TryGetPropertyValue("OrphanableTempCollectionEntries", out JsonNode? entriesNode); + + if (idsNode == null && entriesNode == null) + { + return false; + } + + ids = ParseGuidSet(idsNode); + entries = ParseEntries(entriesNode); + return true; + } + + private static HashSet ParseGuidSet(JsonNode? node) + { + HashSet ids = []; + if (node is not JsonArray array) + { + return ids; + } + + foreach (JsonNode? item in array) + { + Guid id = ParseGuid(item); + if (id != Guid.Empty) + { + ids.Add(id); + } + } + + return ids; + } + + private static List ParseEntries(JsonNode? node) + { + List entries = []; + if (node is not JsonArray array) + { + return entries; + } + + foreach (JsonNode? item in array) + { + if (item is not JsonObject obj) + { + continue; + } + + Guid id = ParseGuid(obj["Id"]); + if (id == Guid.Empty) + { + continue; + } + + DateTime registeredAtUtc = DateTime.MinValue; + if (TryParseDateTime(obj["RegisteredAtUtc"], out DateTime parsed)) + { + registeredAtUtc = parsed; + } + + entries.Add(new OrphanableTempCollectionEntry + { + Id = id, + RegisteredAtUtc = registeredAtUtc + }); + } + + return entries; + } + + private static Guid ParseGuid(JsonNode? node) + { + if (node is JsonValue value) + { + if (value.TryGetValue(out string? stringValue) && Guid.TryParse(stringValue, out Guid parsed)) + { + return parsed; + } + } + + return Guid.Empty; + } + + private static bool TryParseDateTime(JsonNode? node, out DateTime value) + { + value = DateTime.MinValue; + if (node is not JsonValue val) + { + return false; + } + + if (val.TryGetValue(out DateTime direct)) + { + value = direct; + return true; + } + + if (val.TryGetValue(out string? stringValue) + && DateTime.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out DateTime parsed)) + { + value = parsed; + return true; + } + + return false; + } + + private static bool MergeTempCollectionData(TempCollectionConfig config, HashSet ids, List entries, DateTime now) + { + bool changed = false; + Dictionary entryLookup = new(); + for (var i = config.OrphanableTempCollectionEntries.Count - 1; i >= 0; i--) + { + var entry = config.OrphanableTempCollectionEntries[i]; + if (entry.Id == Guid.Empty) + { + config.OrphanableTempCollectionEntries.RemoveAt(i); + changed = true; + continue; + } + + if (entryLookup.TryGetValue(entry.Id, out var existing)) + { + if (entry.RegisteredAtUtc != DateTime.MinValue + && (existing.RegisteredAtUtc == DateTime.MinValue || entry.RegisteredAtUtc < existing.RegisteredAtUtc)) + { + existing.RegisteredAtUtc = entry.RegisteredAtUtc; + changed = true; + } + + config.OrphanableTempCollectionEntries.RemoveAt(i); + changed = true; + continue; + } + + entryLookup[entry.Id] = entry; + } + + foreach (OrphanableTempCollectionEntry entry in entries) + { + if (entry.Id == Guid.Empty) + { + continue; + } + + if (!entryLookup.TryGetValue(entry.Id, out OrphanableTempCollectionEntry? existing)) + { + var added = new OrphanableTempCollectionEntry + { + Id = entry.Id, + RegisteredAtUtc = entry.RegisteredAtUtc + }; + config.OrphanableTempCollectionEntries.Add(added); + entryLookup[entry.Id] = added; + changed = true; + continue; + } + + if (entry.RegisteredAtUtc != DateTime.MinValue + && (existing.RegisteredAtUtc == DateTime.MinValue || entry.RegisteredAtUtc < existing.RegisteredAtUtc)) + { + existing.RegisteredAtUtc = entry.RegisteredAtUtc; + changed = true; + } + } + + foreach (Guid id in ids) + { + if (id == Guid.Empty) + { + continue; + } + + if (!entryLookup.TryGetValue(id, out OrphanableTempCollectionEntry? existing)) + { + var added = new OrphanableTempCollectionEntry + { + Id = id, + RegisteredAtUtc = now + }; + config.OrphanableTempCollectionEntries.Add(added); + entryLookup[id] = added; + changed = true; + continue; + } + + if (existing.RegisteredAtUtc == DateTime.MinValue) + { + existing.RegisteredAtUtc = now; + changed = true; + } + } + + return changed; + } } diff --git a/LightlessSync/LightlessConfiguration/ConfigurationSaveService.cs b/LightlessSync/LightlessConfiguration/ConfigurationSaveService.cs index 6208dae..b6a097e 100644 --- a/LightlessSync/LightlessConfiguration/ConfigurationSaveService.cs +++ b/LightlessSync/LightlessConfiguration/ConfigurationSaveService.cs @@ -72,37 +72,41 @@ public class ConfigurationSaveService : IHostedService { _logger.LogTrace("Saving {configName}", config.ConfigurationName); var configDir = config.ConfigurationPath.Replace(config.ConfigurationName, string.Empty); + var isTempCollections = string.Equals(config.ConfigurationName, TempCollectionConfigService.ConfigName, StringComparison.OrdinalIgnoreCase); - try + if (!isTempCollections) { - var configBackupFolder = Path.Join(configDir, BackupFolder); - if (!Directory.Exists(configBackupFolder)) - Directory.CreateDirectory(configBackupFolder); - - var configNameSplit = config.ConfigurationName.Split("."); - var existingConfigs = Directory.EnumerateFiles( - configBackupFolder, - configNameSplit[0] + "*") - .Select(c => new FileInfo(c)) - .OrderByDescending(c => c.LastWriteTime).ToList(); - if (existingConfigs.Skip(10).Any()) + try { - foreach (var oldBak in existingConfigs.Skip(10).ToList()) - { - oldBak.Delete(); - } - } + var configBackupFolder = Path.Join(configDir, BackupFolder); + if (!Directory.Exists(configBackupFolder)) + Directory.CreateDirectory(configBackupFolder); - string backupPath = Path.Combine(configBackupFolder, configNameSplit[0] + "." + DateTime.Now.ToString("yyyyMMddHHmmss") + "." + configNameSplit[1]); - _logger.LogTrace("Backing up current config to {backupPath}", backupPath); - File.Copy(config.ConfigurationPath, backupPath, overwrite: true); - FileInfo fi = new(backupPath); - fi.LastWriteTimeUtc = DateTime.UtcNow; - } - catch (Exception ex) - { - // ignore if file cannot be backupped - _logger.LogWarning(ex, "Could not create backup for {config}", config.ConfigurationPath); + var configNameSplit = config.ConfigurationName.Split("."); + var existingConfigs = Directory.EnumerateFiles( + configBackupFolder, + configNameSplit[0] + "*") + .Select(c => new FileInfo(c)) + .OrderByDescending(c => c.LastWriteTime).ToList(); + if (existingConfigs.Skip(10).Any()) + { + foreach (var oldBak in existingConfigs.Skip(10).ToList()) + { + oldBak.Delete(); + } + } + + string backupPath = Path.Combine(configBackupFolder, configNameSplit[0] + "." + DateTime.Now.ToString("yyyyMMddHHmmss") + "." + configNameSplit[1]); + _logger.LogTrace("Backing up current config to {backupPath}", backupPath); + File.Copy(config.ConfigurationPath, backupPath, overwrite: true); + FileInfo fi = new(backupPath); + fi.LastWriteTimeUtc = DateTime.UtcNow; + } + catch (Exception ex) + { + // ignore if file cannot be backupped + _logger.LogWarning(ex, "Could not create backup for {config}", config.ConfigurationPath); + } } var temp = config.ConfigurationPath + ".tmp"; @@ -110,7 +114,7 @@ public class ConfigurationSaveService : IHostedService { await File.WriteAllTextAsync(temp, JsonSerializer.Serialize(config.Current, typeof(T), new JsonSerializerOptions() { - WriteIndented = true + WriteIndented = !isTempCollections })).ConfigureAwait(false); File.Move(temp, config.ConfigurationPath, true); config.UpdateLastWriteTime(); diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index 4a2b2c9..c68f9aa 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -160,8 +160,6 @@ public class LightlessConfig : ILightlessConfiguration public string? SelectedFinderSyncshell { get; set; } = null; public string LastSeenVersion { get; set; } = string.Empty; public bool EnableParticleEffects { get; set; } = true; - public HashSet OrphanableTempCollections { get; set; } = []; - public List OrphanableTempCollectionEntries { get; set; } = []; public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Unsafe; public bool AnimationAllowOneBasedShift { get; set; } = false; public bool AnimationAllowNeighborIndexTolerance { get; set; } = false; diff --git a/LightlessSync/LightlessConfiguration/Configurations/TempCollectionConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/TempCollectionConfig.cs new file mode 100644 index 0000000..5f16e61 --- /dev/null +++ b/LightlessSync/LightlessConfiguration/Configurations/TempCollectionConfig.cs @@ -0,0 +1,10 @@ +using LightlessSync.LightlessConfiguration.Models; + +namespace LightlessSync.LightlessConfiguration.Configurations; + +[Serializable] +public sealed class TempCollectionConfig : ILightlessConfiguration +{ + public int Version { get; set; } = 1; + public List OrphanableTempCollectionEntries { get; set; } = []; +} diff --git a/LightlessSync/LightlessConfiguration/TempCollectionConfigService.cs b/LightlessSync/LightlessConfiguration/TempCollectionConfigService.cs new file mode 100644 index 0000000..a58a883 --- /dev/null +++ b/LightlessSync/LightlessConfiguration/TempCollectionConfigService.cs @@ -0,0 +1,12 @@ +using LightlessSync.LightlessConfiguration.Configurations; + +namespace LightlessSync.LightlessConfiguration; + +public sealed class TempCollectionConfigService : ConfigurationServiceBase +{ + public const string ConfigName = "tempcollections.json"; + + public TempCollectionConfigService(string configDir) : base(configDir) { } + + public override string ConfigurationName => ConfigName; +} diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index f14aeda..5affe17 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -429,6 +429,7 @@ public sealed class Plugin : IDalamudPlugin LightlessSync.UI.Style.MainStyle.Init(cfg, theme); return cfg; }); + services.AddSingleton(sp => new TempCollectionConfigService(configDir)); services.AddSingleton(sp => new ServerConfigService(configDir)); services.AddSingleton(sp => new NotesConfigService(configDir)); services.AddSingleton(sp => new PairTagConfigService(configDir)); @@ -442,6 +443,7 @@ public sealed class Plugin : IDalamudPlugin services.AddSingleton>(sp => sp.GetRequiredService()); services.AddSingleton>(sp => sp.GetRequiredService()); services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); services.AddSingleton>(sp => sp.GetRequiredService()); services.AddSingleton>(sp => sp.GetRequiredService()); services.AddSingleton>(sp => sp.GetRequiredService()); diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index d7a814a..500db53 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -701,7 +701,23 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber str += $" Room #{location.RoomId}"; } } + return str; + } + public string LocationToLifestream(LocationInfo location) + { + if (location.ServerId is 0 || location.TerritoryId is 0 || ContentFinderData.Value.ContainsKey(location.TerritoryId)) return String.Empty; + var str = WorldData.Value[(ushort)location.ServerId]; + if (location.HouseId is 0 && location.MapId is not 0) + { + var mapName = MapData.Value[(ushort)location.MapId].MapName; + var parts = mapName.Split(" - ", StringSplitOptions.RemoveEmptyEntries); + var locationName = parts.Length > 0 ? parts[^1] : mapName; + str += $", tp {locationName}"; + string message = $"LocationToLifestream: {str}"; + _logger.LogInformation(message); + + } return str; } diff --git a/LightlessSync/Services/LocationShareService.cs b/LightlessSync/Services/LocationShareService.cs index 38b2834..4a8249c 100644 --- a/LightlessSync/Services/LocationShareService.cs +++ b/LightlessSync/Services/LocationShareService.cs @@ -1,3 +1,4 @@ +using Lifestream.Enums; using LightlessSync.API.Data; using LightlessSync.API.Dto.CharaData; using LightlessSync.API.Dto.User; @@ -108,6 +109,144 @@ namespace LightlessSync.Services } } + public LocationInfo? GetLocationForLifestreamByUid(string uid) + { + try + { + if (_locations.TryGetValue(uid, out var location)) + { + return location; + } + return null; + } + catch (Exception e) + { + Logger.LogError(e,"GetLocationInfoByUid error : "); + throw; + } + } + + public AddressBookEntryTuple? GetAddressBookEntryByLocation(LocationInfo location) + { + if (location.ServerId is 0 || location.TerritoryId is 0) + { + return null; + } + + var territoryHousing = (TerritoryTypeIdHousing)location.TerritoryId; + + if (territoryHousing == TerritoryTypeIdHousing.None || !Enum.IsDefined(typeof(TerritoryTypeIdHousing), territoryHousing)) + { + return null; + } + + var city = GetResidentialAetheryteKind(territoryHousing); + + if (city == ResidentialAetheryteKind.None) + { + return null; + } + + if (location.HouseId is not 0 and not 100) + { + AddressBookEntryTuple addressEntry = ( + Name: "", + World: (int)location.ServerId, + City: (int)city, + Ward: (int)location.WardId, + PropertyType: 0, + Plot: (int)location.HouseId, + Apartment: 0, + ApartmentSubdivision: location.DivisionId == 2, + AliasEnabled: false, + Alias: "" + ); + return addressEntry; + } + else if (location.HouseId is 100) + { + AddressBookEntryTuple addressEntry = ( + Name: "", + World: (int)location.ServerId, + City: (int)city, + Ward: (int)location.WardId, + PropertyType: 1, + Plot: 0, + Apartment: (int)location.RoomId, + ApartmentSubdivision: location.DivisionId == 2, + AliasEnabled: false, + Alias: "" + ); + return addressEntry; + } + + return null; + } + + private ResidentialAetheryteKind GetResidentialAetheryteKind(TerritoryTypeIdHousing territoryHousing) + { + return territoryHousing switch + { + TerritoryTypeIdHousing.Shirogane or + TerritoryTypeIdHousing.ShiroganeApartment or + TerritoryTypeIdHousing.ShiroganeSmall or + TerritoryTypeIdHousing.ShiroganeMedium or + TerritoryTypeIdHousing.ShiroganeLarge or + TerritoryTypeIdHousing.ShiroganeFCRoom or + TerritoryTypeIdHousing.ShiroganeFCWorkshop + => ResidentialAetheryteKind.Kugane, + + TerritoryTypeIdHousing.Lavender or + TerritoryTypeIdHousing.LavenderSmall or + TerritoryTypeIdHousing.LavenderMedium or + TerritoryTypeIdHousing.LavenderLarge or + TerritoryTypeIdHousing.LavenderApartment or + TerritoryTypeIdHousing.LavenderFCRoom or + TerritoryTypeIdHousing.LavenderFCWorkshop + => ResidentialAetheryteKind.Gridania, + + TerritoryTypeIdHousing.Mist or + TerritoryTypeIdHousing.MistSmall or + TerritoryTypeIdHousing.MistMedium or + TerritoryTypeIdHousing.MistLarge or + TerritoryTypeIdHousing.MistApartment or + TerritoryTypeIdHousing.MistFCRoom or + TerritoryTypeIdHousing.MistFCWorkshop + => ResidentialAetheryteKind.Limsa, + + TerritoryTypeIdHousing.Goblet or + TerritoryTypeIdHousing.GobletSmall or + TerritoryTypeIdHousing.GobletMedium or + TerritoryTypeIdHousing.GobletLarge or + TerritoryTypeIdHousing.GobletApartment or + TerritoryTypeIdHousing.GobletFCRoom or + TerritoryTypeIdHousing.GobletFCWorkshop + => ResidentialAetheryteKind.Uldah, + + TerritoryTypeIdHousing.Empyream or + TerritoryTypeIdHousing.EmpyreamSmall or + TerritoryTypeIdHousing.EmpyreamMedium or + TerritoryTypeIdHousing.EmpyreamLarge or + TerritoryTypeIdHousing.EmpyreamApartment or + TerritoryTypeIdHousing.EmpyreamFCRoom or + TerritoryTypeIdHousing.EmpyreamFCWorkshop + => ResidentialAetheryteKind.Foundation, + + _ => ResidentialAetheryteKind.None + }; + } + + public string? GetMapAddressByLocation(LocationInfo location) + { + string? liString = null; + var territoryHousing = (TerritoryTypeIdHousing)location.TerritoryId; + if (GetResidentialAetheryteKind(territoryHousing) == ResidentialAetheryteKind.None) + { + liString = _dalamudUtilService.LocationToLifestream(location); + } + return liString; + } + public DateTimeOffset GetSharingStatus(string uid) { try diff --git a/LightlessSync/Services/ModelDecimation/MdlDecimator.cs b/LightlessSync/Services/ModelDecimation/MdlDecimator.cs index 55b511c..4fcdbc0 100644 --- a/LightlessSync/Services/ModelDecimation/MdlDecimator.cs +++ b/LightlessSync/Services/ModelDecimation/MdlDecimator.cs @@ -1343,22 +1343,11 @@ internal static class MdlDecimator return false; } - return IsBodyMaterial(mdl.Materials[mesh.MaterialIndex]); + return ModelDecimationFilters.IsBodyMaterial(mdl.Materials[mesh.MaterialIndex]); } private static bool IsBodyMaterial(string materialPath) - { - if (string.IsNullOrWhiteSpace(materialPath)) - { - return false; - } - - var normalized = materialPath.Replace('\\', '/').ToLowerInvariant(); - var nameStart = normalized.LastIndexOf('/'); - var fileName = nameStart >= 0 ? normalized[(nameStart + 1)..] : normalized; - return fileName.Contains("_bibo", StringComparison.Ordinal) - || fileName.EndsWith("_a.mtrl", StringComparison.Ordinal); - } + => ModelDecimationFilters.IsBodyMaterial(materialPath); private sealed class BodyCollisionData { diff --git a/LightlessSync/Services/ModelDecimation/ModelDecimationFilters.cs b/LightlessSync/Services/ModelDecimation/ModelDecimationFilters.cs new file mode 100644 index 0000000..f2d7f8a --- /dev/null +++ b/LightlessSync/Services/ModelDecimation/ModelDecimationFilters.cs @@ -0,0 +1,132 @@ +namespace LightlessSync.Services.ModelDecimation; + +internal static class ModelDecimationFilters +{ + // MODELS ONLY HERE, NOT MATERIALS + internal static readonly string[] HairPaths = + [ + "/hair/", + "hir.mdl", + ]; + + internal static readonly string[] ClothingPaths = + [ + "chara/equipment/", + "/equipment/", + + "met.mdl", + "top.mdl", + "glv.mdl", + "dwn.mdl", + "sho.mdl", + ]; + + internal static readonly string[] AccessoryPaths = + [ + "/accessory/", + "chara/accessory/", + + "ear.mdl", + "nek.mdl", + "wrs.mdl", + "ril.mdl", + "rir.mdl", + ]; + + internal static readonly string[] BodyPaths = + [ + "/body/", + "chara/equipment/e0000/model/", + "chara/equipment/e9903/model/", + "chara/equipment/e9903/model/", + "chara/equipment/e0279/model/", + ]; + + internal static readonly string[] FaceHeadPaths = + [ + "/face/", + "/obj/face/", + "/head/", + "fac.mdl", + ]; + + internal static readonly string[] TailOrEarPaths = + [ + "/tail/", + "/obj/tail/", + "/zear/", + "/obj/zear/", + + "til.mdl", + "zer.mdl", + ]; + + // BODY MATERIALS ONLY, NOT MESHES + internal static readonly string[] BodyMaterials = + [ + "b0001_bibo.mtrl", + "b0101_bibo.mtrl", + + "b0001_a.mtrl", + "b0001_b.mtrl", + + "b0101_a.mtrl", + "b0101_b.mtrl", + ]; + + internal static string NormalizePath(string path) + => path.Replace('\\', '/').ToLowerInvariant(); + + internal static bool IsHairPath(string normalizedPath) + => ContainsAny(normalizedPath, HairPaths); + + internal static bool IsClothingPath(string normalizedPath) + => ContainsAny(normalizedPath, ClothingPaths); + + internal static bool IsAccessoryPath(string normalizedPath) + => ContainsAny(normalizedPath, AccessoryPaths); + + + internal static bool IsBodyPath(string normalizedPath) + => ContainsAny(normalizedPath, BodyPaths); + + internal static bool IsFaceHeadPath(string normalizedPath) + => ContainsAny(normalizedPath, FaceHeadPaths); + + internal static bool IsTailOrEarPath(string normalizedPath) + => ContainsAny(normalizedPath, TailOrEarPaths); + + internal static bool ContainsAny(string normalizedPath, IReadOnlyList markers) + { + for (var i = 0; i < markers.Count; i++) + { + if (normalizedPath.Contains(markers[i], StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + internal static bool IsBodyMaterial(string materialPath) + { + if (string.IsNullOrWhiteSpace(materialPath)) + { + return false; + } + + var normalized = NormalizePath(materialPath); + var nameStart = normalized.LastIndexOf('/'); + var fileName = nameStart >= 0 ? normalized[(nameStart + 1)..] : normalized; + foreach (var marker in BodyMaterials) + { + if (fileName.Contains(marker, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } +} diff --git a/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs b/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs index 00406f6..3caa070 100644 --- a/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs +++ b/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs @@ -348,46 +348,40 @@ public sealed class ModelDecimationService return true; } - var normalized = NormalizeGamePath(gamePath); - if (normalized.Contains("/hair/", StringComparison.Ordinal)) + var normalized = ModelDecimationFilters.NormalizePath(gamePath); + if (ModelDecimationFilters.IsHairPath(normalized)) { return false; } - if (normalized.Contains("/chara/equipment/", StringComparison.Ordinal)) + if (ModelDecimationFilters.IsClothingPath(normalized)) { return _performanceConfigService.Current.ModelDecimationAllowClothing; } - if (normalized.Contains("/chara/accessory/", StringComparison.Ordinal)) + if (ModelDecimationFilters.IsAccessoryPath(normalized)) { return _performanceConfigService.Current.ModelDecimationAllowAccessories; } - if (normalized.Contains("/chara/human/", StringComparison.Ordinal)) + if (ModelDecimationFilters.IsBodyPath(normalized)) { - if (normalized.Contains("/body/", StringComparison.Ordinal)) - { - return _performanceConfigService.Current.ModelDecimationAllowBody; - } + return _performanceConfigService.Current.ModelDecimationAllowBody; + } - if (normalized.Contains("/face/", StringComparison.Ordinal) || normalized.Contains("/head/", StringComparison.Ordinal)) - { - return _performanceConfigService.Current.ModelDecimationAllowFaceHead; - } + if (ModelDecimationFilters.IsFaceHeadPath(normalized)) + { + return _performanceConfigService.Current.ModelDecimationAllowFaceHead; + } - if (normalized.Contains("/tail/", StringComparison.Ordinal)) - { - return _performanceConfigService.Current.ModelDecimationAllowTail; - } + if (ModelDecimationFilters.IsTailOrEarPath(normalized)) + { + return _performanceConfigService.Current.ModelDecimationAllowTail; } return true; } - private static string NormalizeGamePath(string path) - => path.Replace('\\', '/').ToLowerInvariant(); - private bool TryGetDecimationSettings(out ModelDecimationSettings settings) { settings = new ModelDecimationSettings( diff --git a/LightlessSync/Services/PenumbraTempCollectionJanitor.cs b/LightlessSync/Services/PenumbraTempCollectionJanitor.cs index 87d37ac..6f664de 100644 --- a/LightlessSync/Services/PenumbraTempCollectionJanitor.cs +++ b/LightlessSync/Services/PenumbraTempCollectionJanitor.cs @@ -10,15 +10,18 @@ namespace LightlessSync.Services; public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriberBase { private readonly IpcManager _ipc; - private readonly LightlessConfigService _config; + private readonly TempCollectionConfigService _config; + private readonly CancellationTokenSource _cleanupCts = new(); private int _ran; + private const int CleanupBatchSize = 50; + private static readonly TimeSpan CleanupBatchDelay = TimeSpan.FromMilliseconds(50); private static readonly TimeSpan OrphanCleanupDelay = TimeSpan.FromDays(1); public PenumbraTempCollectionJanitor( ILogger logger, LightlessMediator mediator, IpcManager ipc, - LightlessConfigService config) : base(logger, mediator) + TempCollectionConfigService config) : base(logger, mediator) { _ipc = ipc; _config = config; @@ -31,10 +34,6 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber if (id == Guid.Empty) return; var changed = false; var config = _config.Current; - if (config.OrphanableTempCollections.Add(id)) - { - changed = true; - } var now = DateTime.UtcNow; var existing = config.OrphanableTempCollectionEntries.FirstOrDefault(entry => entry.Id == id); @@ -63,8 +62,7 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber { if (id == Guid.Empty) return; var config = _config.Current; - var changed = config.OrphanableTempCollections.Remove(id); - changed |= RemoveEntry(config.OrphanableTempCollectionEntries, id) > 0; + var changed = RemoveEntry(config.OrphanableTempCollectionEntries, id) > 0; if (changed) { _config.Save(); @@ -79,14 +77,31 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber if (!_ipc.Penumbra.APIAvailable) return; + _ = Task.Run(async () => + { + try + { + await CleanupOrphansOnBootAsync(_cleanupCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + Logger.LogError(ex, "Error cleaning orphaned temp collections"); + } + }); + } + + private async Task CleanupOrphansOnBootAsync(CancellationToken token) + { var config = _config.Current; - var ids = config.OrphanableTempCollections; var entries = config.OrphanableTempCollectionEntries; - if (ids.Count == 0 && entries.Count == 0) + if (entries.Count == 0) return; var now = DateTime.UtcNow; - var changed = EnsureEntries(ids, entries, now); + var changed = EnsureEntryTimes(entries, now); var cutoff = now - OrphanCleanupDelay; var expired = entries .Where(entry => entry.Id != Guid.Empty && entry.RegisteredAtUtc != DateTime.MinValue && entry.RegisteredAtUtc <= cutoff) @@ -105,25 +120,47 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber var appId = Guid.NewGuid(); Logger.LogInformation("Cleaning up {count} orphaned Lightless temp collections older than {delay}", expired.Count, OrphanCleanupDelay); + List removedIds = []; foreach (var id in expired) { + if (token.IsCancellationRequested) + { + break; + } + try { - _ipc.Penumbra.RemoveTemporaryCollectionAsync(Logger, appId, id) - .GetAwaiter().GetResult(); + await _ipc.Penumbra.RemoveTemporaryCollectionAsync(Logger, appId, id).ConfigureAwait(false); } catch (Exception ex) { Logger.LogDebug(ex, "Failed removing orphaned temp collection {id}", id); } + + removedIds.Add(id); + if (removedIds.Count % CleanupBatchSize == 0) + { + try + { + await Task.Delay(CleanupBatchDelay, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } } - foreach (var id in expired) + if (removedIds.Count == 0) { - ids.Remove(id); + if (changed) + { + _config.Save(); + } + return; } - foreach (var id in expired) + foreach (var id in removedIds) { RemoveEntry(entries, id); } @@ -131,6 +168,17 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber _config.Save(); } + protected override void Dispose(bool disposing) + { + if (disposing) + { + _cleanupCts.Cancel(); + _cleanupCts.Dispose(); + } + + base.Dispose(disposing); + } + private static int RemoveEntry(List entries, Guid id) { var removed = 0; @@ -148,29 +196,9 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber return removed; } - private static bool EnsureEntries(HashSet ids, List entries, DateTime now) + private static bool EnsureEntryTimes(List entries, DateTime now) { var changed = false; - foreach (var id in ids) - { - if (id == Guid.Empty) - { - continue; - } - - if (entries.Any(entry => entry.Id == id)) - { - continue; - } - - entries.Add(new OrphanableTempCollectionEntry - { - Id = id, - RegisteredAtUtc = now - }); - changed = true; - } - foreach (var entry in entries) { if (entry.Id == Guid.Empty || entry.RegisteredAtUtc != DateTime.MinValue) diff --git a/LightlessSync/UI/Components/DrawUserPair.cs b/LightlessSync/UI/Components/DrawUserPair.cs index 3ee10ad..09a2517 100644 --- a/LightlessSync/UI/Components/DrawUserPair.cs +++ b/LightlessSync/UI/Components/DrawUserPair.cs @@ -4,8 +4,10 @@ using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; +using LightlessSync.API.Dto.CharaData; using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.User; +using LightlessSync.Interop.Ipc; using LightlessSync.LightlessConfiguration; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; @@ -40,6 +42,7 @@ public class DrawUserPair private readonly LocationShareService _locationShareService; private readonly CharaDataManager _charaDataManager; private readonly PairLedger _pairLedger; + private readonly IpcCallerLifestream _lifestreamIpc; private float _menuWidth = -1; private bool _wasHovered = false; private TooltipSnapshot _tooltipSnapshot = TooltipSnapshot.Empty; @@ -60,7 +63,8 @@ public class DrawUserPair LightlessConfigService configService, LocationShareService locationShareService, CharaDataManager charaDataManager, - PairLedger pairLedger) + PairLedger pairLedger, + IpcCallerLifestream lifestreamIpc) { _id = id; _uiEntry = uiEntry; @@ -79,6 +83,7 @@ public class DrawUserPair _locationShareService = locationShareService; _charaDataManager = charaDataManager; _pairLedger = pairLedger; + _lifestreamIpc = lifestreamIpc; } public PairDisplayEntry DisplayEntry => _displayEntry; @@ -656,6 +661,13 @@ public class DrawUserPair using (ImRaii.PushColor(ImGuiCol.Text, shareColor, shareLocation || shareLocationToOther)) _uiSharedService.IconText(shareLocationIcon); + var popupId = $"LocationPopup_{_pair.UserData.UID}"; + + if (ImGui.IsItemClicked(ImGuiMouseButton.Left) && shareLocation && !string.IsNullOrEmpty(location)) + { + ImGui.OpenPopup(popupId); + } + if (ImGui.IsItemHovered()) { ImGui.BeginTooltip(); @@ -669,6 +681,8 @@ public class DrawUserPair _uiSharedService.IconText(FontAwesomeIcon.LocationArrow); ImGui.SameLine(); ImGui.TextUnformatted(location); + ImGui.Separator(); + ImGui.TextUnformatted("Click to teleport to this location"); } else { @@ -700,6 +714,62 @@ public class DrawUserPair } ImGui.EndTooltip(); } + + if (ImGui.BeginPopup(popupId)) + { + + var locationInfo = _locationShareService.GetLocationForLifestreamByUid(_pair.UserData.UID); + if (locationInfo != null) + { + var locationLi = locationInfo.Value; + var housingAddress = _locationShareService.GetAddressBookEntryByLocation(locationLi); + var mapAddress = _locationShareService.GetMapAddressByLocation(locationLi); + ImGui.TextUnformatted("Teleport to user?"); + ImGui.Separator(); + if (!_lifestreamIpc.APIAvailable) + { + ImGui.TextUnformatted("Lifestream IPC is not available. Please ensure Lifestream is enabled"); + } + else if (housingAddress != null || mapAddress != null) + { + ImGui.TextUnformatted($"Go to {location}?"); + ImGui.TextUnformatted($"NOTE: Teleporting to maps with multiple aetherytes or instances may not be accurate currently. (ie. Thavnair, Yanxia)"); + } + else + { + ImGui.TextUnformatted("Lifestream cannot teleport here. If you are in a residential area, please make sure you're inside a plot."); + } + ImGui.Separator(); + if (_lifestreamIpc.APIAvailable && (housingAddress != null || mapAddress != null)) + { + if (locationLi.HouseId is not 0 && housingAddress != null) + { + if (ImGui.Button("Navigate")) + { + _lifestreamIpc.GoToHousingAddress(housingAddress.Value); + + ImGui.CloseCurrentPopup(); + } + } + else if (mapAddress != null && locationLi.HouseId is 0) + { + if (ImGui.Button("Navigate")) + { + _lifestreamIpc.ExecuteLifestreamCommand(mapAddress); + + ImGui.CloseCurrentPopup(); + } + } + + ImGui.SameLine(); + } + if (ImGui.Button("Close")) + { + ImGui.CloseCurrentPopup(); + } + ImGui.EndPopup(); + } + } } if (individualAnimDisabled || individualSoundsDisabled || individualVFXDisabled || individualIsSticky) diff --git a/LightlessSync/UI/DrawEntityFactory.cs b/LightlessSync/UI/DrawEntityFactory.cs index 08f81b6..b9613a5 100644 --- a/LightlessSync/UI/DrawEntityFactory.cs +++ b/LightlessSync/UI/DrawEntityFactory.cs @@ -16,6 +16,7 @@ using LightlessSync.UI.Handlers; using LightlessSync.UI.Models; using LightlessSync.WebAPI; using Microsoft.Extensions.Logging; +using LightlessSync.Interop.Ipc; namespace LightlessSync.UI; @@ -40,6 +41,7 @@ public class DrawEntityFactory private readonly IdDisplayHandler _uidDisplayHandler; private readonly PairLedger _pairLedger; private readonly PairFactory _pairFactory; + private readonly IpcCallerLifestream _lifestreamIpc; public DrawEntityFactory( ILogger logger, @@ -60,7 +62,8 @@ public class DrawEntityFactory RenameSyncshellTagUi renameSyncshellTagUi, SelectSyncshellForTagUi selectSyncshellForTagUi, PairLedger pairLedger, - PairFactory pairFactory) + PairFactory pairFactory, + IpcCallerLifestream lifestreamIpc) { _logger = logger; _apiController = apiController; @@ -81,6 +84,7 @@ public class DrawEntityFactory _selectSyncshellForTagUi = selectSyncshellForTagUi; _pairLedger = pairLedger; _pairFactory = pairFactory; + _lifestreamIpc = lifestreamIpc; } public DrawFolderGroup CreateGroupFolder( @@ -167,7 +171,8 @@ public class DrawEntityFactory _configService, _locationShareService, _charaDataManager, - _pairLedger); + _pairLedger, + _lifestreamIpc); } public IReadOnlyList GetAllEntries()