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:
parent
714fbfa2e8
commit
162d242c06
8 changed files with 1380 additions and 1 deletions
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
337
src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs
Normal file
337
src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
470
src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs
Normal file
470
src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
172
src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs
Normal file
172
src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs
Normal 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))];
|
||||
}
|
||||
}
|
||||
35
src/OpenAC.MosswartMassacre/mosswartmassacre-metas.xml
Normal file
35
src/OpenAC.MosswartMassacre/mosswartmassacre-metas.xml
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue