openac-mosswartmassacre/src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs
Erik 162d242c06 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>
2026-09-25 12:26:14 +02:00

172 lines
6.6 KiB
C#

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