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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/OpenAC.MosswartMassacre.Tests/FlagTracker/FlagTrackerTests.cs b/tests/OpenAC.MosswartMassacre.Tests/FlagTracker/FlagTrackerTests.cs
index 513b766..2f1664c 100644
--- a/tests/OpenAC.MosswartMassacre.Tests/FlagTracker/FlagTrackerTests.cs
+++ b/tests/OpenAC.MosswartMassacre.Tests/FlagTracker/FlagTrackerTests.cs
@@ -52,7 +52,7 @@ public sealed class FlagTrackerTests
{
using var harness = WithUi();
- RegisteredPanel panel = Assert.Single(harness.Host.Ui.Panels);
+ RegisteredPanel panel = Assert.Single(harness.Host.Ui.Panels, registered => registered.Descriptor.WindowId == "flagtracker");
Assert.Equal("flagtracker", panel.Descriptor.WindowId);
Assert.Equal("Mossy Tracker", panel.Descriptor.Title);
Assert.True(panel.Descriptor.ShowInSidePanel);
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Metas/MetaSyncTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Metas/MetaSyncTests.cs
new file mode 100644
index 0000000..6de3e3b
--- /dev/null
+++ b/tests/OpenAC.MosswartMassacre.Tests/Metas/MetaSyncTests.cs
@@ -0,0 +1,361 @@
+using System.Net;
+using System.Text;
+using OpenAC.MosswartMassacre.Metas;
+using OpenAC.MosswartMassacre.Tests.Fakes;
+
+namespace OpenAC.MosswartMassacre.Tests.Metas;
+
+/// Answers requests from a table of URLs and records what was asked; no network.
+internal sealed class FakeGitServer : HttpMessageHandler
+{
+ public Dictionary Responses { get; } = new(StringComparer.Ordinal);
+
+ public List Requests { get; } = [];
+
+ public void Text(string url, string body) =>
+ Responses[url] = (HttpStatusCode.OK, Encoding.UTF8.GetBytes(body));
+
+ public void Bytes(string url, byte[] body) => Responses[url] = (HttpStatusCode.OK, body);
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ string url = request.RequestUri!.AbsoluteUri;
+ lock (Requests)
+ Requests.Add(url);
+ HttpResponseMessage response = Responses.TryGetValue(url, out var answer)
+ ? new HttpResponseMessage(answer.Status) { Content = new ByteArrayContent(answer.Body) }
+ : new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("not found") };
+ return Task.FromResult(response);
+ }
+}
+
+public sealed class MetaSyncTests
+{
+ // The original's endpoints, verbatim (MetaSyncManager: ApiBaseUrl + repos/{owner}/{repo}/git/trees/{branch}?recursive=1,
+ // RawBaseUrl + "/" + escaped path).
+ private const string TreeUrl =
+ "https://git.snakedesert.se/api/v1/repos/SawatoMosswartsEnjoyersClub/metas/git/trees/main?recursive=1";
+ private const string RawBase = "https://git.snakedesert.se/SawatoMosswartsEnjoyersClub/metas/raw/branch/main";
+
+ // A Gitea tree answer: blobs and trees, only .met/.nav blobs count.
+ private const string TreeJson = """
+ {"sha":"abc","url":"x","tree":[
+ {"path":"README.md","mode":"100644","type":"blob","size":10,"sha":"1"},
+ {"path":"hunting","mode":"040000","type":"tree","sha":"2"},
+ {"path":"hunting/Colo.met","mode":"100644","type":"blob","size":10,"sha":"3"},
+ {"path":"navs/Colo Loop.nav","mode":"100644","type":"blob","size":10,"sha":"4"},
+ {"path":"x.MET","mode":"100644","type":"blob","size":10,"sha":"5"},
+ {"path":"folder.nav","mode":"040000","type":"tree","sha":"6"}
+ ],"truncated":false,"page":1,"total_count":6}
+ """;
+
+ private const string ColoMet = "1\r\nCondition\r\nNever\r\n";
+ private const string LoopNav = "uTank2 NAV 1.2\r\n4\r\n2\r\n";
+ private const string XMet = "x-meta";
+
+ private static (MetaSyncManager Manager, FakeGitServer Server, MemoryStorage Storage, List Notices) Build(
+ string repositoryUrl = "")
+ {
+ var server = new FakeGitServer();
+ server.Text(TreeUrl, TreeJson);
+ server.Text(RawBase + "/hunting/Colo.met", ColoMet);
+ server.Text(RawBase + "/navs/Colo%20Loop.nav", LoopNav);
+ server.Text(RawBase + "/x.MET", XMet);
+ var storage = new MemoryStorage();
+ var notices = new List();
+ var manager = new MetaSyncManager(
+ storage,
+ server,
+ () => repositoryUrl,
+ () => new DateTime(2026, 9, 25, 8, 0, 0, DateTimeKind.Utc),
+ notice => { lock (notices) notices.Add(notice); });
+ return (manager, server, storage, notices);
+ }
+
+ [Fact]
+ public void The_default_repository_gives_the_original_endpoints()
+ {
+ (string tree, string raw) = MetaSyncManager.ResolveEndpoints(string.Empty);
+ Assert.Equal(TreeUrl, tree);
+ Assert.Equal(RawBase, raw);
+
+ (tree, raw) = MetaSyncManager.ResolveEndpoints(MetaSyncManager.DefaultRepositoryUrl);
+ Assert.Equal(TreeUrl, tree);
+ Assert.Equal(RawBase, raw);
+
+ (tree, raw) = MetaSyncManager.ResolveEndpoints("https://git.example.org/me/metas.git/");
+ Assert.Equal("https://git.example.org/api/v1/repos/me/metas/git/trees/main?recursive=1", tree);
+ Assert.Equal("https://git.example.org/me/metas/raw/branch/main", raw);
+ }
+
+ [Fact]
+ public void Files_land_where_the_automation_plugin_reads_dropped_profiles()
+ {
+ Assert.Equal("mosstank/metas/Colo.met", MetaSyncManager.LocalKeyFor("hunting/Colo.met"));
+ Assert.Equal("mosstank/navs/Colo Loop.nav", MetaSyncManager.LocalKeyFor("navs/Colo Loop.nav"));
+ Assert.Equal("mosstank/metas/x.MET", MetaSyncManager.LocalKeyFor("x.MET"));
+ Assert.Null(MetaSyncManager.LocalKeyFor("README.md"));
+ }
+
+ [Fact]
+ public async Task Refresh_lists_the_met_and_nav_blobs_and_what_is_already_here()
+ {
+ var (manager, server, storage, _) = Build();
+ storage.Seed("mosstank/metas/Colo.met", "old");
+
+ int count = await manager.RefreshCatalogAsync();
+
+ Assert.Equal(3, count);
+ Assert.Equal([TreeUrl], server.Requests);
+ IReadOnlyList files = manager.GetCachedFiles();
+ Assert.Equal(["hunting/Colo.met", "navs/Colo Loop.nav", "x.MET"], files.Select(file => file.RelativePath));
+ Assert.Equal(["met", "nav", "met"], files.Select(file => file.Extension));
+ Assert.Equal(MetaFileSyncStatus.Unknown, files[0].Status);
+ Assert.Equal("Unknown", files[0].StatusText);
+ Assert.Equal(MetaFileSyncStatus.NotDownloaded, files[1].Status);
+ Assert.Equal("Not downloaded", files[1].StatusText);
+ Assert.Equal("mosstank/navs/Colo Loop.nav", files[1].LocalPath);
+ Assert.Equal(new DateTime(2026, 9, 25, 8, 0, 0, DateTimeKind.Utc), manager.LastCatalogRefreshUtc);
+ }
+
+ [Fact]
+ public async Task Check_compares_the_stored_text_with_the_remote_file()
+ {
+ var (manager, server, storage, notices) = Build();
+ storage.Seed("mosstank/metas/Colo.met", ColoMet);
+ storage.Seed("mosstank/navs/Colo Loop.nav", "an older route");
+ storage.Seed("mosstank/metas/x.MET", XMet);
+ server.Responses.Remove(RawBase + "/x.MET");
+
+ var summary = await manager.CheckStatusesAsync();
+
+ Assert.Equal((1, 1, 0, 1), summary);
+ IReadOnlyList files = manager.GetCachedFiles();
+ Assert.Equal(
+ ["Up to date", "Update available", "Error"],
+ files.Select(file => file.StatusText));
+ Assert.Contains(notices, notice => notice.StartsWith(
+ "[Metas] Status check failed for x.MET: ", StringComparison.Ordinal));
+ Assert.Contains(RawBase + "/navs/Colo%20Loop.nav", server.Requests);
+ }
+
+ [Fact]
+ public async Task Pull_downloads_missing_and_outdated_files_and_backs_up_the_old_copy()
+ {
+ var (manager, _, storage, _) = Build();
+ storage.Seed("mosstank/metas/Colo.met", ColoMet);
+ storage.Seed("mosstank/navs/Colo Loop.nav", "an older route");
+
+ var result = await manager.DownloadOutdatedAsync();
+
+ Assert.Equal((2, 1, 0, 1), result);
+ Assert.Equal(LoopNav, storage.ReadText("mosstank/navs/Colo Loop.nav"));
+ Assert.Equal("an older route", storage.ReadText("mosstank/navs/Colo Loop.nav.bak"));
+ Assert.Equal(XMet, storage.ReadText("mosstank/metas/x.MET"));
+ Assert.Null(storage.ReadText("mosstank/metas/Colo.met.bak"));
+ Assert.All(manager.GetCachedFiles(), file => Assert.Equal(MetaFileSyncStatus.UpToDate, file.Status));
+
+ // Nothing left to do the second time.
+ Assert.Equal((0, 0, 0, 0), await manager.DownloadOutdatedAsync());
+ }
+
+ [Fact]
+ public async Task A_failed_download_is_counted_and_marked()
+ {
+ var (manager, server, _, notices) = Build();
+ server.Responses.Remove(RawBase + "/x.MET");
+ await manager.RefreshCatalogAsync();
+
+ var result = await manager.DownloadOutdatedAsync();
+
+ Assert.Equal((2, 0, 1, 0), result);
+ Assert.Equal(MetaFileSyncStatus.Error, manager.GetCachedFiles().Single(file => file.RelativePath == "x.MET").Status);
+ Assert.Contains(notices, notice => notice.StartsWith("[Metas] Download failed for x.MET: ", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task A_byte_order_mark_is_dropped_so_the_stored_copy_compares_equal()
+ {
+ var (manager, server, storage, _) = Build();
+ server.Bytes(RawBase + "/hunting/Colo.met", [0xEF, 0xBB, 0xBF, .. Encoding.UTF8.GetBytes(ColoMet)]);
+ await manager.RefreshCatalogAsync();
+
+ MetaDownloadResult first = await manager.DownloadFileAsync("hunting/Colo.met");
+ MetaDownloadResult second = await manager.DownloadFileAsync("hunting/Colo.met");
+
+ Assert.Equal("Downloaded.", first.Message);
+ Assert.Equal(ColoMet, storage.ReadText("mosstank/metas/Colo.met"));
+ Assert.True(second.SkippedAsCurrent);
+ Assert.Equal("Already up to date.", second.Message);
+ Assert.Equal((1, 0, 2, 0), await manager.CheckStatusesAsync());
+ }
+
+ [Fact]
+ public async Task Pulling_one_file_reports_like_the_original()
+ {
+ var (manager, _, storage, _) = Build();
+ storage.Seed("mosstank/metas/Colo.met", "old");
+
+ Assert.Equal("No file selected.", (await manager.DownloadFileAsync(" ")).Message);
+ Assert.Equal("Only .met and .nav files can be pulled.", (await manager.DownloadFileAsync("README.md")).Message);
+ Assert.Equal("Downloaded and updated (.bak created).", (await manager.DownloadFileAsync("hunting/Colo.met")).Message);
+
+ storage.IsAvailable = false;
+ Assert.Equal("VTank path could not be resolved.", (await manager.DownloadFileAsync("hunting/Colo.met")).Message);
+ }
+
+ [Fact]
+ public async Task The_repository_setting_is_honoured()
+ {
+ var (manager, server, _, _) = Build("https://git.example.org/me/metas");
+ server.Text("https://git.example.org/api/v1/repos/me/metas/git/trees/main?recursive=1", TreeJson);
+
+ Assert.Equal(3, await manager.RefreshCatalogAsync());
+ Assert.Equal(["https://git.example.org/api/v1/repos/me/metas/git/trees/main?recursive=1"], server.Requests);
+ }
+
+ // ---- through the /mm verbs and the window ------------------------------
+
+ private static (PluginHarness Harness, MetaSyncFeature Feature, FakeGitServer Server, MemoryStorage Storage) BuildFeature(
+ bool hasUi = false)
+ {
+ var harness = new PluginHarness(arrange: host => host.HasUi = hasUi);
+ harness.Login();
+ var server = new FakeGitServer();
+ server.Text(TreeUrl, TreeJson);
+ server.Text(RawBase + "/hunting/Colo.met", ColoMet);
+ server.Text(RawBase + "/navs/Colo%20Loop.nav", LoopNav);
+ server.Text(RawBase + "/x.MET", XMet);
+ var storage = new MemoryStorage { RootPath = "C:/profiles" };
+ var feature = new MetaSyncFeature(harness.Context, storage, server);
+ return (harness, feature, server, storage);
+ }
+
+ private static async Task Settle(MetaSyncFeature feature)
+ {
+ await feature.LastOperation;
+ feature.Pump();
+ }
+
+ [Fact]
+ public async Task The_mm_verbs_write_the_original_chat_lines_on_the_tick()
+ {
+ var (harness, feature, server, storage) = BuildFeature();
+ using (harness)
+ using (feature)
+ {
+ storage.Seed("mosstank/metas/Colo.met", ColoMet);
+
+ harness.Context.Commands.Dispatch("metasrefresh");
+ await Settle(feature);
+ harness.Context.Commands.Dispatch("metascheck");
+ await Settle(feature);
+ harness.Context.Commands.Dispatch("metaspull");
+ await Settle(feature);
+ harness.Context.Commands.Dispatch("metaspullfile navs/Colo Loop.nav");
+ await Settle(feature);
+ harness.Context.Commands.Dispatch("metaspullfile");
+ server.Responses.Remove(TreeUrl);
+ harness.Context.Commands.Dispatch("metasrefresh");
+ await Settle(feature);
+
+ Assert.Equal(
+ [
+ "[Mosswart Massacre] [Metas] Loaded 3 remote .met/.nav files",
+ "[Mosswart Massacre] [Metas] Up-to-date: 1, Updates: 0, Missing: 2, Errors: 0",
+ "[Mosswart Massacre] [Metas] Downloaded: 2, Skipped: 0, Failed: 0, Backups: 0",
+ "[Mosswart Massacre] [Metas] navs/Colo Loop.nav: Already up to date.",
+ "[Mosswart Massacre] Usage: /mm metaspullfile ",
+ "[Mosswart Massacre] [Metas] Refresh failed: Response status code does not indicate success: 404 (Not Found).",
+ ],
+ harness.ChatLines.Where(line => line.Contains("[Metas]", StringComparison.Ordinal)
+ || line.Contains("metaspullfile", StringComparison.Ordinal)));
+ }
+ }
+
+ [Fact]
+ public async Task Nothing_reaches_chat_before_the_tick_applies_it()
+ {
+ var (harness, feature, _, _) = BuildFeature();
+ using (harness)
+ using (feature)
+ {
+ harness.Context.Commands.Dispatch("metasrefresh");
+ await feature.LastOperation;
+ Assert.DoesNotContain(harness.ChatLines, line => line.Contains("[Metas]", StringComparison.Ordinal));
+
+ harness.Tick(0.01);
+ Assert.Contains("[Mosswart Massacre] [Metas] Loaded 3 remote .met/.nav files", harness.ChatLines);
+ }
+ }
+
+ [Fact]
+ public void A_headless_host_gets_the_verbs_but_no_window()
+ {
+ var (harness, feature, _, _) = BuildFeature();
+ using (harness)
+ using (feature)
+ {
+ Assert.Null(feature.Panel);
+ Assert.Null(harness.Feature().Panel);
+ Assert.Empty(harness.Host.Ui.Panels);
+ }
+ }
+
+ [Fact]
+ public async Task The_window_lists_searches_and_downloads_the_picked_file()
+ {
+ var (harness, feature, _, storage) = BuildFeature(hasUi: true);
+ using (harness)
+ using (feature)
+ {
+ MetasViewModel panel = feature.Panel!;
+ Assert.False(panel.IsVisible);
+ Assert.Equal("VTank Path: C:/profiles", panel.PathText);
+ Assert.Equal(["No files loaded"], panel.FilePaths);
+ storage.Seed("mosstank/metas/x.MET", "stale");
+
+ panel.RefreshList();
+ Assert.Equal("Status: refreshing remote file list...", panel.StatusText);
+ await Settle(feature);
+ Assert.Equal("Status: loaded 3 remote files", panel.StatusText);
+ // Missing first, then the rest by path.
+ Assert.Equal(["hunting/Colo.met", "navs/Colo Loop.nav", "x.MET"], panel.FilePaths);
+ Assert.Equal(["MET", "NAV", "MET"], panel.FileTypes);
+ Assert.Equal(["Not downloaded", "Not downloaded", "Unknown"], panel.FileStatus);
+ Assert.Equal([0x0046A0u, 0x0046A0u, 0xFFFFFFu], panel.FileStatusColors);
+ Assert.Equal(["[met] hunting/Colo.met", "[nav] navs/Colo Loop.nav", "[met] x.MET"], panel.SelectionItems);
+ Assert.Equal("[met] hunting/Colo.met", panel.SelectedLabel);
+ Assert.StartsWith("Last Check: 2026-09-25 ", panel.LastCheckText, StringComparison.Ordinal);
+
+ panel.CheckUpdates();
+ await Settle(feature);
+ Assert.Equal("Status: 0 current, 1 updates, 2 missing, 0 errors", panel.StatusText);
+ Assert.Equal(["Not downloaded", "Not downloaded", "Update available"], panel.FileStatus);
+ Assert.Equal(0xB46E00u, panel.FileStatusColors[2]);
+
+ panel.SearchChanged("loop");
+ Assert.Equal(["navs/Colo Loop.nav"], panel.FilePaths);
+ Assert.Equal("[nav] navs/Colo Loop.nav", panel.SelectedLabel);
+ panel.SearchChanged("zzz");
+ Assert.Equal(["No matches"], panel.FilePaths);
+ Assert.Equal(["Try a different filter"], panel.FileStatus);
+ panel.ClearSearch();
+ Assert.Equal(3, panel.FilePaths.Count);
+
+ panel.SelectionChanged("[met] x.MET");
+ panel.DownloadSelected();
+ Assert.Equal("Status: downloading x.MET...", panel.StatusText);
+ await Settle(feature);
+ Assert.Equal("Status: Downloaded and updated (.bak created).", panel.StatusText);
+ Assert.Equal(XMet, storage.ReadText("mosstank/metas/x.MET"));
+ Assert.Equal("stale", storage.ReadText("mosstank/metas/x.MET.bak"));
+ Assert.Equal("[met] x.MET", panel.SelectedLabel);
+
+ panel.DownloadOutdated();
+ await Settle(feature);
+ Assert.Equal("Status: downloaded 2, skipped 0, failed 0, backups 0", panel.StatusText);
+ Assert.All(panel.FileStatus, status => Assert.Equal("Up to date", status));
+ }
+ }
+}
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Ui/TrackerAndMetasMarkupContractTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Ui/TrackerAndMetasMarkupContractTests.cs
index 52f7949..93c57ea 100644
--- a/tests/OpenAC.MosswartMassacre.Tests/Ui/TrackerAndMetasMarkupContractTests.cs
+++ b/tests/OpenAC.MosswartMassacre.Tests/Ui/TrackerAndMetasMarkupContractTests.cs
@@ -1,6 +1,7 @@
using System.Reflection;
using System.Xml.Linq;
using OpenAC.MosswartMassacre.FlagTracker;
+using OpenAC.MosswartMassacre.Metas;
namespace OpenAC.MosswartMassacre.Tests.Ui;
@@ -19,6 +20,7 @@ public sealed class TrackerAndMetasMarkupContractTests
public static TheoryData MarkupFiles => new()
{
{ FlagTrackerFeature.MarkupFile, typeof(FlagTrackerViewModel) },
+ { MetaSyncFeature.MarkupFile, typeof(MetasViewModel) },
};
[Theory]