HashCalculator.GUI/ModXml.cs

321 lines
12 KiB
C#

using Microsoft.Win32;
using Mvp.Xml.XInclude;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Threading.Tasks.Dataflow;
using System.Xml;
using System.Xml.Linq;
namespace HashCalculator.GUI
{
public class ModXml : IModXml
{
protected enum DocumentOption
{
Normal,
IsEntryPoint
}
const string RegistryPath = @"Microsoft\Windows\CurrentVersion\Uninstall\{F6A3F605-7B10-4939-8D3D-4594332C1649}";
public static readonly IEnumerable<string> NotSupportedAttributes = new[]
{
"fragid", "set-xml-id", "encoding", "accept", "accept-language"
};
public static XNamespace XInclude { get; } = "http://www.w3.org/2001/XInclude";
public static XNamespace EalaAsset { get; } = "uri:ea.com:eala:asset";
public DirectoryInfo BaseDirectory { get; }
public int TotalFilesProcessed => _processed.Count;
public int TotalAssets => _assets.Count;
private readonly ConcurrentDictionary<string, byte> _processed = new ConcurrentDictionary<string, byte>();
private readonly ConcurrentDictionary<AssetEntry, string> _assets = new ConcurrentDictionary<AssetEntry, string>();
private readonly ModUriResolver _uriResolver;
private readonly string _xmlFullPath;
private readonly CancellationToken _token;
private readonly BufferBlock<AssetEntry> _entries;
public ModXml(string xmlPath, string? sdkRootPath, CancellationToken token)
{
BaseDirectory = new DirectoryInfo(FindBaseDirectory(xmlPath));
var allMods = BaseDirectory.Parent
?? throw new ArgumentException($"{nameof(xmlPath)}'s {nameof(BaseDirectory)} doesn't have a parent");
if (!allMods.Name.Equals("Mods", StringComparison.OrdinalIgnoreCase))
{
throw new DirectoryNotFoundException();
}
var allModsParent = allMods.Parent
?? throw new ArgumentException($"SDK Mods folder doesn't have a parent");
sdkRootPath ??= allModsParent.FullName;
IReadOnlyCollection<string> GetPaths(string name) => new string[]
{
sdkRootPath,
allModsParent.FullName,
Path.Combine(BaseDirectory.FullName, name),
Path.Combine(sdkRootPath, "Mods"),
allMods.FullName,
Path.Combine(sdkRootPath, name.Equals("Data", StringComparison.OrdinalIgnoreCase) ? "SageXml" : name)
};
_uriResolver = new ModUriResolver(new Dictionary<string, IReadOnlyCollection<string>>(StringComparer.OrdinalIgnoreCase)
{
{ "Art", GetPaths("Art") },
{ "Audio", GetPaths("Audio") },
{ "Data", GetPaths("Data") },
});
_xmlFullPath = Path.GetFullPath(xmlPath);
_token = token;
_entries = new BufferBlock<AssetEntry>();
}
public static string? LocateSdkFromRegistry()
{
using var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32);
return hklm.GetValue(RegistryPath) as string;
}
public async IAsyncEnumerable<AssetEntry> ProcessDocument()
{
if (_processed.Any() || _assets.Any())
{
throw new InvalidOperationException();
}
var task = Task.Run(async () =>
{
try
{
await ProcessDocumentInternal(_xmlFullPath);
}
catch
{
if (!_token.IsCancellationRequested)
{
throw;
}
}
finally
{
_entries.Complete();
}
});
while (await _entries.OutputAvailableAsync())
{
yield return _entries.Receive();
}
await task;
}
private async Task ProcessDocumentInternal(string fullPath)
{
_token.ThrowIfCancellationRequested();
if (!_processed.TryAdd(fullPath.ToUpperInvariant(), default))
{
return;
}
var document = await GetFileAsync(fullPath);
var rootName = document.Root?.Name
?? throw new InvalidDataException("Document doesn't have a root");
if (rootName != EalaAsset + "AssetDeclaration" && rootName != "AssetDeclaration")
{
throw new NotSupportedException();
}
var includes = from include in document.Root.Elements(EalaAsset + "Includes").Elements()
let includePath = GetIncludePath(include, fullPath)
where includePath != null
select Task.Run(() => ProcessDocumentInternal(includePath), _token);
var includesTasks = includes.ToArray();
var items = from element in document.Root.Elements()
where element.Attribute("id") != null
let entry = AssetEntry.TryParse(element)
where entry != null
select entry;
foreach (var item in items)
{
if (_assets.TryAdd(item, fullPath))
{
_entries.Post(item);
}
else
{
var previousPath = _assets[item];
if(!fullPath.Contains("SageXml"))
{
TracerListener.WriteLine($"[ModXml] `{fullPath}`: Attempted to add item `{item.Type}:{item.Name}` multiple times - already processed in `{previousPath}`");
}
}
}
await Task.WhenAll(includesTasks);
}
private string? GetIncludePath(XElement include, string includerPath)
{
if (include.Name != EalaAsset + "Include")
{
throw new InvalidDataException();
}
if (include.Attribute("type")?.Value == "reference")
{
return null;
}
var source = include.Attribute("source")?.Value;
if (source == null)
{
throw new InvalidDataException();
}
string? includedSource;
try
{
includedSource = _uriResolver.ResolveUri(new Uri(includerPath), source).LocalPath;
}
catch (FileNotFoundException error)
{
if (!(error.FileName?.StartsWith("ART:", StringComparison.OrdinalIgnoreCase) is true))
{
TracerListener.WriteLine($"[ModXml]: {error}");
}
return null;
}
if (!File.Exists(includedSource))
{
TracerListener.WriteLine($"[ModXml]: Warning, include path does not exist! It is: {includedSource}");
return null;
}
return includedSource;
}
private async Task<XDocument> GetFileAsync(string normalizedPath)
{
try
{
var xml = await File.ReadAllBytesAsync(normalizedPath, _token);
using var xmlStream = new MemoryStream(xml);
using var reader = new XIncludingReader(normalizedPath, xmlStream, _uriResolver)
{
// I dont know why but looks like it need to be set again
// Otherwise it wont work
XmlResolver = _uriResolver
};
reader.MoveToContent();
return XDocument.Load(reader);
}
catch (Exception e)
{
throw new InvalidDataException($"Failed to process XML file: {normalizedPath} - {e.Message}", e);
}
}
private static string FindBaseDirectory(string currentPath)
{
var file = new FileInfo(currentPath);
var directoryName = file.DirectoryName
?? throw new ArgumentException($"{nameof(currentPath)} doens't have a directory");
if (File.Exists(Path.Combine(directoryName, "Data", "mod.xml")))
{
return directoryName;
}
return FindBaseDirectory(directoryName);
}
}
public class ModUriResolver : XmlResolver
{
protected IReadOnlyDictionary<string, IReadOnlyCollection<string>> Paths { get; }
public ModUriResolver(IReadOnlyDictionary<string, IReadOnlyCollection<string>> paths)
{
Paths = paths;
}
public override object GetEntity(Uri? absoluteUri, string? role, Type? ofObjectToReturn)
{
if (absoluteUri == null)
{
throw new ArgumentNullException(nameof(absoluteUri));
}
if (ofObjectToReturn != null && ofObjectToReturn != typeof(Stream))
{
throw new NotImplementedException();
}
return File.OpenRead(absoluteUri.LocalPath);
}
public override Uri ResolveUri(Uri? baseUri, string? relativeUri)
{
if (baseUri == null)
{
if (!Path.IsPathRooted(relativeUri))
{
throw new ArgumentException($"{nameof(baseUri)} is null and {nameof(relativeUri)} is not rooted");
}
return new Uri(relativeUri);
}
if (relativeUri == null)
{
throw new ArgumentNullException(nameof(relativeUri));
}
var localPath = Path.GetDirectoryName(baseUri.LocalPath)
?? throw new ArgumentException($"{nameof(baseUri)} doesn't have a local path");
return new Uri(Path.GetFullPath(ResolvePath(relativeUri, localPath)));
}
private string ResolvePath(string href, string currentDirectory)
{
var splitted = href.Split(new[] { ':' }, 2);
if (splitted.Length > 1)
{
var prefix = splitted[0];
var path = splitted[1];
if (Paths.TryGetValue(prefix, out var collection))
{
foreach (var directory in collection)
{
if (prefix.Equals("Art", StringComparison.OrdinalIgnoreCase))
{
var artPath = Path.Combine(directory, path.Substring(0, 2), path);
if (File.Exists(artPath))
{
return artPath;
}
}
var realPath = Path.Combine(directory, path);
if (File.Exists(realPath))
{
return realPath;
}
}
throw new FileNotFoundException("Cannot find the file specified", href);
}
throw new FileNotFoundException($"Unknown prefix: {prefix}", href);
}
var relativePath = Path.Combine(currentDirectory, href);
if (!File.Exists(relativePath))
{
throw new FileNotFoundException("Cannot find the file specified", href);
}
return relativePath;
}
}
}