diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs index d8b0610..ef047b0 100644 --- a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs +++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs @@ -1,5 +1,6 @@ using OpenAC.MosswartMassacre.Backend; using OpenAC.MosswartMassacre.FlagTracker; +using OpenAC.MosswartMassacre.Metas; using OpenAC.MosswartMassacre.Quests; namespace OpenAC.MosswartMassacre; @@ -18,5 +19,6 @@ internal static class FeatureCatalog var quests = new QuestManager(context); features.Add(quests); features.Add(new FlagTrackerFeature(context, quests)); + features.Add(new MetaSyncFeature(context)); } } diff --git a/src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs b/src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs new file mode 100644 index 0000000..0fd528c --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs @@ -0,0 +1,337 @@ +using System.Collections.Concurrent; +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.Metas; + +/// +/// Meta and nav sync from the club's repository: the /mm metasrefresh, +/// metascheck, metaspull and metaspullfile verbs and, on a +/// host with a window, the Metas window. Nothing here reaches the backend. +/// +/// +/// Network work runs on the thread pool; every result, chat line and window +/// update is queued and applied on the next tick. The file list is not tied +/// to a character, so it survives a relog. +/// +internal sealed class MetaSyncFeature : IMmFeature +{ + /// The panel's markup file, beside the plugin assembly. + public const string MarkupFile = "mosswartmassacre-metas.xml"; + + private readonly MmContext _context; + private readonly MetaSyncManager _manager; + private readonly ConcurrentQueue _onTick = new(); + private readonly CancellationTokenSource _shutdown = new(); + private readonly Action _tick; + private readonly IDisposable? _panel; + private Task _lastOperation = Task.CompletedTask; + private bool _disposed; + + public MetaSyncFeature(MmContext context) + : this(context, context.Host.VtankProfiles, handler: null) + { + } + + /// Builds the feature on a given profile storage and HTTP handler; tests pass fakes. + internal MetaSyncFeature(MmContext context, IPluginStorage profiles, HttpMessageHandler? handler) + { + _context = context; + _manager = new MetaSyncManager( + profiles, + handler, + () => RepositoryUrl, + () => _context.Clock.UtcNow, + message => _onTick.Enqueue(() => _context.Chat.Write(message))); + + _tick = _ => Pump(); + context.Host.Events.Tick += _tick; + + context.Commands.Register("metasrefresh", _ => RunRefresh(), "Refresh remote metas list"); + context.Commands.Register("metascheck", _ => RunCheck(), "Check local metas against remote"); + context.Commands.Register("metaspull", _ => RunPullOutdated(), "Download outdated metas/nav files"); + context.Commands.Register("metaspullfile", HandlePullFile, "Download one metas/nav file by relative path"); + + if (!context.Host.HasUi) + return; + + Panel = new MetasViewModel(); + Panel.ShowPath(_manager.StorageRoot); + Panel.ShowFiles([]); + Panel.ButtonPressed += OnButton; + Panel.SearchRequested += ShowFiles; + + string directory = Path.GetDirectoryName(typeof(MetaSyncFeature).Assembly.Location) ?? "."; + _panel = context.Host.Ui.RegisterPanel( + new PluginPanelDescriptor("metas", "Metas") + { + IconText = "ME", + // Shown while the binding's IsVisible is set; the host's own + // shown state starts true so that flag alone opens it. + StartVisible = true, + ShowInSidePanel = true, + }, + Path.Combine(directory, MarkupFile), + Panel); + } + + /// + /// The window's binding object; set its IsVisible to open the + /// window. Null on a host without a window. + /// + public MetasViewModel? Panel { get; } + + internal MetaSyncManager Manager => _manager; + + /// The operation started last; tests await it before pumping the tick. + internal Task LastOperation => _lastOperation; + + /// The repository setting, or the original's repository when it is empty. + private string RepositoryUrl + { + get + { + string? url = _context.Settings.Current.MetasRepositoryUrl; + return string.IsNullOrWhiteSpace(url) ? MetaSyncManager.DefaultRepositoryUrl : url; + } + } + + public void OnLogin() => Panel?.ShowPath(_manager.StorageRoot); + + public void OnLogoff() + { + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _context.Host.Events.Tick -= _tick; + _shutdown.Cancel(); + if (Panel is not null) + { + Panel.ButtonPressed -= OnButton; + Panel.SearchRequested -= ShowFiles; + } + _panel?.Dispose(); + _onTick.Clear(); + // Operations run one after another, so the manager goes once the + // last one, cancelled above, has finished. + _lastOperation.ContinueWith( + _ => { _manager.Dispose(); _shutdown.Dispose(); }, + CancellationToken.None, + TaskContinuationOptions.None, + TaskScheduler.Default); + } + + /// Applies finished work on the tick thread. Internal so tests can pump without a host tick. + internal void Pump() + { + while (_onTick.TryDequeue(out Action? action)) + { + try + { + action(); + } + catch (Exception ex) + { + _context.Host.Log.Error("A metas update failed.", ex); + } + } + } + + private void RunRefresh(Action? done = null, Action? failed = null) => + Start( + token => _manager.RefreshCatalogAsync(token), + count => + { + ShowFiles(); + if (done is null) + _context.Chat.Write($"[Metas] Loaded {count} remote .met/.nav files"); + else + done(count); + }, + ex => + { + if (failed is null) + _context.Chat.Write($"[Metas] Refresh failed: {ex.Message}"); + else + failed(ex); + }); + + private void RunCheck( + Action<(int UpToDate, int NeedsUpdate, int NotDownloaded, int Errors)>? done = null, + Action? failed = null) => + Start( + token => _manager.CheckStatusesAsync(token), + summary => + { + ShowFiles(); + if (done is null) + { + _context.Chat.Write( + $"[Metas] Up-to-date: {summary.UpToDate}, Updates: {summary.NeedsUpdate}, Missing: {summary.NotDownloaded}, Errors: {summary.Errors}"); + } + else + { + done(summary); + } + }, + ex => + { + if (failed is null) + _context.Chat.Write($"[Metas] Check failed: {ex.Message}"); + else + failed(ex); + }); + + private void RunPullOutdated( + Action<(int Downloaded, int Skipped, int Failed, int Backups)>? done = null, + Action? failed = null) => + Start( + token => _manager.DownloadOutdatedAsync(token), + result => + { + ShowFiles(); + if (done is null) + { + _context.Chat.Write( + $"[Metas] Downloaded: {result.Downloaded}, Skipped: {result.Skipped}, Failed: {result.Failed}, Backups: {result.Backups}"); + } + else + { + done(result); + } + }, + ex => + { + if (failed is null) + _context.Chat.Write($"[Metas] Pull failed: {ex.Message}"); + else + failed(ex); + }); + + private void HandlePullFile(string[] args) + { + if (args.Length < 2) + { + _context.Chat.Write("Usage: /mm metaspullfile "); + return; + } + + string relativePath = string.Join(" ", args.Skip(1)); + Start( + token => _manager.DownloadFileAsync(relativePath, token), + result => + { + ShowFiles(); + _context.Chat.Write($"[Metas] {relativePath}: {result.Message}"); + }, + ex => _context.Chat.Write($"[Metas] Pull file failed: {ex.Message}")); + } + + private void OnButton(string button) + { + MetasViewModel? panel = Panel; + if (panel is null) + return; + + switch (button) + { + case nameof(MetasViewModel.RefreshList): + panel.ShowPath(_manager.StorageRoot); + panel.StatusText = "Status: refreshing remote file list..."; + RunRefresh( + count => panel.StatusText = $"Status: loaded {count} remote files", + ex => + { + panel.StatusText = "Status: refresh failed"; + _context.Chat.Write($"[Metas] Refresh failed: {ex.Message}"); + }); + break; + + case nameof(MetasViewModel.CheckUpdates): + panel.ShowPath(_manager.StorageRoot); + panel.StatusText = "Status: checking remote updates..."; + RunCheck( + summary => panel.StatusText = + $"Status: {summary.UpToDate} current, {summary.NeedsUpdate} updates, {summary.NotDownloaded} missing, {summary.Errors} errors", + ex => + { + panel.StatusText = "Status: update check failed"; + _context.Chat.Write($"[Metas] Update check failed: {ex.Message}"); + }); + break; + + case nameof(MetasViewModel.DownloadOutdated): + panel.StatusText = "Status: downloading outdated files..."; + RunPullOutdated( + result => panel.StatusText = + $"Status: downloaded {result.Downloaded}, skipped {result.Skipped}, failed {result.Failed}, backups {result.Backups}", + ex => + { + panel.StatusText = "Status: pull outdated failed"; + _context.Chat.Write($"[Metas] Pull outdated failed: {ex.Message}"); + }); + break; + + case nameof(MetasViewModel.DownloadSelected): + string selection = panel.SelectedRelativePath; + if (string.IsNullOrWhiteSpace(selection)) + { + _context.Chat.Write("[Metas] Select a file first"); + return; + } + panel.StatusText = $"Status: downloading {selection}..."; + Start( + token => _manager.DownloadFileAsync(selection, token), + result => + { + ShowFiles(); + panel.StatusText = $"Status: {result.Message}"; + }, + ex => + { + panel.StatusText = "Status: download failed"; + _context.Chat.Write($"[Metas] Download failed: {ex.Message}"); + }); + break; + } + } + + private void ShowFiles() + { + if (Panel is null) + return; + Panel.ShowFiles(_manager.GetCachedFiles()); + Panel.ShowLastCheck(_manager.LastStatusCheckUtc, _manager.LastCatalogRefreshUtc); + } + + /// Runs on the thread pool and applies its outcome on the next tick. + private void Start(Func> work, Action done, Action failed) + { + if (_disposed) + return; + CancellationToken token = _shutdown.Token; + Task previous = _lastOperation; + // Each operation starts when the one before it has finished, the + // order the player asked for them in. + _lastOperation = Task.Run(async () => + { + await previous.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + try + { + T result = await work(token).ConfigureAwait(false); + _onTick.Enqueue(() => done(result)); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Shutting down: nobody is left to tell. + } + catch (Exception ex) + { + _onTick.Enqueue(() => failed(ex)); + } + }, CancellationToken.None); + } +} diff --git a/src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs b/src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs new file mode 100644 index 0000000..c84a798 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs @@ -0,0 +1,470 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using AcDream.Plugin.Abstractions; +using Newtonsoft.Json.Linq; + +namespace OpenAC.MosswartMassacre.Metas; + +internal enum MetaFileSyncStatus +{ + Unknown, + NotDownloaded, + UpToDate, + UpdateAvailable, + Error, +} + +/// One .met or .nav file of the repository and how the local copy compares. +internal sealed class MetaFileEntry +{ + public string RelativePath { get; set; } = string.Empty; + public string FileName { get; set; } = string.Empty; + public string Extension { get; set; } = string.Empty; + + /// The storage key the file is kept under, or empty when there is no storage. + public string LocalPath { get; set; } = string.Empty; + + public bool LocalExists { get; set; } + public string LocalSha256 { get; set; } = string.Empty; + public string RemoteSha256 { get; set; } = string.Empty; + public MetaFileSyncStatus Status { get; set; } + public string StatusText { get; set; } = string.Empty; + + public MetaFileEntry Clone() => (MetaFileEntry)MemberwiseClone(); +} + +internal sealed class MetaDownloadResult +{ + public string RelativePath { get; set; } = string.Empty; + public bool Downloaded { get; set; } + public bool SkippedAsCurrent { get; set; } + public bool BackupCreated { get; set; } + public string Message { get; set; } = string.Empty; +} + +/// +/// Keeps the shared automation profiles in step with the metas repository on +/// the club's git server: lists its .met and .nav files through +/// the server's API, compares each with the local copy by SHA-256, and +/// downloads the ones that differ, keeping the old copy as .bak. +/// +/// +/// The endpoints are the original's: the tree comes from +/// {server}/api/v1/repos/{owner}/{repo}/git/trees/main?recursive=1 and each +/// file from {repository}/raw/branch/main/{path}. The repository address is a +/// setting that defaults to the original's repository. +/// Files land where the automation plugin reads dropped profiles, flattened +/// to their bare names as the original flattened them into its profile +/// folder: metas under mosstank/metas, navs under mosstank/navs. +/// The shared storage holds text, so both sides are compared as the text a +/// read of the stored file gives back (UTF-8, byte-order mark dropped), +/// hashed as UTF-8; for the plain-text files in the repository that is the +/// same as the original's byte comparison. +/// The methods run off the tick thread: they touch only the network, the +/// shared storage and this object's own list, which is locked. One operation +/// runs at a time; a second waits for the first. Notices for the chat +/// window go through the callback, which the owner marshals to the tick. +/// +internal sealed class MetaSyncManager : IDisposable +{ + public const string DefaultRepositoryUrl = "https://git.snakedesert.se/SawatoMosswartsEnjoyersClub/metas"; + public const string BranchName = "main"; + + /// Where the automation plugin looks for dropped metas. + public const string MetaFolder = "mosstank/metas"; + + /// Where the automation plugin looks for dropped navs. + public const string NavFolder = "mosstank/navs"; + + private readonly IPluginStorage _storage; + private readonly HttpClient _http; + private readonly Func _repositoryUrl; + private readonly Func _utcNow; + private readonly Action _notice; + private readonly object _sync = new(); + private readonly SemaphoreSlim _oneAtATime = new(1, 1); + private List _cachedFiles = []; + + public MetaSyncManager( + IPluginStorage storage, + HttpMessageHandler? handler, + Func repositoryUrl, + Func utcNow, + Action notice) + { + _storage = storage; + _http = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false); + _http.Timeout = TimeSpan.FromSeconds(30); + _repositoryUrl = repositoryUrl; + _utcNow = utcNow; + _notice = notice; + } + + public DateTime LastCatalogRefreshUtc { get; private set; } = DateTime.MinValue; + + public DateTime LastStatusCheckUtc { get; private set; } = DateTime.MinValue; + + /// The storage root the files are written beneath, for the path label; null without storage. + public string? StorageRoot => _storage.IsAvailable ? _storage.RootPath ?? string.Empty : null; + + public IReadOnlyList GetCachedFiles() + { + lock (_sync) + return [.. _cachedFiles.Select(static entry => entry.Clone())]; + } + + /// The API and raw-file addresses for a repository address. + public static (string TreeUrl, string RawBaseUrl) ResolveEndpoints(string repositoryUrl) + { + string address = string.IsNullOrWhiteSpace(repositoryUrl) ? DefaultRepositoryUrl : repositoryUrl.Trim(); + var uri = new Uri(address, UriKind.Absolute); + string server = uri.GetLeftPart(UriPartial.Authority); + string path = uri.AbsolutePath.Trim('/'); + if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + path = path[..^4]; + return ( + $"{server}/api/v1/repos/{path}/git/trees/{BranchName}?recursive=1", + $"{server}/{path}/raw/branch/{BranchName}"); + } + + /// The storage key a repository file is kept under, or null for a file that is neither a meta nor a nav. + public static string? LocalKeyFor(string relativePath) + { + string fileName = Path.GetFileName((relativePath ?? string.Empty).Replace('\\', '/')); + if (fileName.Length == 0) + return null; + if (fileName.EndsWith(".met", StringComparison.OrdinalIgnoreCase)) + return $"{MetaFolder}/{fileName}"; + if (fileName.EndsWith(".nav", StringComparison.OrdinalIgnoreCase)) + return $"{NavFolder}/{fileName}"; + return null; + } + + public async Task RefreshCatalogAsync(CancellationToken cancellationToken = default) + { + await _oneAtATime.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await RefreshCatalogCoreAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _oneAtATime.Release(); + } + } + + public async Task<(int UpToDate, int NeedsUpdate, int NotDownloaded, int Errors)> CheckStatusesAsync( + CancellationToken cancellationToken = default) + { + await _oneAtATime.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + List workingSet = CloneCache(); + if (workingSet.Count == 0) + { + await RefreshCatalogCoreAsync(cancellationToken).ConfigureAwait(false); + workingSet = CloneCache(); + } + + foreach (MetaFileEntry entry in workingSet) + { + try + { + string? localText = entry.LocalPath.Length == 0 ? null : _storage.ReadText(entry.LocalPath); + if (localText is null) + { + entry.LocalExists = false; + entry.LocalSha256 = string.Empty; + entry.Status = MetaFileSyncStatus.NotDownloaded; + entry.StatusText = "Not downloaded"; + continue; + } + + entry.LocalExists = true; + entry.LocalSha256 = Sha256(localText); + string remoteText = await DownloadRemoteTextAsync(entry.RelativePath, cancellationToken) + .ConfigureAwait(false); + entry.RemoteSha256 = Sha256(remoteText); + + if (string.Equals(entry.LocalSha256, entry.RemoteSha256, StringComparison.OrdinalIgnoreCase)) + { + entry.Status = MetaFileSyncStatus.UpToDate; + entry.StatusText = "Up to date"; + } + else + { + entry.Status = MetaFileSyncStatus.UpdateAvailable; + entry.StatusText = "Update available"; + } + } + catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + entry.Status = MetaFileSyncStatus.Error; + entry.StatusText = "Error"; + _notice($"[Metas] Status check failed for {entry.RelativePath}: {ex.Message}"); + } + } + + lock (_sync) + { + _cachedFiles = workingSet; + LastStatusCheckUtc = _utcNow(); + } + + return ( + workingSet.Count(static entry => entry.Status == MetaFileSyncStatus.UpToDate), + workingSet.Count(static entry => entry.Status == MetaFileSyncStatus.UpdateAvailable), + workingSet.Count(static entry => entry.Status == MetaFileSyncStatus.NotDownloaded), + workingSet.Count(static entry => entry.Status == MetaFileSyncStatus.Error)); + } + finally + { + _oneAtATime.Release(); + } + } + + public async Task DownloadFileAsync(string relativePath, CancellationToken cancellationToken = default) + { + await _oneAtATime.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await DownloadFileCoreAsync(relativePath, cancellationToken).ConfigureAwait(false); + } + finally + { + _oneAtATime.Release(); + } + } + + public async Task<(int Downloaded, int Skipped, int Failed, int Backups)> DownloadOutdatedAsync( + CancellationToken cancellationToken = default) + { + await _oneAtATime.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + List current = CloneCache(); + if (current.Count == 0) + { + await RefreshCatalogCoreAsync(cancellationToken).ConfigureAwait(false); + current = CloneCache(); + } + + List candidates = [.. current + .Where(static entry => entry.Status is MetaFileSyncStatus.UpdateAvailable + or MetaFileSyncStatus.NotDownloaded + or MetaFileSyncStatus.Unknown) + .OrderBy(static entry => entry.RelativePath, StringComparer.OrdinalIgnoreCase)]; + + int downloaded = 0; + int skipped = 0; + int failed = 0; + int backups = 0; + foreach (MetaFileEntry entry in candidates) + { + try + { + MetaDownloadResult result = await DownloadFileCoreAsync(entry.RelativePath, cancellationToken) + .ConfigureAwait(false); + if (result.Downloaded) + downloaded++; + if (result.SkippedAsCurrent) + skipped++; + if (result.BackupCreated) + backups++; + } + catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + failed++; + _notice($"[Metas] Download failed for {entry.RelativePath}: {ex.Message}"); + UpdateSingleCachedEntry( + entry.RelativePath, entry.LocalPath, entry.LocalSha256, entry.RemoteSha256, + MetaFileSyncStatus.Error, "Error"); + } + } + + return (downloaded, skipped, failed, backups); + } + finally + { + _oneAtATime.Release(); + } + } + + public void Dispose() + { + _http.Dispose(); + _oneAtATime.Dispose(); + } + + private async Task RefreshCatalogCoreAsync(CancellationToken cancellationToken) + { + List remoteEntries = await FetchRemoteTreeEntriesAsync(cancellationToken).ConfigureAwait(false); + HashSet localKeys = ListLocalKeys(); + + List result = [.. remoteEntries + .Where(static path => path.EndsWith(".met", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".nav", StringComparison.OrdinalIgnoreCase)) + .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase) + .Select(path => + { + string localPath = _storage.IsAvailable ? LocalKeyFor(path) ?? string.Empty : string.Empty; + bool exists = localPath.Length > 0 && localKeys.Contains(localPath); + return new MetaFileEntry + { + RelativePath = path, + FileName = Path.GetFileName(path), + Extension = Path.GetExtension(path).TrimStart('.').ToLowerInvariant(), + LocalPath = localPath, + LocalExists = exists, + Status = exists ? MetaFileSyncStatus.Unknown : MetaFileSyncStatus.NotDownloaded, + StatusText = exists ? "Unknown" : "Not downloaded", + }; + })]; + + lock (_sync) + { + _cachedFiles = result; + LastCatalogRefreshUtc = _utcNow(); + } + return result.Count; + } + + private async Task DownloadFileCoreAsync(string relativePath, CancellationToken cancellationToken) + { + var result = new MetaDownloadResult { RelativePath = relativePath }; + + if (string.IsNullOrWhiteSpace(relativePath)) + { + result.Message = "No file selected."; + return result; + } + + if (!_storage.IsAvailable) + { + result.Message = "VTank path could not be resolved."; + return result; + } + + // The original dropped any file into the profile folder; only the + // two kinds the automation plugin reads have a folder here. + string? localPath = LocalKeyFor(relativePath); + if (localPath is null) + { + result.Message = "Only .met and .nav files can be pulled."; + return result; + } + + string remoteText = await DownloadRemoteTextAsync(relativePath, cancellationToken).ConfigureAwait(false); + string remoteHash = Sha256(remoteText); + + string? localText = _storage.ReadText(localPath); + if (localText is not null) + { + string localHash = Sha256(localText); + if (string.Equals(localHash, remoteHash, StringComparison.OrdinalIgnoreCase)) + { + result.SkippedAsCurrent = true; + result.Message = "Already up to date."; + UpdateSingleCachedEntry(relativePath, localPath, localHash, remoteHash, MetaFileSyncStatus.UpToDate, "Up to date"); + return result; + } + + _storage.WriteText(localPath + ".bak", localText); + result.BackupCreated = true; + } + + _storage.WriteText(localPath, remoteText); + + result.Downloaded = true; + result.Message = result.BackupCreated ? "Downloaded and updated (.bak created)." : "Downloaded."; + UpdateSingleCachedEntry(relativePath, localPath, remoteHash, remoteHash, MetaFileSyncStatus.UpToDate, "Up to date"); + return result; + } + + private async Task> FetchRemoteTreeEntriesAsync(CancellationToken cancellationToken) + { + (string treeUrl, _) = ResolveEndpoints(_repositoryUrl()); + using HttpResponseMessage response = await _http.GetAsync(treeUrl, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + string json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (JObject.Parse(json)["tree"] is not JArray tree) + return []; + + return [.. tree + .Where(static node => string.Equals(node["type"]?.ToString(), "blob", StringComparison.OrdinalIgnoreCase)) + .Select(static node => node["path"]?.ToString()) + .Where(static path => !string.IsNullOrWhiteSpace(path)) + .Select(static path => path!)]; + } + + private async Task DownloadRemoteTextAsync(string relativePath, CancellationToken cancellationToken) + { + (_, string rawBaseUrl) = ResolveEndpoints(_repositoryUrl()); + string escapedPath = string.Join( + "/", + (relativePath ?? string.Empty) + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .Select(Uri.EscapeDataString)); + byte[] bytes = await _http.GetByteArrayAsync($"{rawBaseUrl}/{escapedPath}", cancellationToken) + .ConfigureAwait(false); + return DecodeAsStored(bytes); + } + + /// + /// The text a read of this file from storage would give back: UTF-8, + /// with a leading byte-order mark taken as the encoding it names and + /// dropped, as the host's own text read does. + /// + internal static string DecodeAsStored(byte[] bytes) + { + using var reader = new StreamReader(new MemoryStream(bytes), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + return reader.ReadToEnd(); + } + + internal static string Sha256(string text) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLower(CultureInfo.InvariantCulture); + + private HashSet ListLocalKeys() + { + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + if (!_storage.IsAvailable) + return keys; + foreach (string key in _storage.List(MetaFolder)) + keys.Add(key); + foreach (string key in _storage.List(NavFolder)) + keys.Add(key); + return keys; + } + + private List CloneCache() + { + lock (_sync) + return [.. _cachedFiles.Select(static entry => entry.Clone())]; + } + + private void UpdateSingleCachedEntry( + string relativePath, + string localPath, + string localHash, + string remoteHash, + MetaFileSyncStatus status, + string statusText) + { + lock (_sync) + { + MetaFileEntry? target = _cachedFiles.FirstOrDefault(entry => + string.Equals(entry.RelativePath, relativePath, StringComparison.OrdinalIgnoreCase)); + if (target is null) + return; + + target.LocalPath = localPath; + target.LocalExists = status == MetaFileSyncStatus.UpToDate + || (localPath.Length > 0 && _storage.ReadText(localPath) is not null); + target.LocalSha256 = localHash ?? string.Empty; + target.RemoteSha256 = remoteHash ?? string.Empty; + target.Status = status; + target.StatusText = statusText ?? "Unknown"; + } + } +} diff --git a/src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs b/src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs new file mode 100644 index 0000000..a72beea --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs @@ -0,0 +1,172 @@ +using System.Globalization; + +namespace OpenAC.MosswartMassacre.Metas; + +/// +/// The binding object of the Metas window (mosswartmassacre-metas.xml), +/// the original's Metas tab: the repository's files with their status, a +/// live search, a pick list for one file, and the refresh, check and +/// download buttons. Assigned on the tick thread only. +/// +internal sealed class MetasViewModel +{ + private readonly Dictionary _pathByLabel = new(StringComparer.Ordinal); + + public MetasViewModel() + { + IgnoreRow = _ => { }; + SearchChanged = text => + { + SearchText = text ?? string.Empty; + SearchRequested?.Invoke(); + }; + ClearSearch = () => + { + SearchText = string.Empty; + SearchRequested?.Invoke(); + }; + SelectionChanged = label => SelectedLabel = label ?? string.Empty; + RefreshList = () => ButtonPressed?.Invoke(nameof(RefreshList)); + CheckUpdates = () => ButtonPressed?.Invoke(nameof(CheckUpdates)); + DownloadOutdated = () => ButtonPressed?.Invoke(nameof(DownloadOutdated)); + DownloadSelected = () => ButtonPressed?.Invoke(nameof(DownloadSelected)); + } + + /// A button was pressed; the argument is its binding name. + internal event Action? ButtonPressed; + + /// The search text changed or was cleared. + internal event Action? SearchRequested; + + /// + /// Whether the window is open. The root panel's visibility is bound to it, + /// so the main window opens this one by setting it; it starts closed. + /// + public bool IsVisible { get; set; } + + public string PathText { get; set; } = "VTank Path: unresolved"; + + public string LastCheckText { get; set; } = "Last Check: never"; + + public string StatusText { get; set; } = "Status: idle"; + + public string SearchText { get; set; } = string.Empty; + + public Action SearchChanged { get; } + + public Action ClearSearch { get; } + + public Action RefreshList { get; } + + public Action CheckUpdates { get; } + + public Action DownloadOutdated { get; } + + public Action DownloadSelected { get; } + + public int NoRow => -1; + + public Action IgnoreRow { get; } + + public IReadOnlyList FileTypes { get; private set; } = []; + + public IReadOnlyList FilePaths { get; private set; } = []; + + public IReadOnlyList FileStatus { get; private set; } = []; + + public IReadOnlyList FileStatusColors { get; private set; } = []; + + public IReadOnlyList SelectionItems { get; private set; } = []; + + public string SelectedLabel { get; set; } = string.Empty; + + public Action SelectionChanged { get; } + + /// The repository path of the file picked in the list, or empty. + public string SelectedRelativePath => + _pathByLabel.TryGetValue(SelectedLabel, out string? path) ? path : string.Empty; + + /// + /// Redraws the list and the pick list from the manager's files, filtered + /// by the search text on path or name. The list puts the files that need + /// attention first: missing, then outdated, then failed, then current. + /// + public void ShowFiles(IReadOnlyList all) + { + IReadOnlyList files = ApplySearch(all); + + List ordered = [.. files + .OrderBy(static file => file.Status switch + { + MetaFileSyncStatus.NotDownloaded => 0, + MetaFileSyncStatus.UpdateAvailable => 1, + MetaFileSyncStatus.Error => 2, + _ => 3, + }) + .ThenBy(static file => file.RelativePath, StringComparer.OrdinalIgnoreCase)]; + + if (ordered.Count == 0) + { + bool searching = !string.IsNullOrWhiteSpace(SearchText); + FileTypes = ["-"]; + FilePaths = [searching ? "No matches" : "No files loaded"]; + FileStatus = [searching ? "Try a different filter" : "Click Refresh List"]; + FileStatusColors = [0xFFFFFFu]; + } + else + { + FileTypes = [.. ordered.Select(static file => file.Extension.ToUpperInvariant())]; + FilePaths = [.. ordered.Select(static file => file.RelativePath)]; + FileStatus = [.. ordered.Select(static file => file.StatusText)]; + FileStatusColors = [.. ordered.Select(static file => StatusColor(file.Status))]; + } + + string previous = SelectedRelativePath; + _pathByLabel.Clear(); + var labels = new List(); + foreach (MetaFileEntry file in files.OrderBy(static file => file.RelativePath, StringComparer.OrdinalIgnoreCase)) + { + string label = $"[{file.Extension}] {file.RelativePath}"; + labels.Add(label); + _pathByLabel[label] = file.RelativePath; + } + SelectionItems = labels; + SelectedLabel = labels.FirstOrDefault(label => + previous.Length > 0 && _pathByLabel[label].Equals(previous, StringComparison.OrdinalIgnoreCase)) + ?? labels.FirstOrDefault() + ?? string.Empty; + } + + public void ShowLastCheck(DateTime lastStatusCheckUtc, DateTime lastCatalogRefreshUtc) + { + DateTime stamp = lastStatusCheckUtc != DateTime.MinValue ? lastStatusCheckUtc : lastCatalogRefreshUtc; + LastCheckText = stamp == DateTime.MinValue + ? "Last Check: never" + : string.Create( + CultureInfo.InvariantCulture, + $"Last Check: {DateTime.SpecifyKind(stamp, DateTimeKind.Utc).ToLocalTime():yyyy-MM-dd HH:mm:ss}"); + } + + public void ShowPath(string? storageRoot) => + PathText = storageRoot is null ? "VTank Path: unresolved" : $"VTank Path: {storageRoot}"; + + /// The status text colour: green current, orange outdated, blue missing, red failed. + public static uint StatusColor(MetaFileSyncStatus status) => status switch + { + MetaFileSyncStatus.UpToDate => 0x008000u, + MetaFileSyncStatus.UpdateAvailable => 0xB46E00u, + MetaFileSyncStatus.NotDownloaded => 0x0046A0u, + MetaFileSyncStatus.Error => 0xB40000u, + _ => 0xFFFFFFu, + }; + + private IReadOnlyList ApplySearch(IReadOnlyList files) + { + string filter = SearchText.Trim(); + if (filter.Length == 0) + return files; + return [.. files.Where(file => + file.RelativePath.Contains(filter, StringComparison.OrdinalIgnoreCase) + || file.FileName.Contains(filter, StringComparison.OrdinalIgnoreCase))]; + } +} diff --git a/src/OpenAC.MosswartMassacre/mosswartmassacre-metas.xml b/src/OpenAC.MosswartMassacre/mosswartmassacre-metas.xml new file mode 100644 index 0000000..8acbffd --- /dev/null +++ b/src/OpenAC.MosswartMassacre/mosswartmassacre-metas.xml @@ -0,0 +1,35 @@ + + + + +