diff --git a/Ra3.BattleNet.Metadata.Tests/MetadataTests.cs b/Ra3.BattleNet.Metadata.Tests/MetadataTests.cs index dc1606d..15c3d76 100644 --- a/Ra3.BattleNet.Metadata.Tests/MetadataTests.cs +++ b/Ra3.BattleNet.Metadata.Tests/MetadataTests.cs @@ -228,5 +228,71 @@ public void GetIncludeTree_ReturnsTreeStructure() tree.Should().NotBeNullOrEmpty(); tree.Should().Contain("Metadata"); } + + [Fact] + public void ToNodeTree_ShouldKeepLeafValue() + { + // Arrange + var filePath = Path.Combine(_testDataPath, "valid-metadata.xml"); + var metadata = Metadata.LoadFromFile(filePath); + + // Act + var root = metadata.ToNodeTree(); + var app = root.Children.Single(c => c.Name == "Application"); + var appName = app.Children.Single(c => c.Name == "Name"); + + // Assert + appName.Value.Should().Be("Test Application"); + } + + [Fact] + public void GetBusinessEntities_ShouldReturnTypedEntities() + { + // Arrange + var filePath = Path.Combine(Environment.CurrentDirectory, "..", "..", "..", "..", "Metadata", "metadata.xml"); + var metadata = Metadata.LoadFromFile(filePath); + + // Act + var entities = metadata.GetBusinessEntities(); + + // Assert + entities.Should().Contain(e => e.EntityType == "Application" && e.Id == "RA3BattleNet"); + entities.Should().Contain(e => e.EntityType == "Mod" && e.Id == "Corona"); + entities.Should().Contain(e => e.EntityType == "Markdown"); + entities.Should().Contain(e => e.EntityType == "Manifest"); + } + + + [Fact] + public void Mods_ShouldExposeVersionAndPackages() + { + // Arrange + var filePath = Path.Combine(Environment.CurrentDirectory, "..", "..", "..", "..", "Metadata", "metadata.xml"); + var metadata = Metadata.LoadFromFile(filePath); + + // Act + var corona = metadata.Mods().Single(m => m.Id == "Corona"); + + // Assert + corona.Version.Should().Be("3.229"); + corona.Packages.Should().NotBeEmpty(); + } + + [Fact] + public void Catalog_ShouldProvideConvenientLookup() + { + // Arrange + var filePath = Path.Combine(Environment.CurrentDirectory, "..", "..", "..", "..", "Metadata", "metadata.xml"); + var metadata = Metadata.LoadFromFile(filePath); + + // Act + var catalog = metadata.Catalog(); + var app = catalog.Application("RA3BattleNet"); + + // Assert + app.Should().NotBeNull(); + app!.Version.Should().Be("1.5.2.0"); + } + } } diff --git a/Ra3.BattleNet.Metadata/Metadata.cs b/Ra3.BattleNet.Metadata/Metadata.cs index 224307a..54fc22b 100644 --- a/Ra3.BattleNet.Metadata/Metadata.cs +++ b/Ra3.BattleNet.Metadata/Metadata.cs @@ -14,6 +14,7 @@ public class Metadata private readonly Dictionary _envVars = new(); private readonly Dictionary _fileHashes = new(); private string _currentFilePath = string.Empty; + private string? _value = null; private Metadata? _parent = null; private string _includeType = "public"; // "public" 或 "private" @@ -24,6 +25,10 @@ public class Metadata public IReadOnlyList DefineChildren => _defineChildren; public Metadata? Parent => _parent; public string IncludeType => _includeType; + /// + /// 节点文本值(仅叶子节点有效)。 + /// + public string? Value => _value; public Metadata() { @@ -224,6 +229,12 @@ private XElement ToXElement() { element.SetAttributeValue(var.Key, var.Value); } + + if (!string.IsNullOrWhiteSpace(_value) && !_children.Any()) + { + element.Value = _value; + } + foreach (var child in _children) { element.Add(child.ToXElement()); @@ -298,6 +309,97 @@ private void ParseElement(XElement? element, string currentFilePath, HashSet + /// 将当前元数据转换为可反序列化使用的树状结构。 + /// + public MetadataNode ToNodeTree() + { + return BuildNode(this); + } + + /// + /// 获取所有业务实体(Application/Mod/Markdown/Image/Manifest)节点。 + /// + public IReadOnlyList GetBusinessEntities() + { + var supported = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Application", "Mod", "Markdown", "Image", "Manifest" + }; + + var allNodes = GetAllNodes(); + var entities = new List(); + + foreach (var node in allNodes.Where(n => supported.Contains(n.Name))) + { + var entity = new BusinessEntity + { + EntityType = node.Name, + Id = node.Get("ID"), + Path = node.GetElementPath(), + Attributes = new Dictionary(node._variables, StringComparer.OrdinalIgnoreCase), + Properties = CollectEntityProperties(node) + }; + + entities.Add(entity); + } + + return entities; + } + + private static Dictionary CollectEntityProperties(Metadata node) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var child in node._children) + { + if (!string.IsNullOrWhiteSpace(child._value) && !result.ContainsKey(child.Name)) + { + result[child.Name] = child._value; + } + } + + var packageCount = node._children.FirstOrDefault(c => c.Name == "Packages") + ?._children.Count(c => c.Name == "Package"); + if (packageCount.HasValue) + { + result["PackageCount"] = packageCount.Value.ToString(); + } + + var fileCount = node._children.Count(c => c.Name == "File"); + if (fileCount > 0) + { + result["FileCount"] = fileCount.ToString(); + } + + return result; + } + + private MetadataNode BuildNode(Metadata metadata) + { + return new MetadataNode( + metadata.Name, + metadata.Value, + new Dictionary(metadata._variables, StringComparer.OrdinalIgnoreCase), + metadata._children.Select(BuildNode).ToList()); + } + + private List GetAllNodes() + { + var nodes = new List { this }; + foreach (var child in _children) + { + nodes.AddRange(child.GetAllNodes()); + } + + return nodes; } /// diff --git a/Ra3.BattleNet.Metadata/MetadataModels.cs b/Ra3.BattleNet.Metadata/MetadataModels.cs new file mode 100644 index 0000000..237e400 --- /dev/null +++ b/Ra3.BattleNet.Metadata/MetadataModels.cs @@ -0,0 +1,45 @@ +namespace Ra3.BattleNet.Metadata; + +/// +/// 通用元数据树节点(适合反序列化后继续加工)。 +/// +/// 节点名(即 XML 标签名)。 +/// 叶子节点文本值;若包含子节点则通常为 null 或空字符串。 +/// 节点属性字典(如 IDSource)。 +/// 子节点集合。 +public sealed record MetadataNode( + string Name, + string? Value, + IReadOnlyDictionary Attributes, + IReadOnlyList Children); + +/// +/// 业务实体快照,便于直接查询核心字段。 +/// +public sealed class BusinessEntity +{ + /// + /// 实体类型(如 ApplicationModMarkdown)。 + /// + public string EntityType { get; init; } = string.Empty; + + /// + /// 实体 ID(来自节点 ID 属性)。 + /// + public string? Id { get; init; } + + /// + /// 实体在元数据树中的路径(A -> B -> C)。 + /// + public string Path { get; init; } = string.Empty; + + /// + /// 原始属性字典。 + /// + public IReadOnlyDictionary Attributes { get; init; } = new Dictionary(); + + /// + /// 归一化后的常用属性(如 PackageCountFileCount)。 + /// + public IReadOnlyDictionary Properties { get; init; } = new Dictionary(); +} diff --git a/Ra3.BattleNet.Metadata/MetadataQueryExtensions.cs b/Ra3.BattleNet.Metadata/MetadataQueryExtensions.cs new file mode 100644 index 0000000..23ad23f --- /dev/null +++ b/Ra3.BattleNet.Metadata/MetadataQueryExtensions.cs @@ -0,0 +1,119 @@ +namespace Ra3.BattleNet.Metadata; + +/// +/// 的业务查询扩展方法。 +/// +public static class MetadataQueryExtensions +{ + /// + /// 构建目录式查询入口。 + /// + /// 元数据根对象。 + /// 可按实体类型查询的目录对象。 + public static MetadataCatalog Catalog(this Metadata root) + { + return new MetadataCatalog(root); + } + + /// + /// 获取所有 Mod 实体。 + /// + /// 元数据根对象。 + /// Mod 实体列表。 + public static IReadOnlyList Mods(this Metadata root) + { + return root.GetAllElements("Mod") + .Select(ToMod) + .ToList(); + } + + /// + /// 获取所有 Application 实体。 + /// + /// 元数据根对象。 + /// Application 实体列表。 + public static IReadOnlyList Applications(this Metadata root) + { + return root.GetAllElements("Application") + .Select(ToApplication) + .ToList(); + } + + /// + /// 获取所有 Markdown 资源。 + /// + /// 元数据根对象。 + /// Markdown 资源列表。 + public static IReadOnlyList Markdowns(this Metadata root) + { + return root.GetAllElements("Markdown") + .Select(node => new MarkdownEntry( + Id: node.Get("ID") ?? string.Empty, + Source: node.Get("Source"), + Hash: node.Get("Hash"), + Raw: node)) + .ToList(); + } + + /// + /// 获取所有图片资源。 + /// + /// 元数据根对象。 + /// 图片资源列表。 + public static IReadOnlyList Images(this Metadata root) + { + return root.GetAllElements("Image") + .Select(node => new ImageEntry( + Id: node.Get("ID") ?? string.Empty, + Source: node.Get("Source"), + Url: node.Get("Url"), + Raw: node)) + .ToList(); + } + + /// + /// 将 Mod 节点映射为 。 + /// + private static ModEntry ToMod(Metadata node) + { + return new ModEntry( + Id: node.Get("ID") ?? string.Empty, + Version: node.Find("CurrentVersion")?.Value, + Icon: node.Find("Icon")?.Value, + Packages: ReadPackages(node), + Raw: node); + } + + /// + /// 将 Application 节点映射为 。 + /// + private static ApplicationEntry ToApplication(Metadata node) + { + return new ApplicationEntry( + Id: node.Get("ID") ?? string.Empty, + Version: node.Find("Version")?.Value, + Packages: ReadPackages(node), + Raw: node); + } + + /// + /// 读取一个业务实体下的全部版本包。 + /// + private static IReadOnlyList ReadPackages(Metadata node) + { + var packagesNode = node.Find("Packages"); + if (packagesNode == null) + { + return []; + } + + return packagesNode.Children + .Where(c => c.Name == "Package") + .Select(package => new PackageEntry( + Version: package.Get("Version") ?? string.Empty, + ReleaseDate: package.Find("ReleaseDate")?.Value, + ManifestId: package.Find("Manifest")?.Value, + Raw: package)) + .ToList(); + } +} diff --git a/Ra3.BattleNet.Metadata/MetadataQueryModels.cs b/Ra3.BattleNet.Metadata/MetadataQueryModels.cs new file mode 100644 index 0000000..cba0005 --- /dev/null +++ b/Ra3.BattleNet.Metadata/MetadataQueryModels.cs @@ -0,0 +1,94 @@ +namespace Ra3.BattleNet.Metadata; + +/// +/// 面向业务使用的查询入口(类似“创意工坊”浏览视图)。 +/// +public sealed class MetadataCatalog +{ + private readonly Metadata _root; + + /// + /// 基于已加载的元数据根节点构造查询目录。 + /// + /// 元数据根对象。 + public MetadataCatalog(Metadata root) + { + _root = root; + } + + /// + /// 获取全部 Mod 实体。 + /// + public IReadOnlyList Mods => _root.Mods(); + + /// + /// 获取全部 Application 实体。 + /// + public IReadOnlyList Applications => _root.Applications(); + + /// + /// 获取全部 Markdown 资源。 + /// + public IReadOnlyList Markdowns => _root.Markdowns(); + + /// + /// 获取全部图片资源。 + /// + public IReadOnlyList Images => _root.Images(); + + /// + /// 按 ID 查找 Mod。 + /// + public ModEntry? Mod(string id) => Mods.FirstOrDefault(m => string.Equals(m.Id, id, StringComparison.OrdinalIgnoreCase)); + + /// + /// 按 ID 查找 Application。 + /// + public ApplicationEntry? Application(string id) => Applications.FirstOrDefault(a => string.Equals(a.Id, id, StringComparison.OrdinalIgnoreCase)); +} + +/// +/// Application 业务实体。 +/// +/// 应用 ID。 +/// 应用当前版本(来自 <Version>)。 +/// 版本包列表。 +/// 原始元数据节点。 +public sealed record ApplicationEntry(string Id, string? Version, IReadOnlyList Packages, Metadata Raw); + +/// +/// Mod 业务实体。 +/// +/// Mod ID。 +/// 当前版本(来自 <CurrentVersion>)。 +/// 图标资源 ID。 +/// 版本包列表。 +/// 原始元数据节点。 +public sealed record ModEntry(string Id, string? Version, string? Icon, IReadOnlyList Packages, Metadata Raw); + +/// +/// 版本包实体。 +/// +/// 包版本号(来自 Package@Version)。 +/// 发布日期。 +/// Manifest 资源 ID。 +/// 原始元数据节点。 +public sealed record PackageEntry(string Version, string? ReleaseDate, string? ManifestId, Metadata Raw); + +/// +/// Markdown 资源实体。 +/// +/// 资源 ID。 +/// 源文件路径。 +/// 内容哈希值。 +/// 原始元数据节点。 +public sealed record MarkdownEntry(string Id, string? Source, string? Hash, Metadata Raw); + +/// +/// 图片资源实体。 +/// +/// 资源 ID。 +/// 本地源文件路径。 +/// 远程图片 URL。 +/// 原始元数据节点。 +public sealed record ImageEntry(string Id, string? Source, string? Url, Metadata Raw); diff --git a/Ra3.BattleNet.Metadata/Program.cs b/Ra3.BattleNet.Metadata/Program.cs index d8469ad..6222849 100644 --- a/Ra3.BattleNet.Metadata/Program.cs +++ b/Ra3.BattleNet.Metadata/Program.cs @@ -77,6 +77,21 @@ static void Main(string[] args) Console.WriteLine("=== Include 引用树 ==="); Console.WriteLine(verifiedMetadata.GetIncludeTree()); + Console.WriteLine("=== 业务实体概览 ==="); + var entities = verifiedMetadata.GetBusinessEntities(); + foreach (var entity in entities) + { + var id = string.IsNullOrWhiteSpace(entity.Id) ? "" : entity.Id; + Console.WriteLine($"- [{entity.EntityType}] {id} @ {entity.Path}"); + } + + Console.WriteLine(); + Console.WriteLine("=== 查询API示例(metadata.Mods()) ==="); + foreach (var mod in verifiedMetadata.Mods()) + { + Console.WriteLine($"Mod={mod.Id}, Version={mod.Version}, Packages={mod.Packages.Count}"); + } + Console.WriteLine("处理完成!"); } catch (InvalidOperationException ex) when (ex.Message.Contains("循环引用")) diff --git a/USAGE_EXAMPLE.md b/USAGE_EXAMPLE.md index ebe43bb..eacb8d8 100644 --- a/USAGE_EXAMPLE.md +++ b/USAGE_EXAMPLE.md @@ -20,6 +20,27 @@ var metadata = Metadata.LoadFromFile("./Metadata/metadata.xml"); ## 常见使用场景 + +### 场景0:像你说的那样直接用 `metadata.Mods()` / `mod.Version` + +```csharp +var metadata = Metadata.LoadFromFile("./Metadata/metadata.xml"); + +// 方式A:直接扩展方法 +foreach (var mod in metadata.Mods()) +{ + Console.WriteLine($"{mod.Id} -> {mod.Version}"); +} + +var corona = metadata.Mods().FirstOrDefault(m => m.Id == "Corona"); +Console.WriteLine(corona?.Version); // 3.229 + +// 方式B:Catalog 入口(更像“数据仓库”) +var catalog = metadata.Catalog(); +var app = catalog.Application("RA3BattleNet"); +Console.WriteLine(app?.Version); // 1.5.2.0 +``` + ### 场景1:访问 Corona 模组的 Changelog ```csharp