diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8a661966..1c2bff55 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -279,11 +279,12 @@ jobs: -p:Copyright="© 2025–${{ env.CopyrightYear }} WolfwithSword and contributors" -o build-avalonia - - name: Copy presets folder and license into build output + - name: Copy presets, resources folder and license into build output shell: bash run: | - mkdir -p build-avalonia/presets + mkdir -p build-avalonia/presets build-avalonia/resources cp -R presets/. build-avalonia/presets/ + cp -R resources/. build-avalonia/resources/ cp LICENSE build-avalonia/ rm -f build-avalonia/*.pdb @@ -466,11 +467,12 @@ jobs: -p:PublishTrimmed=false -o build-pr - - name: Copy presets folder and license into build output + - name: Copy presets, resources folder and license into build output shell: bash run: | - mkdir -p build-pr/presets + mkdir -p build-pr/presets build-pr/resources cp -R presets/. build-pr/presets/ + cp -R resources/. build-pr/resources/ cp LICENSE build-pr/ rm -f build-pr/*.pdb diff --git a/.gitignore b/.gitignore index 03709b50..ace32f34 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ -presets/harley/ -presets/honse/ - bin/ obj/ /packages/ @@ -17,9 +14,18 @@ riderModule.iml exports/ imports/ +cache/ + *.smo +*.smw +*.smwc + +presets/** +!presets/ +!presets/**/ +!presets/**/*.smw +!presets/**/*.smwc -# If we set this up !.idea/codeStyles/ !.idea/inspectionProfiles/ !.idea/runConfigurations/ diff --git a/SubathonManager.Core/Enums/WidgetType.cs b/SubathonManager.Core/Enums/WidgetType.cs index 86807663..c57be4b5 100644 --- a/SubathonManager.Core/Enums/WidgetType.cs +++ b/SubathonManager.Core/Enums/WidgetType.cs @@ -7,6 +7,29 @@ public enum WidgetType Video = 2 } +public enum WidgetContextAction +{ + ToggleVisibility, + Clone, + Delete, + ResetScale, + Refresh +} + +public enum WidgetDisplayKind +{ + Widget,// + UnpackedWidget,// + Image, + Video +} + +public enum WidgetCatalogSource +{ + Imported, + Preset +} + public static class WidgetTypeHelper { private static readonly HashSet ImageExtensions = new(StringComparer.OrdinalIgnoreCase) diff --git a/SubathonManager.Core/Events/WidgetEvents.cs b/SubathonManager.Core/Events/WidgetEvents.cs index f0abbdb7..360b0f60 100644 --- a/SubathonManager.Core/Events/WidgetEvents.cs +++ b/SubathonManager.Core/Events/WidgetEvents.cs @@ -1,4 +1,5 @@ -using SubathonManager.Core.Models; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; using System.Diagnostics.CodeAnalysis; namespace SubathonManager.Core.Events; @@ -9,6 +10,12 @@ public static class WidgetEvents public static event Action? WidgetScaleUpdated; public static event Action? WidgetSizeUpdated; public static event Action? SelectEditorWidget; + public static event Action? WidgetActionRequested; + + public static void RaiseWidgetAction(Guid widgetId, WidgetContextAction action) + { + WidgetActionRequested?.Invoke(widgetId, action); + } public static void RaisePositionUpdated(Widget widget) { diff --git a/SubathonManager.Core/Interfaces/IWidgetFileSystem.cs b/SubathonManager.Core/Interfaces/IWidgetFileSystem.cs new file mode 100644 index 00000000..be1e5920 --- /dev/null +++ b/SubathonManager.Core/Interfaces/IWidgetFileSystem.cs @@ -0,0 +1,30 @@ +namespace SubathonManager.Core.Interfaces; + +public interface IWidgetFileSystem +{ + bool Exists(string path); + string? ReadAllText(string path); + byte[]? ReadAllBytes(string path); + bool IsPacked(string path); + string? GetRealFilePath(string path); + IEnumerable EnumerateFiles(string directory); +} + +public sealed class DiskWidgetFileSystem : IWidgetFileSystem +{ + public bool Exists(string path) => File.Exists(path); + public string? ReadAllText(string path) => File.Exists(path) ? File.ReadAllText(path) : null; + public byte[]? ReadAllBytes(string path) => File.Exists(path) ? File.ReadAllBytes(path) : null; + public bool IsPacked(string path) => false; + public string? GetRealFilePath(string path) => File.Exists(path) ? path : null; + + public IEnumerable EnumerateFiles(string directory) => + Directory.Exists(directory) + ? Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories) + : []; +} + +public static class WidgetFiles +{ + public static IWidgetFileSystem Current { get; set; } = new DiskWidgetFileSystem(); +} diff --git a/SubathonManager.Core/Models/StateValue.cs b/SubathonManager.Core/Models/StateValue.cs index 55e85f48..24caa1c6 100644 --- a/SubathonManager.Core/Models/StateValue.cs +++ b/SubathonManager.Core/Models/StateValue.cs @@ -14,4 +14,9 @@ public class StateValue public static class StateKeys { public const string WheelSpinsOwed = "WheelSpinsOwed"; + + public const string WidgetBrowserCollapsed = "WidgetBrowserCollapsed"; + public const string WidgetBrowserSkipDeleteConfirm = "WidgetBrowserSkipDeleteConfirm"; + public const string WidgetBrowserAllVersions = "WidgetBrowserAllVersions"; + public const string EditorPreviewLightBg = "EditorPreviewLightBg"; } diff --git a/SubathonManager.Core/Models/Widget.cs b/SubathonManager.Core/Models/Widget.cs index dfa7f455..0ba46370 100644 --- a/SubathonManager.Core/Models/Widget.cs +++ b/SubathonManager.Core/Models/Widget.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Diagnostics.CodeAnalysis; @@ -155,7 +156,11 @@ WidgetVariableType.StringList or } else if (((WidgetVariableType?)Type).IsFileVariable()) { - if (!Value.StartsWith("./") && !string.IsNullOrWhiteSpace(Value)) + if (ResourcePaths.IsResourceUrl(Value)) + { + sb.Append($"\"{Value}\""); + } + else if (!Value.StartsWith("./") && !string.IsNullOrWhiteSpace(Value)) { sb.Append($"\"externalPath/{Value}\""); } @@ -300,36 +305,51 @@ public void ScanCssVariables() CssVariables = ExtractCssVariablesFromFiles(); } - public List ExtractCssVariablesFromFiles() + public List GetLinkedCssPaths() { - var extractedVars = new List(); + var paths = new List(); - if (!File.Exists(HtmlPath)) - return extractedVars; + var html = WidgetFiles.Current.ReadAllText(HtmlPath); + if (html == null) + return paths; - string htmlContent = File.ReadAllText(HtmlPath); string baseDir = Path.GetDirectoryName(HtmlPath)!; - var cssMatches = CssLinkRegex().Matches(htmlContent); - - foreach (Match match in cssMatches) + foreach (Match match in CssLinkMatches(html)) { string cssFile = match.Groups[1].Value; string cssPath = Path.IsPathRooted(cssFile) ? cssFile : Path.Combine(baseDir, cssFile); - if (!File.Exists(cssPath)) + if (!WidgetFiles.Current.Exists(cssPath)) + continue; + if (paths.Contains(cssPath, StringComparer.OrdinalIgnoreCase)) continue; - string cssContent = File.ReadAllText(cssPath); + paths.Add(cssPath); + } + + return paths; + } + + public static MatchCollection CssLinkMatches(string htmlContent) => CssLinkRegex().Matches(htmlContent); + + public List ExtractCssVariablesFromFiles() + { + var extractedVars = new List(); + + foreach (var cssPath in GetLinkedCssPaths()) + { + string? cssContent = WidgetFiles.Current.ReadAllText(cssPath); + if (cssContent == null) continue; var varMatches = CssVarRegex().Matches(cssContent); string metaPath = cssPath + ".json"; Dictionary>? varTypes = null; - if (File.Exists(metaPath)) + var metaJson = WidgetFiles.Current.ReadAllText(metaPath); + if (metaJson != null) { - var metaJson = File.ReadAllText(metaPath); varTypes = JsonSerializer.Deserialize>>(metaJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = false }); } diff --git a/SubathonManager.Core/Models/WidgetCatalogEntry.cs b/SubathonManager.Core/Models/WidgetCatalogEntry.cs new file mode 100644 index 00000000..73280e61 --- /dev/null +++ b/SubathonManager.Core/Models/WidgetCatalogEntry.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using SubathonManager.Core.Enums; + +namespace SubathonManager.Core.Models; + +[ExcludeFromCodeCoverage] +public class WidgetCatalogEntry +{ + [Key] + public int Id { get; set; } + + public string PackPath { get; set; } = string.Empty; + public long FileSize { get; set; } + public long FileModifiedTicks { get; set; } + + public WidgetCatalogSource Source { get; set; } = WidgetCatalogSource.Imported; + + public string PackId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Author { get; set; } = string.Empty; + public string Group { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public string Entry { get; set; } = string.Empty; + public string DocsUrl { get; set; } = string.Empty; + public string Tags { get; set; } = string.Empty; + public string PreviewImage { get; set; } = string.Empty; + public string PreviewCachePath { get; set; } = string.Empty; + + public DateTime LastSeenUtc { get; set; } + + public float ScaleX { get; set; } = 1; + public float ScaleY { get; set; } = 1; +} diff --git a/SubathonManager.Core/ResourcePaths.cs b/SubathonManager.Core/ResourcePaths.cs new file mode 100644 index 00000000..3a4a5633 --- /dev/null +++ b/SubathonManager.Core/ResourcePaths.cs @@ -0,0 +1,150 @@ +using System.Text.RegularExpressions; + +namespace SubathonManager.Core; + +public static partial class ResourcePaths +{ + public const string UrlPrefix = "/resources/"; + public const string BundleFolder = "resources"; + + public static string Root => Path.GetFullPath("./resources"); + public static readonly string[] DefaultFolders = ["images", "images/logos", "audio"]; // think of more later? + + public static void EnsureCreated() + { + try + { + Directory.CreateDirectory(Root); + foreach (var folder in DefaultFolders) + Directory.CreateDirectory(Path.Combine(Root, folder.Replace('/', Path.DirectorySeparatorChar))); + } + catch { /**/ } + } + + public static List EnumerateRelative() + { + try + { + string root = Root; + if (!Directory.Exists(root)) return []; + + return Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) + .Select(f => Path.GetRelativePath(root, f).Replace('\\', '/')) + .Where(rel => !rel.StartsWith('.') && !rel.Contains("/.")) + .OrderBy(rel => rel, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch { return []; } + } + + public static IEnumerable FindReferences(string? text) + { + if (string.IsNullOrEmpty(text)) yield break; + + foreach (Match m in ReferenceRegex().Matches(text)) + { + string? rel = NormalizeReference(m.Groups[1].Value); + if (rel != null) yield return rel; + } + } + + public static string RewriteReferences(string text, Func prefixFor) + { + if (string.IsNullOrEmpty(text)) return text; + + return ReferenceRegex().Replace(text, m => + { + string? rel = NormalizeReference(m.Groups[1].Value); + if (rel == null) return m.Value; + + string? prefix = prefixFor(rel); + return prefix == null ? m.Value : prefix + m.Groups[1].Value; + }); + } + + private static string? NormalizeReference(string captured) + { + string rel = captured.Split('?')[0].Split('#')[0].Trim('/'); + if (rel.Length == 0) return null; + + try { return Uri.UnescapeDataString(rel); } + catch { return rel; } + } + + public static string? RelativeFromUrl(string? value) + { + if (!IsResourceUrl(value)) return null; + string rel = value!.Replace('\\', '/')[UrlPrefix.Length..].Split('?')[0].Split('#')[0].Trim('/'); + return rel.Length == 0 ? null : rel; + } + + public static bool IsResourceUrl(string? value) + => !string.IsNullOrWhiteSpace(value) + && value.Replace('\\', '/').StartsWith(UrlPrefix, StringComparison.OrdinalIgnoreCase); + + public static string? ToResourceUrl(string? localPath) + { + if (string.IsNullOrWhiteSpace(localPath)) return null; + if (IsResourceUrl(localPath)) return localPath.Replace('\\', '/'); + + try + { + string root = Root; + string full = Path.GetFullPath(localPath); + + if (!full.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + return null; + + string relative = full[(root.Length + 1)..].Replace('\\', '/').Trim('/'); + return relative.Length == 0 ? null : UrlPrefix + relative; + } + catch { return null; } + } + + public static string? ToLocalPath(string? resourceUrl) + { + if (!IsResourceUrl(resourceUrl)) return null; + + try + { + string relative = resourceUrl!.Replace('\\', '/')[UrlPrefix.Length..].Trim('/'); + string root = Root; + string full = Path.GetFullPath(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar))); + + return full.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + ? full + : null; + } + catch { return null; } + } + + [GeneratedRegex(@"(?)]+)", RegexOptions.IgnoreCase)] + private static partial Regex ReferenceRegex(); + + public static string? ResolveRequestPath(string? requestPath) + { + if (string.IsNullOrWhiteSpace(requestPath)) return null; + + string relative = requestPath.Split('?')[0]; + if (relative.StartsWith(UrlPrefix, StringComparison.OrdinalIgnoreCase)) + relative = relative[UrlPrefix.Length..]; + relative = relative.Trim('/'); + + if (relative.Length == 0) return null; + + try + { + relative = Uri.UnescapeDataString(relative); + if (relative.IndexOfAny(Path.GetInvalidPathChars()) >= 0) return null; + + string root = Root; + string full = Path.GetFullPath(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar))); + + if (!full.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + return null; + + return File.Exists(full) ? full : null; + } + catch { return null; } + } +} diff --git a/SubathonManager.Core/SafeFileName.cs b/SubathonManager.Core/SafeFileName.cs new file mode 100644 index 00000000..4b9db491 --- /dev/null +++ b/SubathonManager.Core/SafeFileName.cs @@ -0,0 +1,60 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace SubathonManager.Core; + +public static class SafeFileName +{ + public const string DefaultReplacement = "_"; + public const int MaxLength = 255; + + private static readonly HashSet InvalidChars = BuildInvalidChars(); + + private static HashSet BuildInvalidChars() + { + var chars = new HashSet("\"<>|:*?\\/"); + for (char c = (char)0; c <= (char)31; c++) chars.Add(c); + chars.Add((char)127); + foreach (char c in Path.GetInvalidFileNameChars()) chars.Add(c); + + return chars; + } + + private static readonly HashSet ReservedNames = new(StringComparer.OrdinalIgnoreCase) + { + "CON", "PRN", "AUX", "NUL", + "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + }; + + public static bool IsInvalidChar(char value) => InvalidChars.Contains(value); + + public static bool IsSafe([NotNullWhen(true)] string? value) + => !string.IsNullOrWhiteSpace(value) && string.Equals(Sanitize(value), value, StringComparison.Ordinal); + + public static string Sanitize(string? value, string replacement = DefaultReplacement, + string fallback = "") + { + if (string.IsNullOrWhiteSpace(value)) return fallback; + + var sb = new StringBuilder(value.Length); + foreach (char c in value.Trim()) + { + if (IsInvalidChar(c)) sb.Append(replacement); + else sb.Append(c); + } + + string result = sb.ToString().TrimEnd(' ', '.'); + + if (result.Length > MaxLength) result = result[..MaxLength].TrimEnd(' ', '.'); + if (result.Length == 0) return fallback; + + return IsReserved(result) ? DefaultReplacement + result : result; + } + + private static bool IsReserved(string name) + { + int dot = name.IndexOf('.'); + return ReservedNames.Contains(dot < 0 ? name : name[..dot]); + } +} diff --git a/SubathonManager.Core/Utils.cs b/SubathonManager.Core/Utils.cs index 9e7695db..1759e031 100644 --- a/SubathonManager.Core/Utils.cs +++ b/SubathonManager.Core/Utils.cs @@ -18,6 +18,7 @@ public static class Utils private static readonly ConcurrentDictionary<(SubathonEventSource Source, string Service), IntegrationConnection> ConnectionDetails = new(); public static string? PendingOverlayImportPath { get; set; } + public static string? PendingWidgetPackImportPath { get; set; } public static OAuthCallback? PendingOAuthCallback { get; set; } public static IEnumerable GetAllConnections() diff --git a/SubathonManager.Data/DbContext.cs b/SubathonManager.Data/DbContext.cs index 37ff276e..d5cc5af1 100644 --- a/SubathonManager.Data/DbContext.cs +++ b/SubathonManager.Data/DbContext.cs @@ -42,7 +42,9 @@ public class AppDbContext : DbContext public DbSet JuniperProducts { get; set; } public DbSet StateValues { get; set; } - + + public DbSet WidgetCatalogEntries => Set(); + public AppDbContext(DbContextOptions options) : base(options) { @@ -170,6 +172,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasIndex(s => new { s.SiteId }) .IsUnique(); + modelBuilder.Entity() + .HasIndex(e => e.PackPath) + .IsUnique(); + modelBuilder.Entity() .Property(p => p.ProductId) .HasConversion(v => v.ToString(), v => System.Numerics.BigInteger.Parse(v)) diff --git a/SubathonManager.Data/Migrations/20260729160320_addWidgetCatalog.Designer.cs b/SubathonManager.Data/Migrations/20260729160320_addWidgetCatalog.Designer.cs new file mode 100644 index 00000000..613c5372 --- /dev/null +++ b/SubathonManager.Data/Migrations/20260729160320_addWidgetCatalog.Designer.cs @@ -0,0 +1,1098 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SubathonManager.Data; + +#nullable disable + +namespace SubathonManager.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260729160320_addWidgetCatalog")] + partial class addWidgetCatalog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("SubathonManager.Core.Models.CssVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WidgetId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WidgetId"); + + b.ToTable("CssVariables"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.GoAffProStore", b => + { + b.Property("RowId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("EventName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("INTEGER"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RowId"); + + b.HasIndex("SiteId") + .IsUnique(); + + b.ToTable("GoAffProStores"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JsVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WidgetId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WidgetId"); + + b.ToTable("JsVariables"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperProduct", b => + { + b.Property("ProductId") + .HasColumnType("TEXT"); + + b.Property("LastFetched") + .HasColumnType("TEXT"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StoreId") + .HasColumnType("TEXT"); + + b.Property("Valid") + .HasColumnType("INTEGER"); + + b.HasKey("ProductId"); + + b.HasIndex("StoreId"); + + b.ToTable("JuniperProducts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperStore", b => + { + b.Property("RowId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastFetched") + .HasColumnType("TEXT"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RowId"); + + b.ToTable("JuniperStores"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MakeShipTracking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Orders") + .HasColumnType("INTEGER"); + + b.Property("ProductType") + .HasColumnType("INTEGER"); + + b.Property("Sales") + .HasColumnType("INTEGER"); + + b.Property("ShopifyProductId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MakeShipTrackings"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MultiplierData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("ApplyToPoints") + .HasColumnType("INTEGER"); + + b.Property("ApplyToSeconds") + .HasColumnType("INTEGER"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("FromHypeTrain") + .HasColumnType("INTEGER"); + + b.Property("Multiplier") + .HasColumnType("REAL"); + + b.Property("Started") + .HasColumnType("TEXT"); + + b.Property("SubathonId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SubathonId") + .IsUnique(); + + b.ToTable("MultiplierDatas"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Route", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedTimestamp") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedTimestamp") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Routes"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.StateValue", b => + { + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Name"); + + b.ToTable("StateValues"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("CapDateTime") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsPaused") + .HasColumnType("INTEGER"); + + b.Property("MillisecondsCumulative") + .HasColumnType("INTEGER"); + + b.Property("MillisecondsElapsed") + .HasColumnType("INTEGER"); + + b.Property("MoneySum") + .HasColumnType("REAL"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Points") + .HasColumnType("INTEGER"); + + b.Property("ReversedTime") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonDatas"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonEvent", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Source") + .HasColumnType("INTEGER") + .HasColumnOrder(1); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("Command") + .HasColumnType("INTEGER"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("CurrentPoints") + .HasColumnType("INTEGER"); + + b.Property("CurrentTime") + .HasColumnType("INTEGER"); + + b.Property("EventTimestamp") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("EventTypeMeta") + .HasColumnType("TEXT"); + + b.Property("MultiplierPoints") + .HasColumnType("REAL"); + + b.Property("MultiplierSeconds") + .HasColumnType("REAL"); + + b.Property("PointsValue") + .HasColumnType("INTEGER"); + + b.Property("ProcessedToSubathon") + .HasColumnType("INTEGER"); + + b.Property("SecondaryValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecondsValue") + .HasColumnType("REAL"); + + b.Property("SubathonId") + .HasColumnType("TEXT"); + + b.Property("TertiaryValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("User") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasReversed") + .HasColumnType("INTEGER"); + + b.HasKey("Id", "Source"); + + b.HasIndex("SubathonId"); + + b.ToTable("SubathonEvents"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("GoalSetId") + .HasColumnType("TEXT"); + + b.Property("Points") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GoalSetId"); + + b.ToTable("SubathonGoals"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoalSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonGoalSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPrompt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("CompletionDuration") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("FilterEventType") + .HasColumnType("INTEGER"); + + b.Property("FilterMeta") + .HasColumnType("TEXT"); + + b.Property("FilterSubType") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsInfinite") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("SetId") + .HasColumnType("TEXT"); + + b.Property("SubType") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SetId"); + + b.ToTable("SubathonPrompts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BaselineCount") + .HasColumnType("INTEGER"); + + b.Property("EndedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("PromptId") + .HasColumnType("TEXT"); + + b.Property("SetId") + .HasColumnType("TEXT"); + + b.Property("SnapshotTargetValue") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PromptId"); + + b.HasIndex("SetId"); + + b.ToTable("SubathonPromptRuns"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Cooldown") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RandomOffset") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SubathonPromptSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonValue", b => + { + b.Property("EventType") + .HasColumnType("INTEGER") + .HasColumnOrder(0); + + b.Property("Meta") + .HasColumnType("TEXT") + .HasColumnOrder(1); + + b.Property("Points") + .HasColumnType("REAL"); + + b.Property("Seconds") + .HasColumnType("REAL"); + + b.HasKey("EventType", "Meta"); + + b.ToTable("SubathonValues"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsInfinite") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Weight") + .HasColumnType("INTEGER"); + + b.Property("WheelId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelId"); + + b.ToTable("WheelItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SpinCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("WheelSets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("ActionType") + .HasColumnType("INTEGER"); + + b.Property("Parameter") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WheelItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelItemId") + .IsUnique(); + + b.ToTable("WheelSpinActions"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WheelId") + .HasColumnType("TEXT"); + + b.Property("WheelItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("WheelId"); + + b.HasIndex("WheelItemId"); + + b.ToTable("WheelSpinHistories"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTrigger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CountThreshold") + .HasColumnType("INTEGER"); + + b.Property("Currency") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("MoneyThreshold") + .HasColumnType("REAL"); + + b.Property("SpinsToAdd") + .HasColumnType("INTEGER"); + + b.Property("TierValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WheelSpinTriggers"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTriggerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("SpinsAdded") + .HasColumnType("INTEGER"); + + b.Property("SubathonEventId") + .HasColumnType("TEXT"); + + b.Property("SubathonEventType") + .HasColumnType("INTEGER"); + + b.Property("TriggerId") + .HasColumnType("TEXT"); + + b.Property("TriggerSource") + .HasColumnType("INTEGER"); + + b.Property("TriggerUser") + .HasColumnType("TEXT"); + + b.Property("TriggeredAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TriggerId"); + + b.ToTable("WheelSpinTriggerHistories"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DocsUrl") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("HtmlPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RouteId") + .HasColumnType("TEXT"); + + b.Property("ScaleX") + .HasColumnType("REAL"); + + b.Property("ScaleY") + .HasColumnType("REAL"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Visibility") + .HasColumnType("INTEGER"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("X") + .HasColumnType("REAL"); + + b.Property("Y") + .HasColumnType("REAL"); + + b.Property("Z") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RouteId"); + + b.ToTable("Widgets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WidgetCatalogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DocsUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Entry") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileModifiedTicks") + .HasColumnType("INTEGER"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Group") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreviewCachePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreviewImage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ScaleX") + .HasColumnType("REAL"); + + b.Property("ScaleY") + .HasColumnType("REAL"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PackPath") + .IsUnique(); + + b.ToTable("WidgetCatalogEntries"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.CssVariable", b => + { + b.HasOne("SubathonManager.Core.Models.Widget", "Widget") + .WithMany("CssVariables") + .HasForeignKey("WidgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Widget"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JsVariable", b => + { + b.HasOne("SubathonManager.Core.Models.Widget", "Widget") + .WithMany("JsVariables") + .HasForeignKey("WidgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Widget"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperProduct", b => + { + b.HasOne("SubathonManager.Core.Models.JuniperStore", "Store") + .WithMany("Products") + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.MultiplierData", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonData", "LinkedSubathon") + .WithOne("Multiplier") + .HasForeignKey("SubathonManager.Core.Models.MultiplierData", "SubathonId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedSubathon"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonEvent", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonData", "LinkedSubathon") + .WithMany() + .HasForeignKey("SubathonId"); + + b.Navigation("LinkedSubathon"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoal", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonGoalSet", "LinkedGoalSet") + .WithMany("Goals") + .HasForeignKey("GoalSetId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedGoalSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPrompt", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonPromptSet", "LinkedSet") + .WithMany("Prompts") + .HasForeignKey("SetId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptRun", b => + { + b.HasOne("SubathonManager.Core.Models.SubathonPrompt", "LinkedPrompt") + .WithMany() + .HasForeignKey("PromptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SubathonManager.Core.Models.SubathonPromptSet", "LinkedSet") + .WithMany() + .HasForeignKey("SetId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("LinkedPrompt"); + + b.Navigation("LinkedSet"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSet", "LinkedWheel") + .WithMany("WheelItems") + .HasForeignKey("WheelId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("LinkedWheel"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinAction", b => + { + b.HasOne("SubathonManager.Core.Models.WheelItem", "LinkedItem") + .WithOne("Action") + .HasForeignKey("SubathonManager.Core.Models.WheelSpinAction", "WheelItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LinkedItem"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinHistory", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSet", "LinkedWheel") + .WithMany() + .HasForeignKey("WheelId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("SubathonManager.Core.Models.WheelItem", "LinkedItem") + .WithMany() + .HasForeignKey("WheelItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LinkedItem"); + + b.Navigation("LinkedWheel"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTriggerHistory", b => + { + b.HasOne("SubathonManager.Core.Models.WheelSpinTrigger", "Trigger") + .WithMany("History") + .HasForeignKey("TriggerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.HasOne("SubathonManager.Core.Models.Route", "Route") + .WithMany("Widgets") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.JuniperStore", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Route", b => + { + b.Navigation("Widgets"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonData", b => + { + b.Navigation("Multiplier") + .IsRequired(); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonGoalSet", b => + { + b.Navigation("Goals"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.SubathonPromptSet", b => + { + b.Navigation("Prompts"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelItem", b => + { + b.Navigation("Action"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSet", b => + { + b.Navigation("WheelItems"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.WheelSpinTrigger", b => + { + b.Navigation("History"); + }); + + modelBuilder.Entity("SubathonManager.Core.Models.Widget", b => + { + b.Navigation("CssVariables"); + + b.Navigation("JsVariables"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SubathonManager.Data/Migrations/20260729160320_addWidgetCatalog.cs b/SubathonManager.Data/Migrations/20260729160320_addWidgetCatalog.cs new file mode 100644 index 00000000..66802cf2 --- /dev/null +++ b/SubathonManager.Data/Migrations/20260729160320_addWidgetCatalog.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SubathonManager.Data.Migrations +{ + /// + public partial class addWidgetCatalog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "WidgetCatalogEntries", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PackPath = table.Column(type: "TEXT", nullable: false), + FileSize = table.Column(type: "INTEGER", nullable: false), + FileModifiedTicks = table.Column(type: "INTEGER", nullable: false), + Source = table.Column(type: "INTEGER", nullable: false), + PackId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Author = table.Column(type: "TEXT", nullable: false), + Group = table.Column(type: "TEXT", nullable: false), + Version = table.Column(type: "TEXT", nullable: false), + Entry = table.Column(type: "TEXT", nullable: false), + DocsUrl = table.Column(type: "TEXT", nullable: false), + Tags = table.Column(type: "TEXT", nullable: false), + PreviewImage = table.Column(type: "TEXT", nullable: false), + PreviewCachePath = table.Column(type: "TEXT", nullable: false), + LastSeenUtc = table.Column(type: "TEXT", nullable: false), + ScaleX = table.Column(type: "REAL", nullable: false), + ScaleY = table.Column(type: "REAL", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WidgetCatalogEntries", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_WidgetCatalogEntries_PackPath", + table: "WidgetCatalogEntries", + column: "PackPath", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "WidgetCatalogEntries"); + } + } +} diff --git a/SubathonManager.Data/Migrations/AppDbContextModelSnapshot.cs b/SubathonManager.Data/Migrations/AppDbContextModelSnapshot.cs index 9c1a0d3a..1e76a4ea 100644 --- a/SubathonManager.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/SubathonManager.Data/Migrations/AppDbContextModelSnapshot.cs @@ -813,6 +813,82 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Widgets"); }); + modelBuilder.Entity("SubathonManager.Core.Models.WidgetCatalogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DocsUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Entry") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileModifiedTicks") + .HasColumnType("INTEGER"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Group") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreviewCachePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreviewImage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ScaleX") + .HasColumnType("REAL"); + + b.Property("ScaleY") + .HasColumnType("REAL"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PackPath") + .IsUnique(); + + b.ToTable("WidgetCatalogEntries"); + }); + modelBuilder.Entity("SubathonManager.Core.Models.CssVariable", b => { b.HasOne("SubathonManager.Core.Models.Widget", "Widget") diff --git a/SubathonManager.Data/OverlayPorter.cs b/SubathonManager.Data/OverlayPorter.cs index d641fa9d..54629a6e 100644 --- a/SubathonManager.Data/OverlayPorter.cs +++ b/SubathonManager.Data/OverlayPorter.cs @@ -7,7 +7,11 @@ using Microsoft.EntityFrameworkCore; using SubathonManager.Core; using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; +using SubathonManager.Data.Overlays; +using SubathonManager.Data.Widgets; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Data; @@ -16,7 +20,9 @@ public static class OverlayPorter { private const int SegmentHashLength = 4; private const string ExternalFolder = "_external"; - private const string ManifestFileName = "overlay.json"; + private const string PacksFolder = "packs"; + private const string ManifestFileName = OverlayPackInstaller.ManifestFileName; + public const string FormatVersion = "2"; private static readonly JsonSerializerOptions SerializeOptions = new JsonSerializerOptions { @@ -26,10 +32,12 @@ public static class OverlayPorter #region EXPORT - public static async Task ExportRouteAsync(Route route, string outputPath, string exportName, HashSet? excludedZipEntries = null, string version = "1", string appVersion = "") + public static async Task ExportRouteAsync(Route route, string outputPath, string exportName, + HashSet? excludedZipEntries = null, string version = "1", string appVersion = "", + string author = "", List? tags = null) { var widgets = route.Widgets.ToList(); - var plan = BuildExportPlan(widgets); + var plan = BuildExportPlan(widgets, excludedZipEntries); await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write); using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create, leaveOpen: false); @@ -37,16 +45,27 @@ public static async Task ExportRouteAsync(Route route, string outputPath, string var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var (srcFile, zipEntry) in plan.FileCopies) { - if (!File.Exists(srcFile)) continue; + if (!WidgetFiles.Current.Exists(srcFile)) continue; if (!seen.Add(zipEntry)) continue; if (excludedZipEntries != null && excludedZipEntries.Contains(zipEntry)) continue; - archive.CreateEntryFromFile(srcFile, zipEntry, CompressionLevel.Optimal); + + if (File.Exists(srcFile)) + { + await archive.CreateEntryFromFileAsync(srcFile, zipEntry, CompressionLevel.Optimal); + continue; + } + + var bytes = WidgetFiles.Current.ReadAllBytes(srcFile); + if (bytes == null) continue; + var packedEntry = archive.CreateEntry(zipEntry, CompressionLevel.Optimal); + await using var packedStream = await packedEntry.OpenAsync(); + await packedStream.WriteAsync(bytes); } - var manifest = BuildManifest(route, widgets, plan, exportName, version, appVersion); + var manifest = BuildManifest(route, widgets, plan, exportName, version, appVersion, author, tags); var manifestJson = JsonSerializer.Serialize(manifest, SerializeOptions); var manifestEntry = archive.CreateEntry(ManifestFileName, CompressionLevel.Optimal); - await using var manifestStream = manifestEntry.Open(); + await using var manifestStream = await manifestEntry.OpenAsync(); await manifestStream.WriteAsync(Encoding.UTF8.GetBytes(manifestJson)); OpenExportFolder(outputPath); } @@ -70,9 +89,20 @@ private static void OpenExportFolder(string outputPath) catch {/**/} } - private static ExportPlan BuildExportPlan(List widgets) + public const string ResourcesFolder = "_resources"; + + private static ExportPlan BuildExportPlan(List widgets, HashSet? excludedZipEntries = null) { var plan = new ExportPlan(); + bool IsKept(string zipEntry) => excludedZipEntries?.Contains(zipEntry) != true; + + foreach (var rel in ResourcePaths.EnumerateRelative()) + { + string zipEntry = $"{ResourcesFolder}/{rel}"; + if (!IsKept(zipEntry)) continue; + if (ResourcePaths.ToLocalPath(ResourcePaths.UrlPrefix + rel) is { } source) + plan.FileCopies.Add((source, zipEntry)); + } var widgetRoots = widgets.Select(w => w.GetPath()).ToList(); var zipRoots = GetZipWidgetRoots(widgetRoots); @@ -86,17 +116,26 @@ private static ExportPlan BuildExportPlan(List widgets) plan.WidgetFolderMap[widget.Id] = zipWidgetRoot; - if (widget.Type.IsAsset()) + bool isPacked = WidgetPackPaths.TryResolve(widget.HtmlPath, out var packFile, out var packEntry, + out var packId, out var packVersion); + + if (isPacked) { - if (File.Exists(widget.HtmlPath)) + string packZipPath = $"{PacksFolder}/{packId}/{packVersion}{WidgetPackPaths.PackExtension}"; + plan.FileCopies.Add((packFile, packZipPath)); + plan.WidgetPacks[widget.Id] = new PackReference(packId, packVersion, packEntry, packZipPath); + } + else if (widget.Type.IsAsset()) + { + if (WidgetFiles.Current.Exists(widget.HtmlPath)) { string fileName = Path.GetFileName(widget.HtmlPath); plan.FileCopies.Add((widget.HtmlPath, $"{zipWidgetRoot}/{fileName}")); } } - else if (Directory.Exists(widgetRoot)) + else { - foreach (var file in Directory.EnumerateFiles(widgetRoot, "*", SearchOption.AllDirectories)) + foreach (var file in WidgetFiles.Current.EnumerateFiles(widgetRoot)) { string relative = Path.GetRelativePath(widgetRoot, file).Replace('\\', '/'); plan.FileCopies.Add((file, $"{zipWidgetRoot}/{relative}")); @@ -108,6 +147,16 @@ private static ExportPlan BuildExportPlan(List widgets) if (!((WidgetVariableType?)jsVar.Type).IsFileVariable()) continue; if (string.IsNullOrWhiteSpace(jsVar.Value)) continue; + if (ResourcePaths.RelativeFromUrl(jsVar.Value) is { } resourceRel) + { + if (!IsKept($"{ResourcesFolder}/{resourceRel}")) continue; + string upToRoot = string.Concat( + Enumerable.Repeat("../", zipWidgetRoot.Split('/').Length)); + SetRewrite(plan.VariableRewrites, widget.Id, jsVar.Name, + $"{upToRoot}{ResourcesFolder}/{resourceRel}"); + continue; + } + bool isAbsolute = !jsVar.Value.StartsWith("./") && !jsVar.Value.StartsWith("../") && Path.IsPathRooted(jsVar.Value); if (!isAbsolute) continue; @@ -135,7 +184,8 @@ private static ExportPlan BuildExportPlan(List widgets) return plan; } - private static JsonElement BuildManifest(Route route, List widgets, ExportPlan plan, string exportName, string version = "1", string appVersion = "") + private static JsonElement BuildManifest(Route route, List widgets, ExportPlan plan, string exportName, + string version = "1", string appVersion = "", string author = "", List? tags = null) { if (string.IsNullOrWhiteSpace(appVersion)) appVersion = AppServices.AppVersion; var widgetList = widgets.Select(w => @@ -151,11 +201,16 @@ private static JsonElement BuildManifest(Route route, List widgets, Expo return new { name = v.Name, value, type = v.Type }; }); + var pack = plan.WidgetPacks.TryGetValue(w.Id, out var packRef) + ? new { id = packRef.PackId, version = packRef.Version, entry = packRef.Entry, file = packRef.ZipPath } + : null; + return new { id = w.Id, name = w.Name, htmlPath = htmlZipRelPath, + pack, type = w.Type.ToString(), position = new { x = w.X, y = w.Y, z = w.Z }, size = new { width = w.Width, height = w.Height }, @@ -169,13 +224,18 @@ private static JsonElement BuildManifest(Route route, List widgets, Expo var obj = new { + // formatversion helps determine some behaviour from old stuff version = version, + format_version = FormatVersion, app_version = appVersion, exported_at = DateTime.UtcNow, route = new { id = route.Id, name = exportName, + author = author, + overlay_version = version, + tags = tags ?? new List(), resolution = new { width = route.Width, height = route.Height }, created = route.CreatedTimestamp, updated = route.UpdatedTimestamp @@ -202,8 +262,17 @@ public static async Task ImportRouteAsync( string archiveName = Path.GetFileNameWithoutExtension(smoPath); string extractDir = Path.Combine(extractRoot, SanitizeName(archiveName)); Directory.CreateDirectory(extractDir); - ZipFile.ExtractToDirectory(smoPath, extractDir, overwriteFiles: true); + await ZipFile.ExtractToDirectoryAsync(smoPath, extractDir, overwriteFiles: true); + return await ImportExtractedRouteAsync(extractDir, factory, archiveName); + } + + public static async Task ImportExtractedRouteAsync( + string extractDir, + IDbContextFactory factory, + string fallbackName, + string? routeNameOverride = null) + { string manifestPath = Path.Combine(extractDir, ManifestFileName); if (!File.Exists(manifestPath)) return ImportResult.Fail("overlay.json not found in archive"); @@ -214,57 +283,98 @@ public static async Task ImportRouteAsync( await using var db = await factory.CreateDbContextAsync(); var routeEl = root.GetProperty("route"); - string routeName = routeEl.GetProperty("name").GetString() ?? archiveName; - - var existingWidgetPaths = new HashSet( - db.Widgets.Select(w => w.HtmlPath), - StringComparer.OrdinalIgnoreCase); + string routeName = routeNameOverride + ?? routeEl.GetProperty("name").GetString() + ?? fallbackName; var manifestWidgets = root.GetProperty("widgets").EnumerateArray() - .Select(wEl => + .Select(wEl => ResolveManifestWidget(wEl, extractDir)) + .ToList(); + + var existing = db.Widgets + .Select(w => new { w.Id, w.HtmlPath, w.RouteId }) + .ToList() + .Select(w => new { - string htmlZipRelPath = wEl.GetProperty("htmlPath").GetString() ?? ""; - string htmlAbsPath = Path.GetFullPath( - Path.Combine(extractDir, htmlZipRelPath.Replace('/', Path.DirectorySeparatorChar))); - return (wEl, htmlAbsPath); + w.Id, + w.HtmlPath, + w.RouteId, + PackFolder = WidgetPackPaths.Resolve(w.HtmlPath)?.PackFolderStr }) .ToList(); - bool anyWidgetExists = manifestWidgets.Any(w => existingWidgetPaths.Contains(w.htmlAbsPath)); + Guid? matchedRouteId = null; + foreach (var mw in manifestWidgets) + { + var hit = existing.FirstOrDefault(w => + mw.PackFolder != null + ? string.Equals(w.PackFolder, mw.PackFolder, StringComparison.OrdinalIgnoreCase) + : string.Equals(w.HtmlPath, mw.HtmlAbsPath, StringComparison.OrdinalIgnoreCase)); + + if (hit == null) continue; + matchedRouteId = hit.RouteId; + break; + } + + if (matchedRouteId == null) + { + string baseName = OverlayPackPaths.BaseRouteName(routeName); + if (!string.IsNullOrWhiteSpace(baseName)) + { + matchedRouteId = db.Routes + .Select(r => new { r.Id, r.Name }) + .ToList() + .FirstOrDefault(r => string.Equals( + OverlayPackPaths.BaseRouteName(r.Name), + baseName, StringComparison.OrdinalIgnoreCase)) + ?.Id; + } + } - Route? route; + Route? route = null; bool routeIsNew; - if (anyWidgetExists) + if (matchedRouteId != null) { - string? matchedPath = manifestWidgets - .Select(w => w.htmlAbsPath) - .FirstOrDefault(p => existingWidgetPaths.Contains(p)); - route = await db.Routes .Include(r => r.Widgets).ThenInclude(w => w.JsVariables) .Include(r => r.Widgets).ThenInclude(w => w.CssVariables) - .FirstOrDefaultAsync(r => r.Widgets.Any(w => w.HtmlPath == matchedPath)); - - if (route == null) { route = BuildNewRoute(routeEl, routeName); routeIsNew = true; } - else routeIsNew = false; + .FirstOrDefaultAsync(r => r.Id == matchedRouteId); } - else + + if (route == null) { route = BuildNewRoute(routeEl, routeName); routeIsNew = true; } + else + { + routeIsNew = false; + } var newWidgets = new List(); var newCssVariables = new List(); var newJsVariables = new List(); + var repointedWidgets = new List(); - foreach (var (wEl, htmlAbsPath) in manifestWidgets) + foreach (var mw in manifestWidgets) { - string widgetExtractFolder = Path.GetDirectoryName(htmlAbsPath)!; + var wEl = mw.Element; + string htmlAbsPath = mw.HtmlAbsPath; + + string widgetExtractFolder = mw.PackFolder != null + ? mw.OverlayFolder + : Path.GetDirectoryName(htmlAbsPath)!; + Widget? existingWidget = routeIsNew ? null - : route.Widgets.FirstOrDefault(w => - string.Equals(w.HtmlPath, htmlAbsPath, StringComparison.OrdinalIgnoreCase)); + : route.Widgets.FirstOrDefault(w => MatchesManifestWidget(w, mw)); + + if (existingWidget != null && + !string.Equals(existingWidget.HtmlPath, htmlAbsPath, StringComparison.OrdinalIgnoreCase)) + { + existingWidget.HtmlPath = htmlAbsPath; + repointedWidgets.Add(existingWidget); + } if (existingWidget == null) { @@ -301,7 +411,8 @@ public static async Task ImportRouteAsync( widget.CssVariables.Add(v); } foreach (var v in wEl.GetProperty("jsVariables").EnumerateArray().Select(jsEl => - BuildJsVariable(jsEl, widget.Id, widgetExtractFolder)).OfType()) + BuildJsVariable(jsEl, widget.Id, widgetExtractFolder, mw.PackFolder != null)) + .OfType()) { widget.JsVariables.Add(v); } @@ -325,31 +436,103 @@ public static async Task ImportRouteAsync( { string? name = jsEl.TryGetProperty("name", out var n) ? n.GetString() : null; if (name == null || existingJsNames.Contains(name)) continue; - var v = BuildJsVariable(jsEl, existingWidget.Id, widgetExtractFolder); + var v = BuildJsVariable(jsEl, existingWidget.Id, widgetExtractFolder, mw.PackFolder != null); if (v != null) newJsVariables.Add(v); } } } + var repointedIds = new HashSet(repointedWidgets.Select(w => w.Id)); + foreach (var mw in manifestWidgets.Where(m => m.PackFolder != null)) + { + var strays = existing.Where(w => + string.Equals(w.PackFolder, mw.PackFolder, StringComparison.OrdinalIgnoreCase) && + !string.Equals(w.HtmlPath, mw.HtmlAbsPath, StringComparison.OrdinalIgnoreCase) && + !repointedIds.Contains(w.Id)); + + foreach (var stray in strays) + { + var tracked = await db.Widgets.FirstOrDefaultAsync(w => w.Id == stray.Id); + if (tracked == null || !repointedIds.Add(tracked.Id)) continue; + tracked.HtmlPath = mw.HtmlAbsPath; + repointedWidgets.Add(tracked); + } + } + + bool renameMerged = !routeIsNew && !string.Equals(route!.Name, routeName, StringComparison.Ordinal); + return new ImportResult { Route = routeIsNew ? route : null, NewWidgets = newWidgets, NewCssVariables = newCssVariables, NewJsVariables = newJsVariables, - RouteIsNew = routeIsNew + RepointedWidgets = repointedWidgets, + RouteIsNew = routeIsNew, + MergedRouteId = routeIsNew ? null : route!.Id, + MergedRouteName = renameMerged ? routeName : null }; } - private static Route BuildNewRoute(JsonElement routeEl, string fallbackName) => new Route + private record ManifestWidget(JsonElement Element, string HtmlAbsPath, string OverlayFolder, string? PackFolder); + + private static ManifestWidget ResolveManifestWidget(JsonElement wEl, string extractDir) + { + string htmlZipRelPath = wEl.TryGetProperty("htmlPath", out var hp) ? hp.GetString() ?? "" : ""; + + string overlayPath = Path.GetFullPath( + Path.Combine(extractDir, htmlZipRelPath.Replace('/', Path.DirectorySeparatorChar))); + string overlayFolder = Path.GetDirectoryName(overlayPath) ?? extractDir; + + if (!wEl.TryGetProperty("pack", out var packEl) || packEl.ValueKind != JsonValueKind.Object) + return new ManifestWidget(wEl, overlayPath, overlayFolder, null); + + string file = packEl.TryGetProperty("file", out var f) ? f.GetString() ?? "" : ""; + if (string.IsNullOrWhiteSpace(file)) + return new ManifestWidget(wEl, overlayPath, overlayFolder, null); + + string smwPath = Path.GetFullPath( + Path.Combine(extractDir, file.Replace('/', Path.DirectorySeparatorChar))); + + var packManifest = WidgetPackInstaller.ReadManifest(smwPath); + string entry = packManifest?.Entry ?? string.Empty; + if (string.IsNullOrWhiteSpace(entry)) + entry = packEl.TryGetProperty("entry", out var en) ? en.GetString() ?? "" : ""; + + if (string.IsNullOrWhiteSpace(entry) || !File.Exists(smwPath)) + return new ManifestWidget(wEl, overlayPath, overlayFolder, null); + + string mountRoot = Path.Combine( + Path.GetDirectoryName(smwPath)!, + Path.GetFileNameWithoutExtension(smwPath)); + + string packFolder = Path.GetDirectoryName(smwPath)!; + string htmlPath = WidgetPackPaths.EntryPathIn(mountRoot, entry); + + return new ManifestWidget(wEl, htmlPath, overlayFolder, packFolder); + } + + private static bool MatchesManifestWidget(Widget widget, ManifestWidget mw) + { + if (mw.PackFolder != null) + { + var location = WidgetPackPaths.Resolve(widget.HtmlPath); + return location != null && + string.Equals(location.PackFolderStr, mw.PackFolder, StringComparison.OrdinalIgnoreCase); + } + + return string.Equals(widget.HtmlPath, mw.HtmlAbsPath, StringComparison.OrdinalIgnoreCase); + } + + private static Route BuildNewRoute(JsonElement routeEl, string routeName) => new Route { Id = Guid.NewGuid(), - Name = routeEl.TryGetProperty("name", out var n) ? n.GetString() ?? fallbackName : fallbackName, + Name = routeName, Width = routeEl.GetProperty("resolution").GetProperty("width").GetInt32(), Height = routeEl.GetProperty("resolution").GetProperty("height").GetInt32(), }; - private static JsVariable? BuildJsVariable(JsonElement jsEl, Guid widgetId, string widgetFolder) + private static JsVariable? BuildJsVariable(JsonElement jsEl, Guid widgetId, string widgetFolder, bool isPacked) { var v = JsVariable.FromJson(jsEl, widgetId); if (v == null) return null; @@ -364,9 +547,18 @@ public static async Task ImportRouteAsync( resolvedValue = resolvedValue.Replace('\\', '/'); string normWidgetFolder = widgetFolder.Replace('\\', '/').TrimEnd('/') + "/"; - v.Value = resolvedValue.StartsWith(normWidgetFolder, StringComparison.OrdinalIgnoreCase) - ? "./" + resolvedValue[normWidgetFolder.Length..] - : resolvedValue; + if (!resolvedValue.StartsWith(normWidgetFolder, StringComparison.OrdinalIgnoreCase)) + { + v.Value = resolvedValue; + return v; + } + + string relative = resolvedValue[normWidgetFolder.Length..]; + bool bundledByOverlay = isPacked && + relative.StartsWith(ExternalFolder + "/", + StringComparison.OrdinalIgnoreCase); + + v.Value = bundledByOverlay ? resolvedValue : "./" + relative; return v; } @@ -466,33 +658,39 @@ private static string HashSegment(string segment) return Convert.ToHexString(bytes)[..SegmentHashLength].ToLowerInvariant(); } - private static string SanitizeName(string name) - { - var invalid = Path.GetInvalidFileNameChars(); - return new string(name.Select(c => invalid.Contains(c) ? '_' : c).ToArray()); - } + private static string SanitizeName(string name) => SafeFileName.Sanitize(name); #endregion #region TYPES public class ImportResult { public Route? Route { get; init; } - public List NewWidgets { get; init; } = new(); - public List NewCssVariables { get; init; } = new(); - public List NewJsVariables { get; init; } = new(); + public List NewWidgets { get; init; } = []; + public List NewCssVariables { get; init; } = []; + public List NewJsVariables { get; init; } = []; public bool RouteIsNew { get; init; } public bool Failed { get; init; } public string? FailReason { get; init; } + + public List RepointedWidgets { get; init; } = []; + public Guid? MergedRouteId { get; init; } + public string? MergedRouteName { get; init; } + public bool HasAnythingNew => - RouteIsNew || NewWidgets.Count > 0 || NewCssVariables.Count > 0 || NewJsVariables.Count > 0; + RouteIsNew || NewWidgets.Count > 0 || NewCssVariables.Count > 0 || NewJsVariables.Count > 0 + || RepointedWidgets.Count > 0 + || (MergedRouteName != null && MergedRouteId != null); public static ImportResult Fail(string reason) => new ImportResult { Failed = true, FailReason = reason }; } + private record PackReference(string PackId, string Version, string Entry, string ZipPath); + private class ExportPlan { public Dictionary WidgetFolderMap { get; } = new(); // debug helper - public List<(string Src, string ZipEntry)> FileCopies { get; } = new(); + public Dictionary WidgetPacks { get; } = new(); + public List<(string Src, string ZipEntry)> FileCopies { get; } = []; public Dictionary> VariableRewrites { get; } = new(); } #endregion diff --git a/SubathonManager.Data/Overlays/OverlayPackInstaller.cs b/SubathonManager.Data/Overlays/OverlayPackInstaller.cs new file mode 100644 index 00000000..00b5d5ea --- /dev/null +++ b/SubathonManager.Data/Overlays/OverlayPackInstaller.cs @@ -0,0 +1,132 @@ +using System.IO.Compression; +using System.Text.Json; + +namespace SubathonManager.Data.Overlays; + +public class OverlayManifest +{ + public string FormatVersion { get; init; } = "1"; + public string AppVersion { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string Author { get; init; } = string.Empty; + public string Version { get; init; } = "1.0.0"; + public List Tags { get; init; } = []; +} + +public static class OverlayPackInstaller +{ + public const string ManifestFileName = "overlay.json"; + public record InstalledOverlay(OverlayManifest Manifest, string ArchiveFile, string UnpackDir); + + public static OverlayManifest? ReadManifest(string smoPath) + { + try + { + using var stream = new FileStream(smoPath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + + var entry = archive.Entries.FirstOrDefault(e => + string.Equals(e.FullName.Replace('\\', '/'), ManifestFileName, StringComparison.OrdinalIgnoreCase)); + if (entry == null) return null; + + using var reader = new StreamReader(entry.Open()); + using var doc = JsonDocument.Parse(reader.ReadToEnd()); + return Parse(doc.RootElement, smoPath); + } + catch + { + return null; + } + } + + public static InstalledOverlay? Install(string smoPath) + { + var manifest = ReadManifest(smoPath); + if (manifest == null) return null; + + string archiveTarget = OverlayPackPaths.ArchiveFile(manifest.Author, manifest.Name, manifest.Version); + string unpackDir = OverlayPackPaths.UnpackDir(manifest.Author, manifest.Name); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(archiveTarget)!); + + if (!string.Equals(Path.GetFullPath(smoPath), Path.GetFullPath(archiveTarget), + StringComparison.OrdinalIgnoreCase)) + File.Copy(smoPath, archiveTarget, overwrite: true); + + Directory.CreateDirectory(unpackDir); + ExtractSafely(archiveTarget, unpackDir); + } + catch + { + return null; + } + + return new InstalledOverlay(manifest, archiveTarget, unpackDir); + } + + private static void ExtractSafely(string archivePath, string targetDir) + { + string root = Path.GetFullPath(targetDir) + Path.DirectorySeparatorChar; + + using var stream = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) continue; // directory marker + + string relative = entry.FullName.Replace('\\', '/').TrimStart('/'); + string destination = Path.GetFullPath( + Path.Combine(targetDir, relative.Replace('/', Path.DirectorySeparatorChar))); + + if (!destination.StartsWith(root, StringComparison.OrdinalIgnoreCase)) continue; + + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + entry.ExtractToFile(destination, overwrite: true); + } + } + + private static OverlayManifest Parse(JsonElement root, string smoPath) + { + var route = root.TryGetProperty("route", out var r) ? r : default; + string name = Str(route, "name"); + + if (string.IsNullOrWhiteSpace(name)) name = Path.GetFileNameWithoutExtension(smoPath); + + string version = Str(route, "overlay_version"); + if (string.IsNullOrWhiteSpace(version)) version = Str(root, "version"); + if (string.IsNullOrWhiteSpace(version)) version = "1.0.0"; + + return new OverlayManifest + { + FormatVersion = Str(root, "format_version"), + AppVersion = Str(root, "app_version"), + Name = name, + Author = Str(route, "author"), + Version = version, + Tags = StrList(route, "tags") + }; + } + + private static string Str(JsonElement el, string name) + => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out var v) && + v.ValueKind == JsonValueKind.String + ? v.GetString() ?? string.Empty + : string.Empty; + + private static List StrList(JsonElement el, string name) + { + if (el.ValueKind != JsonValueKind.Object || + !el.TryGetProperty(name, out var v) || + v.ValueKind != JsonValueKind.Array) + return []; + + return v.EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.String) + .Select(item => item.GetString() ?? string.Empty) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToList(); + } +} diff --git a/SubathonManager.Data/Overlays/OverlayPackPaths.cs b/SubathonManager.Data/Overlays/OverlayPackPaths.cs new file mode 100644 index 00000000..6c7a7f2f --- /dev/null +++ b/SubathonManager.Data/Overlays/OverlayPackPaths.cs @@ -0,0 +1,83 @@ +using System.Text.RegularExpressions; +using SubathonManager.Core; +using SubathonManager.Data.Widgets; +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Data.Overlays; + +public static partial class OverlayPackPaths +{ + public const string OverlayExtension = ".smo"; + public const string UnpackFolderName = "unpack"; + public static string ImportsRoot => Path.GetFullPath(Path.Combine("./imports", "overlays")); + + public static string OverlayRoot(string author, string name) + { + string authorFolder = WidgetPackPaths.Slug(author); + if (string.IsNullOrEmpty(authorFolder)) authorFolder = "unknown"; + + string nameFolder = WidgetPackPaths.Slug(name); + if (string.IsNullOrEmpty(nameFolder)) nameFolder = "overlay"; + + return Path.Combine(ImportsRoot, authorFolder, nameFolder); + } + + public static string ArchiveFile(string author, string name, string version) + { + string versionFile = SanitizeSegment(version); + if (string.IsNullOrEmpty(versionFile)) versionFile = "1.0.0"; + return Path.Combine(OverlayRoot(author, name), versionFile + OverlayExtension); + } + + public static string UnpackDir(string author, string name) + => Path.Combine(OverlayRoot(author, name), UnpackFolderName); + + public static List ImportedVersions(string author, string name) + { + string folder = OverlayRoot(author, name); + if (!Directory.Exists(folder)) return []; + + try + { + return Directory.EnumerateFiles(folder, "*" + OverlayExtension) + .Select(Path.GetFileNameWithoutExtension) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v!) + .ToList(); + } + catch + { + return []; + } + } + + public static string RouteName(string name, string version) + { + string trimmed = (name ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(trimmed)) trimmed = "Imported Overlay"; + + string display = WidgetPackPaths.DisplayVersion(version); + return string.IsNullOrWhiteSpace(display) ? trimmed : $"{trimmed} v{display}"; + } + + public static string BaseRouteName(string? routeName) + { + return string.IsNullOrWhiteSpace(routeName) ? string.Empty : + VersionSuffixRegex().Replace(routeName.Trim(), string.Empty).Trim(); + } + + [GeneratedRegex(@"\s+v\d[\w.\-]*$", RegexOptions.IgnoreCase)] + private static partial Regex VersionSuffixRegex(); + + public static string BuildFileName(string author, string name, string version) + { + var parts = new[] { author, name, version } + .Select(p => SanitizeSegment(p).Replace(' ', '-')) + .Where(p => !string.IsNullOrWhiteSpace(p)); + + string joined = string.Join('_', parts); + return (string.IsNullOrWhiteSpace(joined) ? "overlay" : joined) + OverlayExtension; + } + + private static string SanitizeSegment(string? value) => SafeFileName.Sanitize(value); +} diff --git a/SubathonManager.Data/WidgetEntityHelper.cs b/SubathonManager.Data/WidgetEntityHelper.cs index 9d2f8040..72f0fd82 100644 --- a/SubathonManager.Data/WidgetEntityHelper.cs +++ b/SubathonManager.Data/WidgetEntityHelper.cs @@ -1,5 +1,4 @@ -using System.Reflection; -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -7,6 +6,7 @@ using System.Text.RegularExpressions; using SubathonManager.Core.Events; using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; using SubathonManager.Core; using SubathonManager.Core.Objects; @@ -250,26 +250,14 @@ public void SyncJsVariables(Widget widget) public WidgetMeta ExtractWidgetMetadataSync(string htmlpath) { var jsonPath = htmlpath + ".json"; - if (!File.Exists(jsonPath)) - { - var html = File.ReadAllText(htmlpath); - Dictionary data = GetMetaDataHtml(html); - try - { - File.WriteAllText(jsonPath, JsonSerializer.Serialize(ConvertHtmlMetaToJsonMeta(data), JsonOptions)); - _logger?.LogDebug("Wrote widget meta JSON to {Path}", jsonPath); - } - catch (Exception ex) - { - _logger?.LogWarning(ex, "Failed to write widget meta JSON to {Path}", jsonPath); - return new WidgetMeta(); - } - - } + if (!EnsureMetaSidecar(htmlpath, jsonPath, out var packedFallback)) + return packedFallback; + try { - var json = File.ReadAllText(jsonPath); - return JsonSerializer.Deserialize(json, JsonOptions) ?? new WidgetMeta(); + var json = WidgetFiles.Current.ReadAllText(jsonPath); + if (json != null) + return JsonSerializer.Deserialize(json, JsonOptions) ?? new WidgetMeta(); } catch (Exception ex) { @@ -279,36 +267,31 @@ public WidgetMeta ExtractWidgetMetadataSync(string htmlpath) return new WidgetMeta(); } - public async Task ExtractWidgetMetadata(string htmlpath) + public Task ExtractWidgetMetadata(string htmlpath) + => Task.FromResult(ExtractWidgetMetadataSync(htmlpath)); + + private bool EnsureMetaSidecar(string htmlpath, string jsonPath, out WidgetMeta packedFallback) { - var jsonPath = htmlpath + ".json"; - if (!File.Exists(jsonPath)) - { - var html = await File.ReadAllTextAsync(htmlpath); - Dictionary data = GetMetaDataHtml(html); - try - { - await File.WriteAllTextAsync(jsonPath, JsonSerializer.Serialize(ConvertHtmlMetaToJsonMeta(data), JsonOptions)); - _logger?.LogDebug("Wrote widget meta JSON to {Path}", jsonPath); - } - catch (Exception ex) - { - _logger?.LogWarning(ex, "Failed to write widget meta JSON to {Path}", jsonPath); - return new WidgetMeta(); - } - } - + packedFallback = new WidgetMeta(); + + if (WidgetFiles.Current.Exists(jsonPath)) return true; + if (WidgetFiles.Current.IsPacked(htmlpath)) return false; + + var html = WidgetFiles.Current.ReadAllText(htmlpath); + if (html == null) return false; + try { - var json = await File.ReadAllTextAsync(jsonPath); - return JsonSerializer.Deserialize(json, JsonOptions) ?? new WidgetMeta(); + var meta = ConvertHtmlMetaToJsonMeta(GetMetaDataHtml(html)); + File.WriteAllText(jsonPath, JsonSerializer.Serialize(meta, JsonOptions)); + _logger?.LogDebug("Wrote widget meta JSON to {Path}", jsonPath); + return true; } catch (Exception ex) { - _logger?.LogWarning(ex, "Failed to read widget meta JSON at {Path}", jsonPath); + _logger?.LogWarning(ex, "Failed to write widget meta JSON to {Path}", jsonPath); + return false; } - - return new WidgetMeta(); } internal WidgetMeta ConvertHtmlMetaToJsonMeta(Dictionary data) diff --git a/SubathonManager.Data/WidgetPorter.cs b/SubathonManager.Data/WidgetPorter.cs new file mode 100644 index 00000000..ad78360f --- /dev/null +++ b/SubathonManager.Data/WidgetPorter.cs @@ -0,0 +1,673 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; +using SubathonManager.Data.Widgets; +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Data; + +public static partial class WidgetPorter +{ + public const string ManifestFileName = WidgetPackInstaller.ManifestFileName; + public const string ContentFolder = "content"; + public const string ExternalFolder = "_external"; + public const string SharedFolder = "_shared"; + public const string FormatVersion = "1"; + + private static readonly JsonSerializerOptions MetaOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Converters = { new JsonStringEnumConverter() } + }; + + private static readonly JsonSerializerOptions ManifestOptions = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Converters = { new JsonStringEnumConverter() } + }; + + #region TYPES + + public enum SmwEntryKind + { + Manifest, + Entry, + Css, + Meta, + Asset, + External + } + + public class SmwEntry + { + public required string ZipEntry { get; init; } + public string? AbsSource { get; init; } + public SmwEntryKind Kind { get; init; } + public bool DefaultSelected { get; set; } + public bool Locked { get; init; } + public Func? Generator { get; init; } + public bool InUse { get; init; } + public string? UsageHint { get; init; } + } + + public class SmwExportOptions + { + public string Name { get; init; } = string.Empty; + public string Author { get; init; } = string.Empty; + public string Group { get; init; } = string.Empty; + + public string Version { get; init; } = "1.0.0"; + public string AppVersion { get; init; } = string.Empty; + + public List Tags { get; init; } = []; + + public string PreviewImagePath { get; init; } = string.Empty; + } + + public static readonly string[] PreviewExtensions = [".png", ".jpg", ".jpeg", ".webp", ".gif"]; + + public static string PreviewEntryName(string sourcePath) + { + string ext = Path.GetExtension(sourcePath).ToLowerInvariant(); + if (!PreviewExtensions.Contains(ext)) ext = ".png"; + return "preview" + ext; + } + + public static string? ExtractExistingPreview(Widget widget) + { + var location = WidgetPackPaths.Resolve(widget.HtmlPath); + if (location == null) return null; + + var manifest = WidgetPackInstaller.ReadManifest(location.PackFileStr); + if (manifest == null || string.IsNullOrWhiteSpace(manifest.PreviewImage)) return null; + + var bytes = WidgetFiles.Current.ReadAllBytes( + WidgetPackPaths.EntryPathIn(location.MountRootStr, manifest.PreviewImage)); + if (bytes == null) return null; + + try + { + string temp = Path.Combine(Path.GetTempPath(), + $"smw-preview-{Guid.NewGuid():N}{Path.GetExtension(manifest.PreviewImage)}"); + File.WriteAllBytes(temp, bytes); + return temp; + } + catch + { + return null; + } + } + + + public static List ParseTags(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return []; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + return raw.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Where(tag => seen.Add(tag)) + .ToList(); + } + + public class ExportPlan + { + public List Entries { get; } = []; + public Dictionary VariableRewrites { get; } = new(StringComparer.OrdinalIgnoreCase); + public string EntryZipPath { get; set; } = string.Empty; + + public Dictionary OptionalRewrites { get; } + = new(StringComparer.OrdinalIgnoreCase); + + public bool IsSelected(string zipEntry) + => Entries.Any(e => e.ZipEntry.Equals(zipEntry, StringComparison.OrdinalIgnoreCase) + && (e.Locked || e.DefaultSelected)); + + public string ResolveVarValue(JsVariable jsVar) + { + if (VariableRewrites.TryGetValue(jsVar.Name, out var rewritten)) return rewritten; + if (OptionalRewrites.TryGetValue(jsVar.Name, out var optional) && IsSelected(optional.ZipEntry)) + return optional.Value; + return jsVar.Value ?? string.Empty; + } + } + + #endregion + + #region PLAN + + public static ExportPlan BuildPlan(Widget widget) + { + var plan = new ExportPlan(); + if (widget.Type.IsAsset() || !WidgetFiles.Current.Exists(widget.HtmlPath)) return plan; + + string widgetRoot = widget.GetPath(); + string htmlEntry = $"{ContentFolder}/{Path.GetFileName(widget.HtmlPath)}"; + plan.EntryZipPath = htmlEntry; + + var cssPaths = widget.GetLinkedCssPaths(); + var cssZipMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + var htmlLinkRewrites = new Dictionary(StringComparer.Ordinal); + + foreach (var cssPath in cssPaths) + { + string? entry = ToContentEntry(widgetRoot, cssPath); + if (entry == null) + { + entry = $"{ContentFolder}/{SharedFolder}/{Path.GetFileName(cssPath)}"; + string original = GetHtmlLinkFor(widget, cssPath); + if (!string.IsNullOrEmpty(original)) + htmlLinkRewrites[original] = $"./{SharedFolder}/{Path.GetFileName(cssPath)}"; + } + cssZipMap[cssPath] = entry; + } + + plan.Entries.Add(new SmwEntry + { + ZipEntry = ManifestFileName, + Kind = SmwEntryKind.Manifest, + DefaultSelected = true, + Locked = true, + Generator = opts => Encoding.UTF8.GetBytes(BuildManifest(widget, plan, opts)) + }); + + string htmlText = WidgetFiles.Current.ReadAllText(widget.HtmlPath) ?? string.Empty; + var hardcodedResources = new HashSet(ResourcePaths.FindReferences(htmlText), + StringComparer.OrdinalIgnoreCase); + foreach (var cssPath in cssPaths) + hardcodedResources.UnionWith(ResourcePaths.FindReferences(WidgetFiles.Current.ReadAllText(cssPath))); + + Func? htmlGenerator = null; + if (htmlLinkRewrites.Count > 0 || hardcodedResources.Count > 0) + htmlGenerator = _ => Encoding.UTF8.GetBytes( + RewriteResourceUrls(RewriteHtmlLinks(htmlText, htmlLinkRewrites), htmlEntry, plan)); + + plan.Entries.Add(new SmwEntry + { + ZipEntry = htmlEntry, + AbsSource = widget.HtmlPath, + Kind = SmwEntryKind.Entry, + DefaultSelected = true, + Locked = true, + Generator = htmlGenerator + }); + + plan.Entries.Add(new SmwEntry + { + ZipEntry = $"{htmlEntry}.json", + AbsSource = WidgetFiles.Current.Exists(widget.HtmlPath + ".json") ? widget.HtmlPath + ".json" : null, + Kind = SmwEntryKind.Meta, + DefaultSelected = true, + Generator = opts => Encoding.UTF8.GetBytes(BuildWidgetMetaJson(widget, plan, opts)) + }); + + var consumed = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var cssPath in cssPaths) + { + string cssEntry = cssZipMap[cssPath]; + var varsInFile = ClaimCssVariables(widget, cssPath, consumed); + + string cssText = WidgetFiles.Current.ReadAllText(cssPath) ?? string.Empty; + bool cssUsesResources = ResourcePaths.FindReferences(cssText).Any(); + + Func? cssGenerator = null; + if (varsInFile.Count > 0 || cssUsesResources) + { + var capturedVars = varsInFile; + string capturedEntry = cssEntry; + cssGenerator = _ => Encoding.UTF8.GetBytes( + RewriteResourceUrls(OverrideCssValues(cssText, capturedVars), capturedEntry, plan)); + } + + plan.Entries.Add(new SmwEntry + { + ZipEntry = cssEntry, + AbsSource = cssPath, + Kind = SmwEntryKind.Css, + DefaultSelected = true, + Generator = cssGenerator + }); + + string cssMetaPath = cssPath + ".json"; + bool hasMeta = WidgetFiles.Current.Exists(cssMetaPath); + bool worthWriting = varsInFile.Any(v => + v.Type != WidgetCssVariableType.Default || !string.IsNullOrWhiteSpace(v.Description)); + + if (!hasMeta && !worthWriting) continue; + + string capturedMetaPath = cssMetaPath; + var capturedMetaVars = varsInFile; + plan.Entries.Add(new SmwEntry + { + ZipEntry = $"{cssEntry}.json", + AbsSource = hasMeta ? cssMetaPath : null, + Kind = SmwEntryKind.Meta, + DefaultSelected = true, + Generator = _ => Encoding.UTF8.GetBytes(BuildCssMetaJson(capturedMetaPath, capturedMetaVars)) + }); + } + + var claimed = new HashSet(plan.Entries.Select(en => en.ZipEntry), + StringComparer.OrdinalIgnoreCase); + foreach (var file in WidgetFiles.Current.EnumerateFiles(widgetRoot)) + { + string? entry = ToContentEntry(widgetRoot, file); + if (entry == null || !claimed.Add(entry)) continue; + + plan.Entries.Add(new SmwEntry + { + ZipEntry = entry, + AbsSource = file, + Kind = SmwEntryKind.Asset, + DefaultSelected = false + }); + } + + AddFileVariableEntries(widget, widgetRoot, plan, claimed); + AddSharedResourceEntries(widget, plan, claimed, hardcodedResources); + return plan; + } + + private static void AddSharedResourceEntries(Widget widget, ExportPlan plan, HashSet claimed, + HashSet hardcodedResources) + { + var varUsed = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var jsVar in widget.JsVariables) + { + if (!((WidgetVariableType?)jsVar.Type).IsFileVariable()) continue; + if (ResourcePaths.RelativeFromUrl(jsVar.Value) is { } rel) varUsed[rel] = jsVar.Name; + } + + foreach (var rel in ResourcePaths.EnumerateRelative()) + { + string entry = $"{ContentFolder}/{ExternalFolder}/{ResourcePaths.BundleFolder}/{rel}"; + if (!claimed.Add(entry)) continue; + + bool byVar = varUsed.TryGetValue(rel, out var varName); + bool byMarkup = hardcodedResources.Contains(rel); + + plan.Entries.Add(new SmwEntry + { + ZipEntry = entry, + AbsSource = ResourcePaths.ToLocalPath(ResourcePaths.UrlPrefix + rel), + Kind = SmwEntryKind.External, + DefaultSelected = false, + InUse = byVar || byMarkup, + UsageHint = byVar + ? $"Used by variable \"{varName}\"" + : byMarkup ? "Referenced directly by this widget's html/css" : null + }); + + if (byVar) + plan.OptionalRewrites[varName!] = + (entry, $"./{ExternalFolder}/{ResourcePaths.BundleFolder}/{rel}"); + } + } + + private static void AddFileVariableEntries(Widget widget, string widgetRoot, ExportPlan plan, HashSet claimed) + { + foreach (var jsVar in widget.JsVariables) + { + if (!((WidgetVariableType?)jsVar.Type).IsFileVariable()) continue; + if (string.IsNullOrWhiteSpace(jsVar.Value)) continue; + if (ResourcePaths.IsResourceUrl(jsVar.Value)) continue; + + bool isFolderType = jsVar.Type == WidgetVariableType.FolderPath; + bool isRelative = jsVar.Value.StartsWith("./") || jsVar.Value.StartsWith("../"); + + if (isRelative) + { + string rel = jsVar.Value.TrimStart('.').TrimStart('/'); + string prefix = $"{ContentFolder}/{rel}"; + foreach (var entry in plan.Entries.Where(en => + en.Kind == SmwEntryKind.Asset && + (en.ZipEntry.Equals(prefix, StringComparison.OrdinalIgnoreCase) || + en.ZipEntry.StartsWith(prefix.TrimEnd('/') + "/", + StringComparison.OrdinalIgnoreCase)))) + { + entry.DefaultSelected = true; + } + continue; + } + + if (!Path.IsPathRooted(jsVar.Value)) continue; + + if (isFolderType && Directory.Exists(jsVar.Value)) + { + string varFolder = SanitizeName(jsVar.Name); + foreach (var file in Directory.EnumerateFiles(jsVar.Value, "*", + SearchOption.AllDirectories)) + { + string rel = Path.GetRelativePath(jsVar.Value, file).Replace('\\', '/'); + string entry = $"{ContentFolder}/{ExternalFolder}/{varFolder}/{rel}"; + + if (!claimed.Add(entry)) continue; + plan.Entries.Add(new SmwEntry + { + ZipEntry = entry, + AbsSource = file, + Kind = SmwEntryKind.External, + DefaultSelected = true + }); + } + + plan.VariableRewrites[jsVar.Name] = $"./{ExternalFolder}/{varFolder}"; + } + else if (!isFolderType && File.Exists(jsVar.Value)) + { + string fileName = Path.GetFileName(jsVar.Value); + string entry = $"{ContentFolder}/{ExternalFolder}/{fileName}"; + if (claimed.Add(entry)) + { + plan.Entries.Add(new SmwEntry + { + ZipEntry = entry, + AbsSource = jsVar.Value, + Kind = SmwEntryKind.External, + DefaultSelected = true + }); + } + plan.VariableRewrites[jsVar.Name] = $"./{ExternalFolder}/{fileName}"; + } + } + } + + #endregion + + #region EXPORT + + public static async Task ExportWidgetAsync(ExportPlan plan, SmwExportOptions options, string outputPath) + { + var opts = options; + if (string.IsNullOrWhiteSpace(opts.AppVersion)) + { + opts = new SmwExportOptions + { + Name = options.Name, + Author = options.Author, + Group = options.Group, + Version = options.Version, + Tags = options.Tags, + PreviewImagePath = options.PreviewImagePath, + AppVersion = AppServices.AppVersion + }; + } + + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outputPath))!); + + await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write); + await using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create, leaveOpen: false); + + var written = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in plan.Entries) + { + if (entry is { Locked: false, DefaultSelected: false }) continue; + if (!written.Add(entry.ZipEntry)) continue; + + byte[]? bytes = null; + if (entry.Generator != null) + { + bytes = entry.Generator(opts); + } + else if (entry.AbsSource != null) + { + if (File.Exists(entry.AbsSource)) + { + await archive.CreateEntryFromFileAsync(entry.AbsSource, entry.ZipEntry, CompressionLevel.Optimal); + continue; + } + bytes = WidgetFiles.Current.ReadAllBytes(entry.AbsSource); + } + + if (bytes == null) continue; + + var zipEntry = archive.CreateEntry(entry.ZipEntry, CompressionLevel.Optimal); + await using var stream = await zipEntry.OpenAsync(); + await stream.WriteAsync(bytes); + } + + if (!string.IsNullOrWhiteSpace(opts.PreviewImagePath) && File.Exists(opts.PreviewImagePath)) + { + string previewEntry = PreviewEntryName(opts.PreviewImagePath); + if (written.Add(previewEntry)) + await archive.CreateEntryFromFileAsync(opts.PreviewImagePath, previewEntry, CompressionLevel.Optimal); + } + } + + public static string ExportsDirectory => Path.GetFullPath(Path.Combine("./exports", "widgets")); + + public static WidgetMeta ReadExistingMeta(Widget widget) + { + var json = WidgetFiles.Current.ReadAllText(widget.HtmlPath + ".json"); + if (json == null) return new WidgetMeta(); + try + { + return JsonSerializer.Deserialize(json, MetaOptions) ?? new WidgetMeta(); + } + catch + { + return new WidgetMeta(); + } + } + + public static string BuildFileName(string author, string group, string name, string version) + { + var parts = new[] { author, WidgetPackPaths.NormalizeGroup(group), name, version } + .Select(p => SanitizeName(p?.Trim() ?? string.Empty).Replace(' ', '-')) + .Where(p => !string.IsNullOrWhiteSpace(p)); + return $"{string.Join('_', parts)}.smw"; + } + + private static string BuildManifest(Widget widget, ExportPlan plan, SmwExportOptions opts) + { + string name = string.IsNullOrWhiteSpace(opts.Name) ? widget.Name : opts.Name; + string group = WidgetPackPaths.NormalizeGroup(opts.Group); + + string packId = WidgetPackPaths.TryResolve(widget.HtmlPath, + out _, out _, out var existingPackId, out _) + ? existingPackId + : WidgetPackPaths.MakePackId(opts.Author, group, name); + + var obj = new + { + version = FormatVersion, + app_version = opts.AppVersion, + exported_at = DateTime.UtcNow, + widget = new + { + pack_id = packId, + name, + author = opts.Author, + group, + widget_version = opts.Version, + tags = opts.Tags, + preview_image = string.IsNullOrWhiteSpace(opts.PreviewImagePath) + ? string.Empty + : PreviewEntryName(opts.PreviewImagePath), + docsUrl = widget.DocsUrl ?? string.Empty, + type = widget.Type.ToString(), + entry = plan.EntryZipPath, + size = new { width = widget.Width, height = widget.Height }, + scale = new { x = widget.ScaleX, y = widget.ScaleY } + } + }; + return JsonSerializer.Serialize(obj, ManifestOptions); + } + + #endregion + + #region OVERRIDES + + private static List ClaimCssVariables(Widget widget, string cssPath, HashSet consumed) + { + var result = new List(); + string? content = WidgetFiles.Current.ReadAllText(cssPath); + if (content == null) return result; + + foreach (Match match in CssVarRegex().Matches(content)) + { + string name = match.Groups[1].Value.Trim(); + if (!consumed.Add(name)) continue; + var dbVar = widget.CssVariables.FirstOrDefault(v => + string.Equals(v.Name, name, StringComparison.OrdinalIgnoreCase)); + if (dbVar != null) result.Add(dbVar); + } + + return result; + } + + private static string RewriteResourceUrls(string text, string ownZipEntry, ExportPlan plan) + { + if (string.IsNullOrEmpty(text)) return text; + + int depth = ownZipEntry.Split('/').Length - 2; + string upToContent = depth <= 0 ? "./" : string.Concat(Enumerable.Repeat("../", depth)); + + return ResourcePaths.RewriteReferences(text, rel => + plan.IsSelected($"{ContentFolder}/{ExternalFolder}/{ResourcePaths.BundleFolder}/{rel}") + ? $"{upToContent}{ExternalFolder}/{ResourcePaths.BundleFolder}/" + : null); + } + + private static string OverrideCssValues(string css, List variables) + { + foreach (var variable in variables) + { + if (string.IsNullOrWhiteSpace(variable.Value)) continue; + var pattern = new Regex($@"(--{Regex.Escape(variable.Name)}\s*:\s*)([^;]*)(;)"); + css = pattern.Replace(css, m => $"{m.Groups[1].Value}{variable.Value}{m.Groups[3].Value}", 1); + } + return css; + } + + private static string BuildCssMetaJson(string existingMetaPath, List variables) + { + Dictionary> meta = new(StringComparer.Ordinal); + var existingJson = WidgetFiles.Current.ReadAllText(existingMetaPath); + if (existingJson != null) + { + try + { + meta = JsonSerializer.Deserialize>>(existingJson) ?? meta; + } + catch { /**/ } + } + + foreach (var variable in variables) + { + if (!meta.TryGetValue(variable.Name, out var entry)) + { + entry = new Dictionary(StringComparer.Ordinal); + meta[variable.Name] = entry; + } + entry["type"] = variable.Type.ToString(); + entry["description"] = variable.Description ?? string.Empty; + } + + return JsonSerializer.Serialize(meta, ManifestOptions); + } + + private static string BuildWidgetMetaJson(Widget widget, ExportPlan plan, SmwExportOptions opts) + { + WidgetMeta meta = ReadExistingMeta(widget); + + if (!string.IsNullOrWhiteSpace(opts.Author)) meta.Author = opts.Author; + if (!string.IsNullOrWhiteSpace(widget.DocsUrl)) meta.Url = widget.DocsUrl!; + meta.Width = widget.Width; + meta.Height = widget.Height; + + foreach (var jsVar in widget.JsVariables) + { + if (!meta.Vars.TryGetValue(jsVar.Name, out var metaVar)) + { + metaVar = new WidgetMetaVar(); + meta.Vars[jsVar.Name] = metaVar; + } + + metaVar.Name = jsVar.Name; + metaVar.Type = jsVar.Type; + metaVar.Description = jsVar.Description ?? string.Empty; + + metaVar.Value = ToMetaValue(jsVar.Type, plan.ResolveVarValue(jsVar), metaVar); + } + + return JsonSerializer.Serialize(meta, MetaOptions); + } + + private static object ToMetaValue(WidgetVariableType type, string value, WidgetMetaVar metaVar) + { + var items = value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList(); + + switch (type) + { + case WidgetVariableType.StringSelect: + metaVar.Options = items; + return items.Count > 0 ? items[0] : string.Empty; + + case WidgetVariableType.Boolean: + return bool.TryParse(value, out var b) && b; + + case WidgetVariableType.Int: + case WidgetVariableType.Percent: + return int.TryParse(value, out var i) ? i : 0; + + case WidgetVariableType.Float: + return float.TryParse(value, out var f) ? f : 0f; + + default: + if (type.IsListType()) return items; + return value; + } + } + + #endregion + + #region HELPERS + + private static string? ToContentEntry(string widgetRoot, string absPath) + { + string rel = Path.GetRelativePath(widgetRoot, absPath).Replace('\\', '/'); + if (rel.StartsWith("../") || rel == ".." || Path.IsPathRooted(rel)) return null; + return $"{ContentFolder}/{rel}"; + } + + private static string GetHtmlLinkFor(Widget widget, string cssPath) + { + string baseDir = Path.GetDirectoryName(widget.HtmlPath)!; + foreach (Match match in Widget.CssLinkMatches(WidgetFiles.Current.ReadAllText(widget.HtmlPath) ?? string.Empty)) + { + string href = match.Groups[1].Value; + string resolved = Path.IsPathRooted(href) ? href : Path.Combine(baseDir, href); + if (string.Equals(Path.GetFullPath(resolved), Path.GetFullPath(cssPath), StringComparison.OrdinalIgnoreCase)) + return href; + } + return string.Empty; + } + + private static string RewriteHtmlLinks(string html, Dictionary rewrites) + { + foreach (var (original, replacement) in rewrites) + { + html = html.Replace($"\"{original}\"", $"\"{replacement}\"") + .Replace($"'{original}'", $"'{replacement}'"); + } + return html; + } + + private static string SanitizeName(string name) => SafeFileName.Sanitize(name); + + [GeneratedRegex(@"--([a-zA-Z0-9-_]+)\s*:\s*([^;]+);")] + private static partial Regex CssVarRegex(); + + #endregion +} diff --git a/SubathonManager.Data/Widgets/WidgetCatalog.cs b/SubathonManager.Data/Widgets/WidgetCatalog.cs new file mode 100644 index 00000000..82f8471d --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetCatalog.cs @@ -0,0 +1,397 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Data.Widgets; + +public static class WidgetCatalog +{ + public static string PresetsRoot => WidgetPackPaths.PresetsRoot; + public static string ImportsRoot => WidgetPackPaths.ImportsRoot; + public static string PreviewCacheRoot => Path.GetFullPath(Path.Combine("./cache", "widget-previews")); + + public record ScanRoot(string Path, WidgetCatalogSource Source); + + public static IEnumerable Roots() + { + yield return new ScanRoot(PresetsRoot, WidgetCatalogSource.Preset); + yield return new ScanRoot(ImportsRoot, WidgetCatalogSource.Imported); + } + + public static async Task> RefreshAsync( + IDbContextFactory factory, CancellationToken ct = default) + { + await using var db = await factory.CreateDbContextAsync(ct); + + var rows = await db.WidgetCatalogEntries.ToListAsync(ct); + var byPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var row in rows) + byPath.TryAdd(row.PackPath, row); + + var live = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var now = DateTime.UtcNow; + + foreach (var root in Roots()) + { + foreach (var file in EnumeratePackages(root.Path)) + { + ct.ThrowIfCancellationRequested(); + + string stored = ToStoredPath(file); + if (!seen.Add(stored)) continue; + + FileInfo info; + try { info = new FileInfo(file); } + catch { continue; } + + byPath.TryGetValue(stored, out var entry); + + bool unchanged = entry != null && + entry.FileSize == info.Length && + entry.FileModifiedTicks == info.LastWriteTimeUtc.Ticks; + + if (unchanged) + { + if (!string.IsNullOrWhiteSpace(entry!.PreviewImage) && + (string.IsNullOrWhiteSpace(entry.PreviewCachePath) || !File.Exists(entry.PreviewCachePath))) + entry.PreviewCachePath = ExtractPreview(file, entry.PreviewImage) ?? string.Empty; + + entry.Source = root.Source; + entry.LastSeenUtc = now; + live.Add(entry); + continue; + } + + var manifest = WidgetPackInstaller.ReadManifest(file); + if (manifest == null || string.IsNullOrWhiteSpace(manifest.Entry)) + { + if (entry != null) db.WidgetCatalogEntries.Remove(entry); + continue; + } + + if (entry == null) + { + entry = new WidgetCatalogEntry { PackPath = stored }; + db.WidgetCatalogEntries.Add(entry); + } + + Fill(entry, file, info, manifest, root.Source, now); + live.Add(entry); + } + } + + foreach (var row in rows.Where(row => !seen.Contains(row.PackPath))) + { + db.WidgetCatalogEntries.Remove(row); + DeletePreview(row.PreviewCachePath); + } + + try + { + await db.SaveChangesAsync(ct); + } + catch { /**/ } + + BuildIndex(live); + + return live + .OrderBy(e => e.Author, StringComparer.OrdinalIgnoreCase) + .ThenBy(e => e.Group, StringComparer.OrdinalIgnoreCase) + .ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase) + .ThenByDescending(e => e.Version, Comparer.Create(WidgetPackPaths.CompareVersions)) + .ToList(); + } + + private static void Fill(WidgetCatalogEntry entry, string file, FileInfo info, + WidgetPackManifest manifest, WidgetCatalogSource source, DateTime now) + { + entry.FileSize = info.Length; + entry.FileModifiedTicks = info.LastWriteTimeUtc.Ticks; + entry.Source = source; + entry.PackId = manifest.PackId; + entry.Name = manifest.Name; + entry.Author = manifest.Author; + entry.Group = WidgetPackPaths.NormalizeGroup(manifest.Group); + entry.Version = manifest.Version; + entry.Entry = manifest.Entry; + entry.DocsUrl = manifest.DocsUrl; + entry.Tags = string.Join(", ", manifest.Tags); + entry.PreviewImage = manifest.PreviewImage; + entry.PreviewCachePath = ExtractPreview(file, manifest.PreviewImage, force: true) ?? string.Empty; + entry.ScaleX = manifest.ScaleX; + entry.ScaleY = manifest.ScaleY; + entry.LastSeenUtc = now; + } + + public static async Task RefreshEntryAsync(IDbContextFactory factory, + string packPath, CancellationToken ct = default) + { + string file = ToAbsolutePath(packPath); + + await using var db = await factory.CreateDbContextAsync(ct); + var entry = await db.WidgetCatalogEntries.FirstOrDefaultAsync(e => e.PackPath == packPath, ct); + + if (!File.Exists(file)) + { + if (entry != null) + { + db.WidgetCatalogEntries.Remove(entry); + DeletePreview(entry.PreviewCachePath); + try { await db.SaveChangesAsync(ct); } catch { /**/ } + } + + return null; + } + + var manifest = WidgetPackInstaller.ReadManifest(file); + if (manifest == null || string.IsNullOrWhiteSpace(manifest.Entry)) return null; + + FileInfo info; + try { info = new FileInfo(file); } + catch { return null; } + + if (entry == null) + { + entry = new WidgetCatalogEntry { PackPath = ToStoredPath(file) }; + db.WidgetCatalogEntries.Add(entry); + } + + Fill(entry, file, info, manifest, SourceFor(file), DateTime.UtcNow); + try + { + string cacheDir = WidgetPackPaths.CacheDirFor(file); + if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, recursive: true); + } + catch { /**/ } + + WidgetPackMemoryCache.InvalidatePack(file); + WidgetPackPaths.InvalidateVersionCache(); + WidgetPackPaths.InvalidateResolveCache(); + + try { await db.SaveChangesAsync(ct); } catch { /**/ } + + return entry; + } + + private static WidgetCatalogSource SourceFor(string file) + { + try + { + return Path.GetFullPath(file).StartsWith(PresetsRoot + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase) + ? WidgetCatalogSource.Preset + : WidgetCatalogSource.Imported; + } + catch + { + return WidgetCatalogSource.Imported; + } + } + + public static async Task DeleteAsync(IDbContextFactory factory, WidgetCatalogEntry entry, + CancellationToken ct = default) + { + if (entry.Source == WidgetCatalogSource.Preset) return false; + + string file = ToAbsolutePath(entry.PackPath); + + try + { + if (File.Exists(file)) File.Delete(file); + + string cacheDir = WidgetPackPaths.CacheDirFor(file); + if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, recursive: true); + } + catch + { + return false; + } + + DeletePreview(entry.PreviewCachePath); + WidgetPackMemoryCache.InvalidatePack(file); + WidgetPackPaths.InvalidateVersionCache(); + WidgetPackPaths.InvalidateResolveCache(); + + try + { + await using var db = await factory.CreateDbContextAsync(ct); + await db.WidgetCatalogEntries.Where(e => e.PackPath == entry.PackPath).ExecuteDeleteAsync(ct); + } + catch { /**/ } + + try + { + string? folder = Path.GetDirectoryName(file); + if (folder != null && Directory.Exists(folder) && + !Directory.EnumerateFileSystemEntries(folder).Any() && + WidgetPackPaths.IsInGlobalStore(folder)) + Directory.Delete(folder); + } + catch { /**/ } + + return true; + } + + #region pack id index + + private static readonly Lock IndexLock = new(); + private static Dictionary> _byPackId = new(StringComparer.OrdinalIgnoreCase); + private static Dictionary _byPath = new(StringComparer.OrdinalIgnoreCase); + + public static void BuildIndex(IEnumerable entries) + { + var byPackId = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var byPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in entries) + { + byPath[entry.PackPath] = entry; + + if (string.IsNullOrWhiteSpace(entry.PackId)) continue; + + if (!byPackId.TryGetValue(entry.PackId, out var list)) + byPackId[entry.PackId] = list = new List(); + + list.Add(entry); + } + + lock (IndexLock) + { + _byPackId = byPackId; + _byPath = byPath; + } + } + + public static async Task LoadIndexAsync(IDbContextFactory factory, CancellationToken ct = default) + { + try + { + await using var db = await factory.CreateDbContextAsync(ct); + BuildIndex(await db.WidgetCatalogEntries.AsNoTracking().ToListAsync(ct)); + } + catch {/**/} + } + + public static WidgetCatalogEntry? EntryForPackFile(string packFile) + { + string stored = ToStoredPath(packFile); + + lock (IndexLock) + { + if (_byPath.TryGetValue(stored, out var byStored)) + return byStored; + + return _byPath.GetValueOrDefault(Path.GetFullPath(packFile)); + } + } + + public static List EntriesForPackId(string packId) + { + lock (IndexLock) + { + return _byPackId.TryGetValue(packId, out var list) + ? [..list] : []; + } + } + + #endregion + + public static string ToAbsolutePath(string storedPath) + { + try + { + return Path.IsPathRooted(storedPath) + ? storedPath + : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), storedPath)); + } + catch { return storedPath; } + } + + private static string ToStoredPath(string absolute) + { + try + { + string full = Path.GetFullPath(absolute); + string baseDir = Directory.GetCurrentDirectory(); + string relative = Path.GetRelativePath(baseDir, full); + + return relative.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relative) + ? full + : relative; + } + catch { return absolute; } + } + + private static IEnumerable EnumeratePackages(string root) + { + if (!Directory.Exists(root)) yield break; + + IEnumerable files; + try + { + files = Directory.EnumerateFiles(root, "*" + WidgetPackPaths.PackExtension, SearchOption.AllDirectories); + } + catch { yield break; } + + foreach (var file in files) + yield return file; + } + + private static string? ExtractPreview(string packFile, string previewEntry, bool force = false) + { + if (string.IsNullOrWhiteSpace(previewEntry)) return null; + + string ext = Path.GetExtension(previewEntry); + if (string.IsNullOrWhiteSpace(ext)) ext = ".png"; + + string target = Path.Combine(PreviewCacheRoot, PreviewKey(packFile) + ext); + if (!force && File.Exists(target)) return target; + + try + { + Directory.CreateDirectory(PreviewCacheRoot); + + using var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + + string wanted = previewEntry.Replace('\\', '/').TrimStart('/'); + var entry = archive.Entries.FirstOrDefault(e => + string.Equals(e.FullName.Replace('\\', '/'), wanted, StringComparison.OrdinalIgnoreCase)); + if (entry == null) return null; + + entry.ExtractToFile(target, overwrite: true); + return target; + } + catch + { + return null; + } + } + + private static void DeletePreview(string? path) + { + if (string.IsNullOrWhiteSpace(path)) return; + try + { + string full = Path.GetFullPath(path); + if (!full.StartsWith(PreviewCacheRoot, StringComparison.OrdinalIgnoreCase)) return; + if (File.Exists(full)) File.Delete(full); + } + catch { /**/ } + } + + private static string PreviewKey(string packFile) + { + string key; + try { key = Path.GetFullPath(packFile).ToLowerInvariant(); } + catch { key = packFile.ToLowerInvariant(); } + + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(key)); + return $"{WidgetPackPaths.Slug(Path.GetFileNameWithoutExtension(packFile))}-{Convert.ToHexString(hash)[..12].ToLowerInvariant()}"; + } +} diff --git a/SubathonManager.Data/Widgets/WidgetCollectionInstaller.cs b/SubathonManager.Data/Widgets/WidgetCollectionInstaller.cs new file mode 100644 index 00000000..85678a15 --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetCollectionInstaller.cs @@ -0,0 +1,146 @@ +using System.IO.Compression; +using System.Text.Json; + +namespace SubathonManager.Data.Widgets; + +public class WidgetCollectionManifest +{ + public string FormatVersion { get; init; } = "1"; + public string AppVersion { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string Author { get; init; } = string.Empty; + public string Version { get; init; } = "1.0.0"; + public string Description { get; init; } = string.Empty; + public List Tags { get; init; } = new(); +} + +public static class WidgetCollectionInstaller +{ + public const string CollectionExtension = ".smwc"; + public const string ManifestFileName = "collection.json"; + + public record InstalledCollection( + WidgetCollectionManifest? Manifest, + List Packs, + int Failed); + + public static WidgetCollectionManifest? ReadManifest(string smwcPath) + { + try + { + using var stream = new FileStream(smwcPath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + + var entry = archive.Entries.FirstOrDefault(e => + string.Equals(e.FullName.Replace('\\', '/'), ManifestFileName, StringComparison.OrdinalIgnoreCase)); + if (entry == null) return null; + + using var reader = new StreamReader(entry.Open()); + using var doc = JsonDocument.Parse(reader.ReadToEnd()); + return Parse(doc.RootElement, smwcPath); + } + catch + { + return null; + } + } + + public static InstalledCollection? InstallAll(string smwcPath) + { + if (!File.Exists(smwcPath)) return null; + + var manifest = ReadManifest(smwcPath); + var installed = new List(); + int failed = 0; + + string scratch = Path.Combine(Path.GetTempPath(), "smwc-" + Guid.NewGuid().ToString("N")); + + try + { + Directory.CreateDirectory(scratch); + + using var stream = new FileStream(smwcPath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) continue; + if (!entry.Name.EndsWith(WidgetPackPaths.PackExtension, StringComparison.OrdinalIgnoreCase)) + continue; + + string staged = Path.Combine(scratch, $"{installed.Count + failed}-{entry.Name}"); + + try + { + entry.ExtractToFile(staged, overwrite: true); + var pack = WidgetPackInstaller.Install(staged); + if (pack != null) installed.Add(pack); + else failed++; + } + catch + { + failed++; + } + } + } + catch + { + return null; + } + finally + { + try + { + if (Directory.Exists(scratch)) + Directory.Delete(scratch, recursive: true); + } + catch { /**/ } + } + + if (installed.Count == 0 && failed == 0) return null; + + return new InstalledCollection(manifest, installed, failed); + } + + private static WidgetCollectionManifest Parse(JsonElement root, string smwcPath) + { + var collection = root.TryGetProperty("collection", out var c) ? c : default; + + string name = Str(collection, "name"); + if (string.IsNullOrWhiteSpace(name)) name = Path.GetFileNameWithoutExtension(smwcPath); + + string version = Str(collection, "version"); + if (string.IsNullOrWhiteSpace(version)) version = "1.0.0"; + + return new WidgetCollectionManifest + { + FormatVersion = Str(root, "format_version"), + AppVersion = Str(root, "app_version"), + Name = name, + Author = Str(collection, "author"), + Version = version, + Description = Str(collection, "description"), + Tags = StrList(collection, "tags") + }; + } + + private static string Str(JsonElement el, string name) + => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out var v) && + v.ValueKind == JsonValueKind.String + ? v.GetString() ?? string.Empty + : string.Empty; + + private static List StrList(JsonElement el, string name) + { + if (el.ValueKind != JsonValueKind.Object || + !el.TryGetProperty(name, out var v) || + v.ValueKind != JsonValueKind.Array) + return new List(); + + return v.EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.String) + .Select(item => item.GetString() ?? string.Empty) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToList(); + } +} diff --git a/SubathonManager.Data/Widgets/WidgetDisplayHelper.cs b/SubathonManager.Data/Widgets/WidgetDisplayHelper.cs new file mode 100644 index 00000000..ef339d20 --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetDisplayHelper.cs @@ -0,0 +1,25 @@ +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; + +namespace SubathonManager.Data.Widgets; + +public static class WidgetDisplayHelper +{ + public static WidgetDisplayKind GetDisplayKind(this Widget widget) => widget.Type switch + { + WidgetType.Image => WidgetDisplayKind.Image, + WidgetType.Video => WidgetDisplayKind.Video, + _ => WidgetPackPaths.TryResolve(widget.HtmlPath, + out _, out _, out _, out _) + ? WidgetDisplayKind.Widget + : WidgetDisplayKind.UnpackedWidget + }; + + public static string GetLabel(this WidgetDisplayKind kind) => kind switch + { + WidgetDisplayKind.Image => "Image", + WidgetDisplayKind.Video => "Video", + WidgetDisplayKind.UnpackedWidget => "Unpacked Widget", + _ => "Widget" + }; +} diff --git a/SubathonManager.Data/Widgets/WidgetPackFileSystem.cs b/SubathonManager.Data/Widgets/WidgetPackFileSystem.cs new file mode 100644 index 00000000..b3247b1a --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetPackFileSystem.cs @@ -0,0 +1,93 @@ +using System.Collections.Concurrent; +using SubathonManager.Core.Interfaces; + +namespace SubathonManager.Data.Widgets; + +public sealed class WidgetPackFileSystem : IWidgetFileSystem +{ + private readonly DiskWidgetFileSystem _disk = new(); + private readonly ConcurrentDictionary _readers = new(StringComparer.OrdinalIgnoreCase); + + public bool Exists(string path) + { + var reader = ReaderFor(path, out var entry); + return reader?.Contains(entry) ?? _disk.Exists(path); + } + + public string? ReadAllText(string path) + { + var reader = ReaderFor(path, out var entry); + return reader != null ? reader.ReadText(entry) : _disk.ReadAllText(path); + } + + public byte[]? ReadAllBytes(string path) + { + var reader = ReaderFor(path, out var entry); + return reader != null ? reader.Read(entry) : _disk.ReadAllBytes(path); + } + + public bool IsPacked(string path) => ReaderFor(path, out _) != null; + + public string? GetRealFilePath(string path) + { + var reader = ReaderFor(path, out var entry); + return reader != null ? reader.Materialize(entry) : _disk.GetRealFilePath(path); + } + + public IEnumerable EnumerateFiles(string directory) + { + var reader = ReaderFor(directory, out var prefix, out var packId, + out var version, requireEntry: false); + + if (reader == null) return _disk.EnumerateFiles(directory); + + string? mountRoot = MountRootFor(Path.Combine(directory, "_")); + if (mountRoot == null) return _disk.EnumerateFiles(directory); + string normalized = string.IsNullOrEmpty(prefix) ? string.Empty : prefix.TrimEnd('/') + "/"; + + return reader.Entries + .Where(e => normalized.Length == 0 || e.StartsWith(normalized, StringComparison.OrdinalIgnoreCase)) + .Select(e => WidgetPackPaths.EntryPathIn(mountRoot, e)) + .ToList(); + } + + public bool Unpack(string packedPath, string targetDir) + { + var reader = ReaderFor(packedPath, out _, out _, out _, requireEntry: false); + return reader != null && reader.ExtractAll(targetDir); + } + + private WidgetPackReader? ReaderFor(string path, out string entry) => + ReaderFor(path, out entry, out _, out _); + + private WidgetPackReader? ReaderFor(string path, out string entry, out string packId, out string version, + bool requireEntry = true) + { + entry = string.Empty; + packId = string.Empty; + version = string.Empty; + + if (string.IsNullOrWhiteSpace(path)) return null; + + string probe = requireEntry ? path : Path.Combine(path, "_"); + var location = WidgetPackPaths.Resolve(probe); + if (location == null) return null; + + packId = location.PackIdStr; + version = location.VersionStr; + + entry = Path.GetRelativePath(location.MountRootStr, Path.GetFullPath(probe)).Replace('\\', '/'); + if (!requireEntry) entry = TrimProbe(entry); + + return _readers.GetOrAdd(location.PackFileStr, + key => new WidgetPackReader(key, WidgetPackPaths.CacheDirFor(key))); + } + + private static string? MountRootFor(string path) => WidgetPackPaths.Resolve(path)?.MountRootStr; + + private static string TrimProbe(string resolved) + { + int slash = resolved.LastIndexOf('/'); + return slash < 0 ? string.Empty : resolved[..slash]; + } +} diff --git a/SubathonManager.Data/Widgets/WidgetPackInstaller.cs b/SubathonManager.Data/Widgets/WidgetPackInstaller.cs new file mode 100644 index 00000000..aedf71a2 --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetPackInstaller.cs @@ -0,0 +1,284 @@ +using System.IO.Compression; +using System.Text.Json; +using SubathonManager.Core.Enums; +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Data.Widgets; + +public class WidgetPackManifest +{ + public string FormatVersion { get; init; } = "1"; + public string AppVersion { get; init; } = string.Empty; + public string PackId { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string Author { get; init; } = string.Empty; + + public string Group { get; init; } = WidgetPackPaths.DefaultGroup; + public string Version { get; init; } = "1.0.0"; + public string DocsUrl { get; init; } = string.Empty; + + public List Tags { get; init; } = new(); + public string PreviewImage { get; init; } = string.Empty; + public string Entry { get; init; } = string.Empty; + + public int Width { get; init; } + public int Height { get; init; } + public float ScaleX { get; init; } = 1; + public float ScaleY { get; init; } = 1; +} + +public static class WidgetPackInstaller +{ + public const string ManifestFileName = "widget.json"; + public record InstalledPack(WidgetPackManifest Manifest, string HtmlPath, string PackFile); + + public static WidgetPackManifest? ReadManifest(string smwPath) + { + try + { + using var stream = new FileStream(smwPath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + + var entry = archive.Entries.FirstOrDefault(e => + string.Equals(e.FullName.Replace('\\', '/'), ManifestFileName, StringComparison.OrdinalIgnoreCase)); + if (entry == null) return null; + + using var reader = new StreamReader(entry.Open()); + using var doc = JsonDocument.Parse(reader.ReadToEnd()); + return Parse(doc.RootElement, smwPath); + } + catch + { + return null; + } + } + + public static InstalledPack? Install(string smwPath) + { + var manifest = ReadManifest(smwPath); + if (manifest == null || string.IsNullOrWhiteSpace(manifest.Entry)) return null; + + string packId = manifest.PackId; + string version = WidgetPackPaths.Slug(manifest.Version); + if (string.IsNullOrWhiteSpace(version)) version = "1-0-0"; + + string target = WidgetPackPaths.PackFile(packId, version); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + if (!string.Equals(Path.GetFullPath(smwPath), Path.GetFullPath(target), StringComparison.OrdinalIgnoreCase)) + File.Copy(smwPath, target, overwrite: true); + + string cacheDir = WidgetPackPaths.CacheDirFor(target); + if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, recursive: true); + + WidgetPackMemoryCache.InvalidatePack(target); + WidgetPackPaths.InvalidateVersionCache(packId); + WidgetPackPaths.InvalidateResolveCache(); + } + catch + { + return null; + } + + string htmlPath = WidgetPackPaths.EntryPath(packId, version, manifest.Entry); + return new InstalledPack(manifest, htmlPath, target); + } + + public static InstalledPack? MountInPlace(string smwPath) + { + if (!File.Exists(smwPath)) return null; + + var manifest = ReadManifest(smwPath); + if (manifest == null || string.IsNullOrWhiteSpace(manifest.Entry)) return null; + + string full = Path.GetFullPath(smwPath); + string mountRoot = Path.Combine( + Path.GetDirectoryName(full)!, + Path.GetFileNameWithoutExtension(full)); + + WidgetPackPaths.InvalidateResolveCache(); + + return new InstalledPack(manifest, WidgetPackPaths.EntryPathIn(mountRoot, manifest.Entry), full); + } + + public static string? DropIntoImports(string smwPath) + { + try + { + if (!File.Exists(smwPath)) return null; + + var installed = Install(smwPath); + if (installed != null) return installed.PackFile; + + Directory.CreateDirectory(WidgetPackPaths.PackedRoot); + string target = Path.Combine(WidgetPackPaths.PackedRoot, Path.GetFileName(smwPath)); + if (string.Equals(Path.GetFullPath(smwPath), Path.GetFullPath(target), StringComparison.OrdinalIgnoreCase)) + return target; + + File.Copy(smwPath, target, overwrite: true); + return target; + } + catch + { + return null; + } + } + + public static int SweepCache(IEnumerable liveWidgetPaths) + { + string cacheRoot = WidgetPackPaths.CacheRoot; + if (!Directory.Exists(cacheRoot)) return 0; + + var live = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var path in liveWidgetPaths) + { + var location = WidgetPackPaths.Resolve(path); + if (location != null) live.Add(WidgetPackPaths.CacheDirFor(location.PackFileStr)); + } + + int removed = 0; + + try + { + foreach (var dir in Directory.EnumerateDirectories(cacheRoot)) + { + if (live.Contains(dir)) continue; + try + { + Directory.Delete(dir, recursive: true); + removed++; + } + catch { /**/ } + } + } + catch { /**/ } + + return removed; + } + + public record PackUpdate(string PackFile, string Version, string Entry); + + public static PackUpdate? FindUpdate(string widgetHtmlPath) + { + var location = WidgetPackPaths.Resolve(widgetHtmlPath); + if (location == null) return null; + + return WidgetPackPaths.IsInPresets(location.PackFileStr) + ? FindPresetUpdate(location) + : FindVersionedFolderUpdate(location); + } + + private static PackUpdate? FindPresetUpdate(WidgetPackPaths.PackLocation location) + { + var current = WidgetCatalog.EntryForPackFile(location.PackFileStr); + if (current == null || string.IsNullOrWhiteSpace(current.PackId)) return null; + + PackUpdate? best = null; + string bestVersion = current.Version; + + foreach (var candidate in WidgetCatalog.EntriesForPackId(current.PackId)) + { + if (candidate.Source != WidgetCatalogSource.Preset) continue; + if (string.Equals(candidate.PackPath, current.PackPath, StringComparison.OrdinalIgnoreCase)) continue; + if (WidgetPackPaths.CompareVersions(candidate.Version, bestVersion) <= 0) continue; + + bestVersion = candidate.Version; + best = new PackUpdate(WidgetCatalog.ToAbsolutePath(candidate.PackPath), candidate.Version, candidate.Entry); + } + + return best; + } + + private static PackUpdate? FindVersionedFolderUpdate(WidgetPackPaths.PackLocation location) + { + if (!WidgetPackPaths.IsVersionName(location.VersionStr)) return null; + + string? best = null; + foreach (var candidate in WidgetPackPaths.VersionsIn(location.PackFolderStr)) + { + if (!WidgetPackPaths.IsVersionName(candidate)) continue; + if (WidgetPackPaths.CompareVersions(candidate, location.VersionStr) <= 0) continue; + if (best == null || WidgetPackPaths.CompareVersions(candidate, best) > 0) best = candidate; + } + + if (best == null) return null; + + string file = Path.Combine(location.PackFolderStr, best + WidgetPackPaths.PackExtension); + return new PackUpdate(file, best, ReadManifest(file)?.Entry ?? string.Empty); + } + + public static string? FindNewerVersion(string widgetHtmlPath) => FindUpdate(widgetHtmlPath)?.Version; + + private static WidgetPackManifest Parse(JsonElement root, string smwPath) + { + var widget = root.TryGetProperty("widget", out var w) ? w : default; + + string name = Str(widget, "name"); + if (string.IsNullOrWhiteSpace(name)) name = Path.GetFileNameWithoutExtension(smwPath); + string author = Str(widget, "author"); + string group = WidgetPackPaths.NormalizeGroup(Str(widget, "group")); + + string packId = Str(widget, "pack_id"); + if (string.IsNullOrWhiteSpace(packId)) packId = WidgetPackPaths.MakePackId(author, group, name); + + string version = Str(widget, "widget_version"); + if (string.IsNullOrWhiteSpace(version)) version = "1.0.0"; + + var size = widget.ValueKind == JsonValueKind.Object && widget.TryGetProperty("size", + out var s) ? s : default; + var scale = widget.ValueKind == JsonValueKind.Object && widget.TryGetProperty("scale", + out var c) ? c : default; + + return new WidgetPackManifest + { + FormatVersion = Str(root, "version"), + AppVersion = Str(root, "app_version"), + PackId = packId, + Name = name, + Author = author, + Group = group, + Version = version, + DocsUrl = Str(widget, "docsUrl"), + Tags = StrList(widget, "tags"), + PreviewImage = Str(widget, "preview_image"), + Entry = Str(widget, "entry").Replace('\\', '/').TrimStart('/'), + Width = Int(size, "width", 400), + Height = Int(size, "height", 400), + ScaleX = Flt(scale, "x", 1f), + ScaleY = Flt(scale, "y", 1f) + }; + } + + private static string Str(JsonElement el, string name) + => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String + ? v.GetString() ?? string.Empty + : string.Empty; + + private static List StrList(JsonElement el, string name) + { + if (el.ValueKind != JsonValueKind.Object || + !el.TryGetProperty(name, out var v) || + v.ValueKind != JsonValueKind.Array) + return new List(); + + return v.EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.String) + .Select(item => item.GetString() ?? string.Empty) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToList(); + } + + private static int Int(JsonElement el, string name, int fallback) + => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, + out var v) && v.TryGetInt32(out var i) + ? i + : fallback; + + private static float Flt(JsonElement el, string name, float fallback) + => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, + out var v) && v.TryGetSingle(out var f) && f > 0 + ? f + : fallback; +} diff --git a/SubathonManager.Data/Widgets/WidgetPackMemoryCache.cs b/SubathonManager.Data/Widgets/WidgetPackMemoryCache.cs new file mode 100644 index 00000000..052a4d23 --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetPackMemoryCache.cs @@ -0,0 +1,100 @@ +namespace SubathonManager.Data.Widgets; + +internal static class WidgetPackMemoryCache +{ + private const long Budget = 32 * 1024 * 1024; + public const long MaxEntrySize = 2 * 1024 * 1024; + + private static readonly Lock CacheLock = new(); + private static readonly Dictionary> Map = new(StringComparer.OrdinalIgnoreCase); + + private static readonly LinkedList Order = []; + + private static long _size; + + private sealed class CacheItem(string key, byte[] data) + { + public string Key { get; } = key; + public byte[] Data { get; } = data; + } + + public static string MakeKey(string packFile, string entry) => $"{packFile}|{entry}"; + + public static bool TryGet(string key, out byte[] data) + { + lock (CacheLock) + { + if (!Map.TryGetValue(key, out var node)) + { + data = []; + return false; + } + + Order.Remove(node); + Order.AddFirst(node); + data = node.Value.Data; + return true; + } + } + + public static void Set(string key, byte[] data) + { + if (data.LongLength >= MaxEntrySize) return; + + lock (CacheLock) + { + if (Map.TryGetValue(key, out var existing)) + { + _size -= existing.Value.Data.LongLength; + Order.Remove(existing); + Map.Remove(key); + } + + var node = Order.AddFirst(new CacheItem(key, data)); + Map[key] = node; + _size += data.LongLength; + + while (_size > Budget && Order.Last is { } tail) + { + Order.RemoveLast(); + Map.Remove(tail.Value.Key); + _size -= tail.Value.Data.LongLength; + } + } + } + + public static void InvalidatePack(string packFile) + { + string prefix = packFile + "|"; + + lock (CacheLock) + { + var doomed = Map.Keys + .Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var key in doomed) + { + if (!Map.TryGetValue(key, out var node)) continue; + Order.Remove(node); + Map.Remove(key); + _size -= node.Value.Data.LongLength; + } + } + } + + public static void Clear() + { + lock (CacheLock) + { + Map.Clear(); + Order.Clear(); + _size = 0; + } + } + + internal static long CurrentSize + { + get { lock (CacheLock) return _size; } + } +} diff --git a/SubathonManager.Data/Widgets/WidgetPackPaths.cs b/SubathonManager.Data/Widgets/WidgetPackPaths.cs new file mode 100644 index 00000000..2f2f39cb --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetPackPaths.cs @@ -0,0 +1,282 @@ +using System.Text.RegularExpressions; +using SubathonManager.Core; +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Data.Widgets; + +public static partial class WidgetPackPaths +{ + public const string PackExtension = ".smw"; + + public static string ImportsRoot => Path.GetFullPath(Path.Combine("./imports", "widgets")); + public static string PackedRoot => Path.Combine(ImportsRoot, "packed"); + public static string UnpackedRoot => Path.Combine(ImportsRoot, "unpacked"); + public static string CacheRoot => Path.GetFullPath(Path.Combine("./cache", "widgets")); + public static string PresetsRoot => Path.GetFullPath("./presets"); + + public static string MountRoot(string packId, string version) + => Path.Combine(PackedRoot, packId, version); + + public static string PackFile(string packId, string version) + => Path.Combine(PackedRoot, packId, version + PackExtension); + + public static string PackFolder(string packId) => Path.Combine(PackedRoot, packId); + + private static readonly Lock VersionCacheLock = new(); + private static readonly Dictionary> VersionCache = new(StringComparer.OrdinalIgnoreCase); + + public static List InstalledVersions(string packId) => VersionsIn(PackFolder(packId)); + public static List VersionsIn(string folder) + { + lock (VersionCacheLock) + { + if (VersionCache.TryGetValue(folder, out var cached)) return cached; + + var versions = new List(); + + if (Directory.Exists(folder)) + { + try + { + versions = Directory.EnumerateFiles(folder, "*" + PackExtension) + .Select(Path.GetFileNameWithoutExtension) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v!) + .ToList(); + } + catch { /**/ } + } + + VersionCache[folder] = versions; + return versions; + } + } + + public static string DisplayVersion(string? version) + => string.IsNullOrWhiteSpace(version) + ? string.Empty + : VersionSeparatorRegex().Replace(version, "."); + + public static void InvalidateVersionCache(string? packId = null) + { + lock (VersionCacheLock) + { + if (packId == null) VersionCache.Clear(); + else VersionCache.Remove(PackFolder(packId)); + } + } + + public static int CompareVersions(string left, string right) + { + var a = VersionSegments(left); + var b = VersionSegments(right); + + for (int i = 0; i < Math.Max(a.Count, b.Count); i++) + { + long x = i < a.Count ? a[i] : 0; + long y = i < b.Count ? b[i] : 0; + if (x != y) return x.CompareTo(y); + } + + return string.Compare(left, right, StringComparison.OrdinalIgnoreCase); + } + + private static List VersionSegments(string version) + => VersionSegmentRegex().Matches(version ?? string.Empty) + .Select(m => long.TryParse(m.Value, out var n) ? n : 0L) + .ToList(); + public const string DefaultGroup = "widgets"; + + public static string NormalizeGroup(string? group) + => string.IsNullOrWhiteSpace(group) ? DefaultGroup : group.Trim(); + + public static bool IsInPresets(string packFileOrFolder) + { + try + { + return Path.GetFullPath(packFileOrFolder) + .StartsWith(PresetsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + catch { return false; } + } + + public static bool IsVersionName(string? name) + => !string.IsNullOrWhiteSpace(name) && VersionNameRegex().IsMatch(name); + + public static bool IsInGlobalStore(string packFileOrFolder) + { + try + { + string full = Path.GetFullPath(packFileOrFolder); + + return full.StartsWith(PackedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + || full.StartsWith(PresetsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + catch { return false; } + } + + public static string UnpackRootFor(PackLocation location, string author, string group, string name) + { + if (IsInGlobalStore(location.PackFolderStr)) + return UnpackRoot(author, group, name, location.VersionStr); + + string container = Path.GetDirectoryName(location.PackFolderStr) ?? location.PackFolderStr; + return Path.Combine(container, "unpacked", location.PackIdStr, location.VersionStr); + } + + public static string UnpackRoot(string author, string group, string name, string version) + { + string authorFolder = Slug(author); + if (string.IsNullOrEmpty(authorFolder)) authorFolder = "unknown"; + + string groupFolder = Slug(NormalizeGroup(group)); + if (string.IsNullOrEmpty(groupFolder)) groupFolder = DefaultGroup; + + string nameFolder = Slug(name); + if (string.IsNullOrEmpty(nameFolder)) nameFolder = "widget"; + + string versionFolder = SanitizeSegment(version); + if (string.IsNullOrEmpty(versionFolder)) versionFolder = "1.0.0"; + + return Path.Combine(UnpackedRoot, authorFolder, groupFolder, nameFolder, versionFolder); + } + + private static string SanitizeSegment(string? value) => SafeFileName.Sanitize(value); + + public static string EntryPath(string packId, string version, string entry) + => Path.Combine(MountRoot(packId, version), entry.Replace('/', Path.DirectorySeparatorChar)); + + public record PackLocation(string PackFileStr, string PackFolderStr, string MountRootStr, string PackIdStr, string VersionStr); + + private static readonly Lock ResolveCacheLock = new(); + private static readonly Dictionary ResolveCache = new(StringComparer.OrdinalIgnoreCase); + private const int MaxMountDepth = 16; + + public static PackLocation? Resolve(string widgetPath) + { + if (string.IsNullOrWhiteSpace(widgetPath)) return null; + + string full; + try { full = Path.GetFullPath(widgetPath); } + catch { return null; } + + string? dir = Path.GetDirectoryName(full); + if (dir == null) return null; + + var found = ResolveDirectory(dir); + return found; + } + + private static PackLocation? ResolveDirectory(string directory) + { + lock (ResolveCacheLock) + { + if (ResolveCache.TryGetValue(directory, out var cached)) return cached; + } + + PackLocation? result = null; + string? current = directory; + + for (int depth = 0; depth < MaxMountDepth && current != null; depth++) + { + string candidate = current + PackExtension; + if (File.Exists(candidate)) + { + string packFolder = Path.GetDirectoryName(current) ?? current; + result = new PackLocation( + PackFileStr: candidate, + PackFolderStr: packFolder, + MountRootStr: current, + PackIdStr: Path.GetFileName(packFolder), + VersionStr: Path.GetFileName(current)); + break; + } + + current = Path.GetDirectoryName(current); + } + + lock (ResolveCacheLock) + { + ResolveCache[directory] = result; + } + + return result; + } + + public static void InvalidateResolveCache() + { + lock (ResolveCacheLock) + { + ResolveCache.Clear(); + } + } + + public static bool TryResolve(string widgetPath, out string packFile, out string entry, + out string packId, out string version) + { + packFile = string.Empty; + entry = string.Empty; + packId = string.Empty; + version = string.Empty; + + var found = Resolve(widgetPath); + if (found == null) return false; + + packFile = found.PackFileStr; + packId = found.PackIdStr; + version = found.VersionStr; + entry = Path.GetRelativePath(found.MountRootStr, Path.GetFullPath(widgetPath)).Replace('\\', '/'); + + return true; + } + + public static string EntryPathIn(string mountRoot, string entry) + => Path.Combine(mountRoot, entry.Replace('/', Path.DirectorySeparatorChar)); + + public static string CacheDirFor(string packFile) + { + string key; + try { key = Path.GetFullPath(packFile).ToLowerInvariant(); } + catch { key = packFile.ToLowerInvariant(); } + + byte[] hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(key)); + string folder = $"{Slug(Path.GetFileNameWithoutExtension(packFile))}-{Convert.ToHexString(hash)[..8].ToLowerInvariant()}"; + + return Path.Combine(CacheRoot, folder); + } + + + public static string MakePackId(string author, string group, string name) + { + string n = Slug(name); + if (string.IsNullOrEmpty(n)) n = "widget"; + + var parts = new[] { Slug(author), Slug(NormalizeGroup(group)), n } + .Where(p => !string.IsNullOrEmpty(p)); + + return string.Join('.', parts); + } + + public static string Slug(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + string slug = SlugStripRegex().Replace(value.Trim().ToLowerInvariant(), "-"); + slug = SlugCollapseRegex().Replace(slug, "-").Trim('-'); + return slug; + } + + [GeneratedRegex(@"[^a-z0-9]+")] + private static partial Regex SlugStripRegex(); + + [GeneratedRegex(@"-{2,}")] + private static partial Regex SlugCollapseRegex(); + + [GeneratedRegex(@"\d+")] + private static partial Regex VersionSegmentRegex(); + + [GeneratedRegex(@"^\d+([._-]\d+)*$")] + private static partial Regex VersionNameRegex(); + + [GeneratedRegex(@"(?<=\d)-(?=\d)")] + private static partial Regex VersionSeparatorRegex(); +} diff --git a/SubathonManager.Data/Widgets/WidgetPackReader.cs b/SubathonManager.Data/Widgets/WidgetPackReader.cs new file mode 100644 index 00000000..93df97bd --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetPackReader.cs @@ -0,0 +1,163 @@ +using System.IO.Compression; +using System.Text; + +namespace SubathonManager.Data.Widgets; + +internal sealed class WidgetPackReader(string packFile, string cacheDir) +{ + private const long MaterializeThreshold = WidgetPackMemoryCache.MaxEntrySize; + + private readonly Lock _indexLock = new(); + private Dictionary? _index; + private DateTime _indexedAt; + + private readonly record struct IndexedEntry(string FullName, long Length); + + private Dictionary Index + { + get + { + lock (_indexLock) + { + var stamp = File.Exists(packFile) ? File.GetLastWriteTimeUtc(packFile) : DateTime.MinValue; + if (_index != null && stamp == _indexedAt) return _index; + + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + using var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) continue; // dir + index[Normalize(entry.FullName)] = new IndexedEntry(entry.FullName, entry.Length); + } + } + catch { /**/ } + + WidgetPackMemoryCache.InvalidatePack(packFile); + _index = index; + _indexedAt = stamp; + return index; + } + } + } + + public bool Contains(string entry) => Index.ContainsKey(Normalize(entry)); + + public IEnumerable Entries => Index.Keys; + + public long LengthOf(string entry) + => Index.TryGetValue(Normalize(entry), out var found) ? found.Length : -1; + + public byte[]? Read(string entry) + { + string key = Normalize(entry); + string cacheKey = WidgetPackMemoryCache.MakeKey(packFile, key); + + if (WidgetPackMemoryCache.TryGet(cacheKey, out var cached)) return cached; + if (!Index.TryGetValue(key, out var indexed)) return null; + + byte[]? bytes = ReadRaw(indexed.FullName); + if (bytes == null) return null; + + if (indexed.Length < MaterializeThreshold) + WidgetPackMemoryCache.Set(cacheKey, bytes); + + return bytes; + } + + public string? ReadText(string entry) + { + var bytes = Read(entry); + if (bytes == null) return null; + return bytes is [0xEF, 0xBB, 0xBF, ..] + ? Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3) + : Encoding.UTF8.GetString(bytes); + } + + public string? Materialize(string entry) + { + string key = Normalize(entry); + if (!Index.TryGetValue(key, out var indexed)) return null; + if (indexed.Length < MaterializeThreshold) return null; + + string target = Path.Combine(cacheDir, key.Replace('/', Path.DirectorySeparatorChar)); + + try + { + var info = new FileInfo(target); + if (info.Exists && info.Length == indexed.Length) return target; + + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + string temp = target + ".partial"; + using (var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var archive = new ZipArchive(stream, ZipArchiveMode.Read)) + { + var zipEntry = archive.GetEntry(indexed.FullName); + if (zipEntry == null) return null; + zipEntry.ExtractToFile(temp, overwrite: true); + } + + File.Move(temp, target, overwrite: true); + return target; + } + catch + { + return null; + } + } + + public bool ExtractAll(string targetDir) + { + try + { + Directory.CreateDirectory(targetDir); + using var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) continue; + + string destination = Path.GetFullPath( + Path.Combine(targetDir, Normalize(entry.FullName).Replace('/', Path.DirectorySeparatorChar))); + + if (!destination.StartsWith(Path.GetFullPath(targetDir) + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase)) + continue; + + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + entry.ExtractToFile(destination, overwrite: true); + } + + return true; + } + catch + { + return false; + } + } + + private byte[]? ReadRaw(string fullName) + { + try + { + using var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + var entry = archive.GetEntry(fullName); + if (entry == null) return null; + + using var entryStream = entry.Open(); + using var buffer = new MemoryStream(entry.Length > 0 ? (int)Math.Min(entry.Length, int.MaxValue) : 0); + entryStream.CopyTo(buffer); + return buffer.ToArray(); + } + catch + { + return null; + } + } + + private static string Normalize(string entry) => entry.Replace('\\', '/').TrimStart('/'); +} \ No newline at end of file diff --git a/SubathonManager.Integration/DevTunnelsService.cs b/SubathonManager.Integration/DevTunnelsService.cs index 33929339..b769af85 100644 --- a/SubathonManager.Integration/DevTunnelsService.cs +++ b/SubathonManager.Integration/DevTunnelsService.cs @@ -72,6 +72,7 @@ public async Task StartAsync(CancellationToken ct = default) BroadcastTunnelStatus(false, null); } + [ExcludeFromCodeCoverage] public async Task TryInstallAsync(CancellationToken ct = default) { var probe = await client.ProbeCliAsync(ct); diff --git a/SubathonManager.Server/AspNetContextAdapter.cs b/SubathonManager.Server/AspNetContextAdapter.cs index b307d9a6..5dffe02a 100644 --- a/SubathonManager.Server/AspNetContextAdapter.cs +++ b/SubathonManager.Server/AspNetContextAdapter.cs @@ -3,6 +3,7 @@ using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; +using SubathonManager.Server.Interfaces; namespace SubathonManager.Server; @@ -49,6 +50,14 @@ public async Task ServeFile(string fullPath, string contentType) await ctx.Response.SendFileAsync(fullPath); } + public async Task ServeBytes(byte[] data, string contentType) + { + ctx.Response.ContentType = contentType; + ctx.Response.ContentLength = data.Length; + AddCorsHeaders(ctx.Response); + await ctx.Response.Body.WriteAsync(data); + } + private static void AddCorsHeaders(HttpResponse response) { response.Headers["Access-Control-Allow-Origin"] = "*"; diff --git a/SubathonManager.Server/Interfaces/IHttpContext.cs b/SubathonManager.Server/Interfaces/IHttpContext.cs index edcc6252..9f375fe6 100644 --- a/SubathonManager.Server/Interfaces/IHttpContext.cs +++ b/SubathonManager.Server/Interfaces/IHttpContext.cs @@ -1,6 +1,6 @@ using System.Text; using System.Net.WebSockets; -namespace SubathonManager.Server; +namespace SubathonManager.Server.Interfaces; public interface IHttpContext { @@ -16,8 +16,7 @@ public interface IHttpContext bool IsWebSocket { get; } Task? AcceptWebSocketAsync(string? subProtocol = null); - Task WriteResponse(int statusCode, string body, bool addCors = false, string? contentType = null); - Task ServeFile(string fullPath, string contentType); + Task ServeBytes(byte[] data, string contentType); } \ No newline at end of file diff --git a/SubathonManager.Server/WebServer.Api.cs b/SubathonManager.Server/WebServer.Api.cs index 098ed685..de6da18c 100644 --- a/SubathonManager.Server/WebServer.Api.cs +++ b/SubathonManager.Server/WebServer.Api.cs @@ -6,6 +6,8 @@ using SubathonManager.Core.Enums; using SubathonManager.Integration; using SubathonManager.Data; +using SubathonManager.Server.Interfaces; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Server; @@ -34,6 +36,43 @@ private void SetupApiRoutes() _routes.Add((new RouteKey("POST", "/api/update-position/"),HandleWidgetUpdateAsync )); _routes.Add((new RouteKey("POST", "/api/update-size/"),HandleWidgetUpdateAsync)); _routes.Add((new RouteKey("POST", "/api/update-dimensions/"),HandleWidgetUpdateAsync)); + _routes.Add((new RouteKey("POST", "/api/widget-action/"),HandleWidgetActionAsync)); + } + + internal async Task HandleWidgetActionAsync(IHttpContext ctx) + { + var parts = ctx.Path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 3 || !Guid.TryParse(parts[2], out var widgetId)) + { + await ctx.WriteResponse(400, "Invalid Widget ID"); + return; + } + + string body; + using (var reader = new StreamReader(ctx.Body, ctx.Encoding)) + body = await reader.ReadToEndAsync(); + + try + { + var data = JsonSerializer.Deserialize>(body); + string name = data != null && data.TryGetValue("action", out var el) && el.ValueKind == JsonValueKind.String + ? el.GetString() ?? string.Empty + : string.Empty; + + if (!Enum.TryParse(name, ignoreCase: true, out var action)) + { + await ctx.WriteResponse(400, "Unknown action"); + return; + } + + WidgetEvents.RaiseWidgetAction(widgetId, action); + await ctx.WriteResponse(200, "OK"); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Failed to handle widget context action"); + await ctx.WriteResponse(400, "Invalid action data"); + } } internal async Task HandleSelectAsync(IHttpContext ctx) diff --git a/SubathonManager.Server/WebServer.Overlays.cs b/SubathonManager.Server/WebServer.Overlays.cs index a7111f23..ddba1da6 100644 --- a/SubathonManager.Server/WebServer.Overlays.cs +++ b/SubathonManager.Server/WebServer.Overlays.cs @@ -4,7 +4,10 @@ using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using SubathonManager.Core; using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; +using SubathonManager.Server.Interfaces; namespace SubathonManager.Server; @@ -59,11 +62,11 @@ internal async Task HandleWidgetRequest(IHttpContext ctx) ? widget.HtmlPath : Path.Combine(folder, relativePath.Replace('/', Path.DirectorySeparatorChar)); - if (File.Exists(filePath)) + if (WidgetFiles.Current.Exists(filePath)) { if (Path.GetExtension(filePath).Equals(".html", StringComparison.OrdinalIgnoreCase)) { - string html = await File.ReadAllTextAsync(filePath); + string html = WidgetFiles.Current.ReadAllText(filePath) ?? string.Empty; var cssOverrides = new StringBuilder(); cssOverrides.AppendLine(GetWebsocketInjectionScript()); @@ -153,8 +156,20 @@ function loadBunnyFont(fontName) { await ctx.WriteResponse(200, html, true, "text/html"); return; } - await ctx.ServeFile(filePath, GetContentType(filePath)); - return; + + var realPath = WidgetFiles.Current.GetRealFilePath(filePath); + if (realPath != null) + { + await ctx.ServeFile(realPath, GetContentType(filePath)); + return; + } + + var bytes = WidgetFiles.Current.ReadAllBytes(filePath); + if (bytes != null) + { + await ctx.ServeBytes(bytes, GetContentType(filePath)); + return; + } } } } @@ -162,6 +177,18 @@ function loadBunnyFont(fontName) { await ctx.WriteResponse(404, "Widget not found"); } + internal async Task HandleResourceRequest(IHttpContext ctx) + { + var filePath = ResourcePaths.ResolveRequestPath(ctx.Path); + if (filePath != null) + { + await ctx.ServeFile(filePath, GetContentType(filePath)); + return; + } + + await ctx.WriteResponse(404, "Resource not found"); + } + internal async Task HandleRouteRequest(IHttpContext ctx) { var path = ctx.Path; @@ -190,6 +217,68 @@ internal async Task HandleRouteRequest(IHttpContext ctx) await ctx.WriteResponse(404, "Route/Overlay not found"); } + private static string BuildWidgetContextMenuScript() => @" + + "; + private string GenerateMergedPage(Core.Models.Route route, bool isEditor = false) { StringBuilder sb = new StringBuilder(); @@ -775,6 +864,8 @@ function updateHandleIndicators() {{ }}); "); + + sb.AppendLine(BuildWidgetContextMenuScript()); } sb.AppendLine(""); diff --git a/SubathonManager.Server/WebServer.WebSocket.cs b/SubathonManager.Server/WebServer.WebSocket.cs index df045a35..190af7dc 100644 --- a/SubathonManager.Server/WebServer.WebSocket.cs +++ b/SubathonManager.Server/WebServer.WebSocket.cs @@ -10,6 +10,7 @@ using SubathonManager.Core.Objects; using SubathonManager.Data; using SubathonManager.Integration; +using SubathonManager.Server.Interfaces; using SubathonManager.Services; // ReSharper disable NullableWarningSuppressionIsUsed diff --git a/SubathonManager.Server/WebServer.cs b/SubathonManager.Server/WebServer.cs index 67cb9a07..08ed0e84 100644 --- a/SubathonManager.Server/WebServer.cs +++ b/SubathonManager.Server/WebServer.cs @@ -9,6 +9,7 @@ using SubathonManager.Core; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; +using SubathonManager.Server.Interfaces; namespace SubathonManager.Server; @@ -86,6 +87,7 @@ private void SetupOverlayRoutes() _routes.Add((new RouteKey("GET", "/ws"),HandleWebSocketRequestAsync)); _routes.Add((new RouteKey("GET", "/widget/"),HandleWidgetRequest )); _routes.Add((new RouteKey("GET", "/route/"),HandleRouteRequest )); + _routes.Add((new RouteKey("GET", ResourcePaths.UrlPrefix),HandleResourceRequest )); } public async Task StartAsync(CancellationToken cancellationToken = default) diff --git a/SubathonManager.Services/CommandService.cs b/SubathonManager.Services/CommandService.cs index 429e74a0..a2e69063 100644 --- a/SubathonManager.Services/CommandService.cs +++ b/SubathonManager.Services/CommandService.cs @@ -4,6 +4,7 @@ using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; +// ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Services; @@ -66,14 +67,13 @@ private static bool ValidateUser(SubathonEvent subathonEvent, string user, bool if (isBroadcaster) return true; string configKey = $"Commands.{subathonEvent.Command}.permissions"; - // All Commands are under twitch section at the moment, despite being cross platform chat commands - // Should update eventually, or is it too late if (isModerator && AppConfig!.GetBool("Chat", $"{configKey}.Mods", false)) return true; if (isVip && AppConfig!.GetBool("Chat", $"{configKey}.VIPs", false)) return true; - string[] whitelist = AppConfig!.Get("Chat", $"{configKey}.Whitelist")!.ToLower().Split(','); + string[] whitelist = AppConfig!.Get("Chat", $"{configKey}.Whitelist")!.ToLower() + .Split(',').Select(s => s.Trim()).ToArray(); if (whitelist.Contains(user.ToLower().Trim())) return true; diff --git a/SubathonManager.Tests/CoreUnitTests/ResourcePathsTests.cs b/SubathonManager.Tests/CoreUnitTests/ResourcePathsTests.cs new file mode 100644 index 00000000..cd79e72c --- /dev/null +++ b/SubathonManager.Tests/CoreUnitTests/ResourcePathsTests.cs @@ -0,0 +1,411 @@ +using SubathonManager.Tests.Utility; + +namespace SubathonManager.Tests.CoreUnitTests; + +public class ResourcePathsPureTests +{ + + [Theory] + [InlineData("/resources/images/logo.png", true)] + [InlineData("/RESOURCES/images/logo.png", true)] + [InlineData("\\resources\\images\\logo.png", true)] + [InlineData("resources/images/logo.png", false)] + [InlineData("/res/images/logo.png", false)] + [InlineData("./resources/logo.png", false)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData(null, false)] + public void IsResourceUrl_Branches(string? value, bool expected) + => Assert.Equal(expected, ResourcePaths.IsResourceUrl(value)); + + + [Theory] + [InlineData("/resources/images/logo.png", "images/logo.png")] + [InlineData("\\resources\\images\\logo.png", "images/logo.png")] + [InlineData("/resources/images/logo.png?v=2", "images/logo.png")] + [InlineData("/resources/images/logo.png#frag", "images/logo.png")] + [InlineData("/resources/images/logo.png?v=2#frag", "images/logo.png")] + [InlineData("/resources//images/logo.png/", "images/logo.png")] + public void RelativeFromUrl_StripsPrefixQueryAndFragment(string url, string expected) + => Assert.Equal(expected, ResourcePaths.RelativeFromUrl(url)); + + [Theory] + [InlineData("/resources/")] + [InlineData("/resources/?x=1")] + [InlineData("/notresources/logo.png")] + [InlineData(null)] + public void RelativeFromUrl_ReturnsNull_WhenNoUsableRelative(string? url) + => Assert.Null(ResourcePaths.RelativeFromUrl(url)); + + + [Theory] + [InlineData(null)] + [InlineData("")] + public void FindReferences_EmptyInput_YieldsNothing(string? text) + => Assert.Empty(ResourcePaths.FindReferences(text)); + + [Fact] + public void FindReferences_NoMatch_YieldsNothing() + => Assert.Empty(ResourcePaths.FindReferences("

nothing to see here

")); + + [Theory] + [InlineData("")] + [InlineData("")] + [InlineData("background: url(/resources/images/logo.png);")] + [InlineData("const a = `/resources/images/logo.png`;")] + [InlineData("let x = /resources/images/logo.png ")] + [InlineData("load(a,/resources/images/logo.png)")] + [InlineData("x;/resources/images/logo.png")] + [InlineData("[/resources/images/logo.png ")] + public void FindReferences_AcceptedDelimiters(string text) + => Assert.Equal(["images/logo.png"], ResourcePaths.FindReferences(text)); + + [Fact] + public void FindReferences_RejectsMidWordMatch() + { + Assert.Empty(ResourcePaths.FindReferences("https://cdn.example.com/resources/images/logo.png")); + } + + [Fact] + public void FindReferences_StripsQueryAndFragment() + { + Assert.Equal(new[] {"audio/ding.mp3"}, + ResourcePaths.FindReferences("