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>
337 lines
12 KiB
C#
337 lines
12 KiB
C#
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);
|
|
}
|
|
}
|