Meta sync: pull the club's metas and navs into the shared profiles

The original kept VTank's profile folder in step with the club's metas
repository; the port does the same against host.VtankProfiles, dropping
.met files under mosstank/metas and .nav files under mosstank/navs where
MossTank reads dropped profiles, flattened to their bare names as the
original flattened them. The Gitea tree and raw endpoints are the
original's; the repository address is the metas_repository_url setting,
defaulting to the original repository. An overwritten file keeps its old
copy as .bak (MossTank's pickers skip .bak side files).

Storage is text-only, so the SHA-256 compare hashes the text a stored read
gives back (UTF-8, BOM dropped); for the plain-text files in the repo that
equals the original's byte compare. Network work runs on the thread pool,
one operation after another; chat lines and window updates are applied on
the tick. The four /mm verbs keep their chat lines, and the Metas tab is
its own markup window with IsVisible for the main window to open.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-25 12:26:14 +02:00
parent 714fbfa2e8
commit 162d242c06
8 changed files with 1380 additions and 1 deletions

View file

@ -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));
}
}

View file

@ -0,0 +1,337 @@
using System.Collections.Concurrent;
using AcDream.Plugin.Abstractions;
namespace OpenAC.MosswartMassacre.Metas;
/// <summary>
/// Meta and nav sync from the club's repository: the <c>/mm metasrefresh</c>,
/// <c>metascheck</c>, <c>metaspull</c> and <c>metaspullfile</c> verbs and, on a
/// host with a window, the Metas window. Nothing here reaches the backend.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal sealed class MetaSyncFeature : IMmFeature
{
/// <summary>The panel's markup file, beside the plugin assembly.</summary>
public const string MarkupFile = "mosswartmassacre-metas.xml";
private readonly MmContext _context;
private readonly MetaSyncManager _manager;
private readonly ConcurrentQueue<Action> _onTick = new();
private readonly CancellationTokenSource _shutdown = new();
private readonly Action<double> _tick;
private readonly IDisposable? _panel;
private Task _lastOperation = Task.CompletedTask;
private bool _disposed;
public MetaSyncFeature(MmContext context)
: this(context, context.Host.VtankProfiles, handler: null)
{
}
/// <summary>Builds the feature on a given profile storage and HTTP handler; tests pass fakes.</summary>
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);
}
/// <summary>
/// The window's binding object; set its <c>IsVisible</c> to open the
/// window. Null on a host without a window.
/// </summary>
public MetasViewModel? Panel { get; }
internal MetaSyncManager Manager => _manager;
/// <summary>The operation started last; tests await it before pumping the tick.</summary>
internal Task LastOperation => _lastOperation;
/// <summary>The repository setting, or the original's repository when it is empty.</summary>
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);
}
/// <summary>Applies finished work on the tick thread. Internal so tests can pump without a host tick.</summary>
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<int>? done = null, Action<Exception>? 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<Exception>? 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<Exception>? 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 <relative/path/file.met>");
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);
}
/// <summary>Runs <paramref name="work"/> on the thread pool and applies its outcome on the next tick.</summary>
private void Start<T>(Func<CancellationToken, Task<T>> work, Action<T> done, Action<Exception> 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);
}
}

View file

@ -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,
}
/// <summary>One <c>.met</c> or <c>.nav</c> file of the repository and how the local copy compares.</summary>
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;
/// <summary>The storage key the file is kept under, or empty when there is no storage.</summary>
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;
}
/// <summary>
/// Keeps the shared automation profiles in step with the metas repository on
/// the club's git server: lists its <c>.met</c> and <c>.nav</c> 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 <c>.bak</c>.
/// </summary>
/// <remarks>
/// <para>The endpoints are the original's: the tree comes from
/// <c>{server}/api/v1/repos/{owner}/{repo}/git/trees/main?recursive=1</c> and each
/// file from <c>{repository}/raw/branch/main/{path}</c>. The repository address is a
/// setting that defaults to the original's repository.</para>
/// <para>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 <c>mosstank/metas</c>, navs under <c>mosstank/navs</c>.
/// 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.</para>
/// <para>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.</para>
/// </remarks>
internal sealed class MetaSyncManager : IDisposable
{
public const string DefaultRepositoryUrl = "https://git.snakedesert.se/SawatoMosswartsEnjoyersClub/metas";
public const string BranchName = "main";
/// <summary>Where the automation plugin looks for dropped metas.</summary>
public const string MetaFolder = "mosstank/metas";
/// <summary>Where the automation plugin looks for dropped navs.</summary>
public const string NavFolder = "mosstank/navs";
private readonly IPluginStorage _storage;
private readonly HttpClient _http;
private readonly Func<string> _repositoryUrl;
private readonly Func<DateTime> _utcNow;
private readonly Action<string> _notice;
private readonly object _sync = new();
private readonly SemaphoreSlim _oneAtATime = new(1, 1);
private List<MetaFileEntry> _cachedFiles = [];
public MetaSyncManager(
IPluginStorage storage,
HttpMessageHandler? handler,
Func<string> repositoryUrl,
Func<DateTime> utcNow,
Action<string> 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;
/// <summary>The storage root the files are written beneath, for the path label; null without storage.</summary>
public string? StorageRoot => _storage.IsAvailable ? _storage.RootPath ?? string.Empty : null;
public IReadOnlyList<MetaFileEntry> GetCachedFiles()
{
lock (_sync)
return [.. _cachedFiles.Select(static entry => entry.Clone())];
}
/// <summary>The API and raw-file addresses for a repository address.</summary>
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}");
}
/// <summary>The storage key a repository file is kept under, or null for a file that is neither a meta nor a nav.</summary>
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<int> 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<MetaFileEntry> 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<MetaDownloadResult> 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<MetaFileEntry> current = CloneCache();
if (current.Count == 0)
{
await RefreshCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
current = CloneCache();
}
List<MetaFileEntry> 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<int> RefreshCatalogCoreAsync(CancellationToken cancellationToken)
{
List<string> remoteEntries = await FetchRemoteTreeEntriesAsync(cancellationToken).ConfigureAwait(false);
HashSet<string> localKeys = ListLocalKeys();
List<MetaFileEntry> 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<MetaDownloadResult> 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<List<string>> 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<string> 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);
}
/// <summary>
/// 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.
/// </summary>
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<string> ListLocalKeys()
{
var keys = new HashSet<string>(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<MetaFileEntry> 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";
}
}
}

