using System.Globalization; namespace OpenAC.MosswartMassacre.Metas; /// /// The binding object of the Metas window (mosswartmassacre-metas.xml), /// the original's Metas tab: the repository's files with their status, a /// live search, a pick list for one file, and the refresh, check and /// download buttons. Assigned on the tick thread only. /// internal sealed class MetasViewModel { private readonly Dictionary _pathByLabel = new(StringComparer.Ordinal); public MetasViewModel() { IgnoreRow = _ => { }; SearchChanged = text => { SearchText = text ?? string.Empty; SearchRequested?.Invoke(); }; ClearSearch = () => { SearchText = string.Empty; SearchRequested?.Invoke(); }; SelectionChanged = label => SelectedLabel = label ?? string.Empty; RefreshList = () => ButtonPressed?.Invoke(nameof(RefreshList)); CheckUpdates = () => ButtonPressed?.Invoke(nameof(CheckUpdates)); DownloadOutdated = () => ButtonPressed?.Invoke(nameof(DownloadOutdated)); DownloadSelected = () => ButtonPressed?.Invoke(nameof(DownloadSelected)); } /// A button was pressed; the argument is its binding name. internal event Action? ButtonPressed; /// The search text changed or was cleared. internal event Action? SearchRequested; /// /// Whether the window is open. The root panel's visibility is bound to it, /// so the main window opens this one by setting it; it starts closed. /// public bool IsVisible { get; set; } public string PathText { get; set; } = "VTank Path: unresolved"; public string LastCheckText { get; set; } = "Last Check: never"; public string StatusText { get; set; } = "Status: idle"; public string SearchText { get; set; } = string.Empty; public Action SearchChanged { get; } public Action ClearSearch { get; } public Action RefreshList { get; } public Action CheckUpdates { get; } public Action DownloadOutdated { get; } public Action DownloadSelected { get; } public int NoRow => -1; public Action IgnoreRow { get; } public IReadOnlyList FileTypes { get; private set; } = []; public IReadOnlyList FilePaths { get; private set; } = []; public IReadOnlyList FileStatus { get; private set; } = []; public IReadOnlyList FileStatusColors { get; private set; } = []; public IReadOnlyList SelectionItems { get; private set; } = []; public string SelectedLabel { get; set; } = string.Empty; public Action SelectionChanged { get; } /// The repository path of the file picked in the list, or empty. public string SelectedRelativePath => _pathByLabel.TryGetValue(SelectedLabel, out string? path) ? path : string.Empty; /// /// Redraws the list and the pick list from the manager's files, filtered /// by the search text on path or name. The list puts the files that need /// attention first: missing, then outdated, then failed, then current. /// public void ShowFiles(IReadOnlyList all) { IReadOnlyList files = ApplySearch(all); List ordered = [.. files .OrderBy(static file => file.Status switch { MetaFileSyncStatus.NotDownloaded => 0, MetaFileSyncStatus.UpdateAvailable => 1, MetaFileSyncStatus.Error => 2, _ => 3, }) .ThenBy(static file => file.RelativePath, StringComparer.OrdinalIgnoreCase)]; if (ordered.Count == 0) { bool searching = !string.IsNullOrWhiteSpace(SearchText); FileTypes = ["-"]; FilePaths = [searching ? "No matches" : "No files loaded"]; FileStatus = [searching ? "Try a different filter" : "Click Refresh List"]; FileStatusColors = [0xFFFFFFu]; } else { FileTypes = [.. ordered.Select(static file => file.Extension.ToUpperInvariant())]; FilePaths = [.. ordered.Select(static file => file.RelativePath)]; FileStatus = [.. ordered.Select(static file => file.StatusText)]; FileStatusColors = [.. ordered.Select(static file => StatusColor(file.Status))]; } string previous = SelectedRelativePath; _pathByLabel.Clear(); var labels = new List(); foreach (MetaFileEntry file in files.OrderBy(static file => file.RelativePath, StringComparer.OrdinalIgnoreCase)) { string label = $"[{file.Extension}] {file.RelativePath}"; labels.Add(label); _pathByLabel[label] = file.RelativePath; } SelectionItems = labels; SelectedLabel = labels.FirstOrDefault(label => previous.Length > 0 && _pathByLabel[label].Equals(previous, StringComparison.OrdinalIgnoreCase)) ?? labels.FirstOrDefault() ?? string.Empty; } public void ShowLastCheck(DateTime lastStatusCheckUtc, DateTime lastCatalogRefreshUtc) { DateTime stamp = lastStatusCheckUtc != DateTime.MinValue ? lastStatusCheckUtc : lastCatalogRefreshUtc; LastCheckText = stamp == DateTime.MinValue ? "Last Check: never" : string.Create( CultureInfo.InvariantCulture, $"Last Check: {DateTime.SpecifyKind(stamp, DateTimeKind.Utc).ToLocalTime():yyyy-MM-dd HH:mm:ss}"); } public void ShowPath(string? storageRoot) => PathText = storageRoot is null ? "VTank Path: unresolved" : $"VTank Path: {storageRoot}"; /// The status text colour: green current, orange outdated, blue missing, red failed. public static uint StatusColor(MetaFileSyncStatus status) => status switch { MetaFileSyncStatus.UpToDate => 0x008000u, MetaFileSyncStatus.UpdateAvailable => 0xB46E00u, MetaFileSyncStatus.NotDownloaded => 0x0046A0u, MetaFileSyncStatus.Error => 0xB40000u, _ => 0xFFFFFFu, }; private IReadOnlyList ApplySearch(IReadOnlyList files) { string filter = SearchText.Trim(); if (filter.Length == 0) return files; return [.. files.Where(file => file.RelativePath.Contains(filter, StringComparison.OrdinalIgnoreCase) || file.FileName.Contains(filter, StringComparison.OrdinalIgnoreCase))]; } }