View file

@ -0,0 +1,172 @@
using System.Globalization;
namespace OpenAC.MosswartMassacre.Metas;
/// <summary>
/// The binding object of the Metas window (<c>mosswartmassacre-metas.xml</c>),
/// 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.
/// </summary>
internal sealed class MetasViewModel
{
private readonly Dictionary<string, string> _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));
}
/// <summary>A button was pressed; the argument is its binding name.</summary>
internal event Action<string>? ButtonPressed;
/// <summary>The search text changed or was cleared.</summary>
internal event Action? SearchRequested;
/// <summary>
/// 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.
/// </summary>
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<string> 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<int> IgnoreRow { get; }
public IReadOnlyList<string> FileTypes { get; private set; } = [];
public IReadOnlyList<string> FilePaths { get; private set; } = [];
public IReadOnlyList<string> FileStatus { get; private set; } = [];
public IReadOnlyList<uint> FileStatusColors { get; private set; } = [];
public IReadOnlyList<string> SelectionItems { get; private set; } = [];
public string SelectedLabel { get; set; } = string.Empty;
public Action<string> SelectionChanged { get; }
/// <summary>The repository path of the file picked in the list, or empty.</summary>
public string SelectedRelativePath =>
_pathByLabel.TryGetValue(SelectedLabel, out string? path) ? path : string.Empty;
/// <summary>
/// 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.
/// </summary>
public void ShowFiles(IReadOnlyList<MetaFileEntry> all)
{
IReadOnlyList<MetaFileEntry> files = ApplySearch(all);
List<MetaFileEntry> 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<string>();
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}";
/// <summary>The status text colour: green current, orange outdated, blue missing, red failed.</summary>
public static uint StatusColor(MetaFileSyncStatus status) => status switch
{
MetaFileSyncStatus.UpToDate => 0x008000u,
MetaFileSyncStatus.UpdateAvailable => 0xB46E00u,
MetaFileSyncStatus.NotDownloaded => 0x0046A0u,
MetaFileSyncStatus.Error => 0xB40000u,
_ => 0xFFFFFFu,
};
private IReadOnlyList<MetaFileEntry> ApplySearch(IReadOnlyList<MetaFileEntry> 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))];
}
}

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Metas window: the original's Metas tab in a window of its own. The
repository's .met/.nav files with their status, a live search, a pick list
for one file, and refresh, check and download. -->
<panel x="80" y="80" w="420" h="320" title="Metas"
visible="{IsVisible}" resizable="true" minw="420" minh="320">
<label x="10" y="24" text="Remote .met/.nav files from metas repo" />
<label x="10" y="40" text="{PathText}" />
<button x="10" y="58" w="95" h="22" text="Refresh List" onclick="{RefreshList}" />
<button x="110" y="58" w="95" h="22" text="Check Updates" onclick="{CheckUpdates}" />
<button x="210" y="58" w="120" h="22" text="Download Outdated" onclick="{DownloadOutdated}" />
<label x="10" y="86" text="Search:" />
<field x="58" y="84" w="212" h="20" text="{SearchText}" onchange="{SearchChanged}" />
<button x="275" y="83" w="55" h="22" text="Clear" onclick="{ClearSearch}" />
<list x="10" y="110" w="400" h="128" rowheight="16"
anchor="left top right bottom"
selected="{NoRow}" onchange="{IgnoreRow}">
<column type="text" width="45" items="{FileTypes}" />
<column type="text" width="230" items="{FilePaths}" />
<column type="text" width="*" items="{FileStatus}" colors="{FileStatusColors}" />
</list>
<label x="10" y="246" text="Selected file:" anchor="left bottom" />
<menu x="95" y="244" w="225" h="20" anchor="left bottom"
items="{SelectionItems}" selected="{SelectedLabel}" onchange="{SelectionChanged}" />
<button x="325" y="243" w="85" h="22" text="Download" anchor="left bottom"
onclick="{DownloadSelected}" />
<label x="10" y="272" text="{LastCheckText}" anchor="left bottom" />
<label x="10" y="290" text="{StatusText}" anchor="left bottom" />
</panel>