Files
CVRManager/Services/ModImportService.cs
Exterrata 9bb4f85802 CVR Manager 1.0.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 02:46:35 -04:00

218 lines
10 KiB
C#

using CVRManager.Models;
namespace CVRManager.Services;
public record Identified(AssemblyDetails File, string Folder, string Source, string Repo, string AssetName, CvrmgMod? Cvrmg, string Why);
public class ModImportService
{
static readonly string[] Folders = { "Mods", "Plugins", "UserLibs" };
readonly GitHubService github;
readonly CvrmgService cvrmg;
readonly Dictionary<string, List<ReleaseInfo>> repoReleases = new(StringComparer.OrdinalIgnoreCase);
public ModImportService(GitHubService github, CvrmgService cvrmg)
{
this.github = github;
this.cvrmg = cvrmg;
}
public static string Norm(string s) => CvrmgService.Normalize(s);
public async Task<List<Identified>> IdentifyAsync(string gameDir, IEnumerable<SourceDef> sources, Action<string> log, CancellationToken ct)
{
var files = new List<(AssemblyDetails Details, string Folder)>();
foreach (var folder in Folders)
{
var dir = Path.Combine(gameDir, folder);
if (!Directory.Exists(dir)) continue;
foreach (var f in Directory.EnumerateFiles(dir, "*.dll", SearchOption.TopDirectoryOnly).OrderBy(f => f, StringComparer.OrdinalIgnoreCase))
files.Add((AssemblyReader.Read(f), folder));
foreach (var sub in Directory.EnumerateDirectories(dir))
log($"Skipping folder {folder}\\{Path.GetFileName(sub)} (only loose DLLs are imported).");
}
log($"Found {files.Count} mod files.");
List<CvrmgMod> catalog = new();
try { catalog = await cvrmg.GetAllAsync(ct); }
catch (Exception e) when (e is not OperationCanceledException) { log($"CVRMG unavailable ({e.Message}); matching without it."); }
var index = new Dictionary<string, List<(string Repo, string Asset)>>();
foreach (var src in sources.Where(s => s.Kind == "github" && s.Repo.Length > 0))
{
var releases = await ReleasesAsync(src.Repo, log, ct);
foreach (var r in releases)
foreach (var a in r.Assets.Where(a => a.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)))
{
var key = Norm(a.Name);
if (!index.TryGetValue(key, out var l)) index[key] = l = new();
if (l.All(x => !x.Repo.Equals(src.Repo, StringComparison.OrdinalIgnoreCase))) l.Add((src.Repo, a.Name));
}
}
var result = new List<Identified>();
foreach (var (d, folder) in files)
{
ct.ThrowIfCancellationRequested();
var cm = catalog.FirstOrDefault(m => m.Hash.Length > 0 && m.Hash == d.Sha256);
var cmWhy = cm != null ? "same file as the current CVRMG release" : "";
if (cm == null)
{
var keys = new[] { d.MelonName, d.AssemblyName, Path.GetFileNameWithoutExtension(d.FileName) }
.Select(Norm).Where(k => k.Length > 0).Distinct().ToList();
cm = catalog.Where(m => new[] { m.Name }.Concat(m.Aliases).Select(Norm).Any(keys.Contains))
.OrderBy(m => m.ApprovalStatus == 1 ? 0 : 1).ThenByDescending(m => m.Updated).FirstOrDefault();
if (cm != null) cmWhy = "name match in CVRMG";
}
string? repo = null, asset = null, why = "";
var link = GitHubService.ParseRepo(d.MelonLink.Contains("github.com", StringComparison.OrdinalIgnoreCase) ? d.MelonLink : "");
if (link != null)
{
var rel = await ReleasesAsync(link, log, ct);
var hit = rel.SelectMany(r => r.Assets).FirstOrDefault(a => Norm(a.Name) == Norm(d.FileName));
if (hit != null) { repo = link; asset = hit.Name; why = "download link in the mod's own metadata"; }
}
if (repo == null && index.TryGetValue(Norm(d.FileName), out var cands))
{
var pick = cands.FirstOrDefault(c => d.MelonAuthor.Length > 0 && c.Repo.Split('/')[0].Equals(d.MelonAuthor, StringComparison.OrdinalIgnoreCase));
if (pick == default) pick = cands[0];
repo = pick.Repo;
asset = pick.Asset;
why = "release assets of " + repo;
}
if (repo != null) result.Add(new Identified(d, folder, "github", repo, asset!, cm, why));
else if (cm != null) result.Add(new Identified(d, folder, "cvrmg", "", "", cm, cmWhy));
else result.Add(new Identified(d, folder, "local", "", "", null,
d.MelonLink.Length > 0 ? $"no published release contains it; its metadata points at {d.MelonLink}" : "no known source"));
}
return result;
}
public void Invalidate() => repoReleases.Clear();
public async Task<List<ReleaseInfo>> ReleasesAsync(string repo, Action<string> log, CancellationToken ct, bool force = false)
{
if (!force && repoReleases.TryGetValue(repo, out var cached)) return cached;
var path = Path.Combine(Paths.Root, "sources", "gh_" + Paths.Safe(repo.Replace('/', '_')) + ".json");
try
{
if (!force && File.Exists(path) && DateTime.UtcNow - File.GetLastWriteTimeUtc(path) < TimeSpan.FromHours(1))
{
var fromDisk = System.Text.Json.JsonSerializer.Deserialize<List<ReleaseInfo>>(await File.ReadAllTextAsync(path, ct));
if (fromDisk != null) return repoReleases[repo] = fromDisk;
}
var list = await github.GetReleasesAsync(repo, ct);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllTextAsync(path, System.Text.Json.JsonSerializer.Serialize(list), ct);
return repoReleases[repo] = list;
}
catch (Exception e) when (e is not OperationCanceledException)
{
if (File.Exists(path))
{
try
{
var stale = System.Text.Json.JsonSerializer.Deserialize<List<ReleaseInfo>>(await File.ReadAllTextAsync(path, ct));
if (stale != null)
{
log($"{repo}: using cached releases ({e.Message})");
return repoReleases[repo] = stale;
}
}
catch (System.Text.Json.JsonException) { }
}
log($"{repo}: {e.Message}");
return repoReleases[repo] = new();
}
}
public static string SnapshotTag(AssemblyDetails d) =>
$"installed {d.Version} ({Convert.ToHexString(Convert.FromBase64String(d.Sha256))[..6].ToLowerInvariant()})";
public int Apply(AppConfig cfg, InstallInfo? install, IEnumerable<Identified> found, Action<string> log, string? gameDir = null)
{
var game = gameDir ?? Paths.GameDir(install!);
var added = 0;
foreach (var it in found)
{
var rel = $"{it.Folder}/{it.File.FileName}";
if (install != null && install.Deployed.Values.Any(l => l.Contains(rel, StringComparer.OrdinalIgnoreCase))) continue;
var def = FindOrCreate(cfg, it);
var tag = SnapshotTag(it.File);
if (def.Releases.All(r => r.Tag != tag))
def.Releases.Add(new ReleaseInfo { Tag = tag, Snapshot = true, Published = File.GetLastWriteTime(Path.Combine(game, it.Folder, it.File.FileName)) });
var filesDir = Paths.ModFilesDir(def, tag);
if (!ModStore.IsDownloaded(def, tag))
{
Directory.CreateDirectory(filesDir);
File.Copy(Path.Combine(game, it.Folder, it.File.FileName), Path.Combine(filesDir, it.File.FileName), true);
File.WriteAllText(Path.Combine(Paths.ModVersionDir(def, tag), ".complete"), "");
}
if (install != null)
{
install.Mods.RemoveAll(a => a.ModId == def.Id);
install.Mods.Add(new ModAssignment { ModId = def.Id, Version = tag, Enabled = true });
install.Deployed[def.Id] = ModStore.MapFiles(def, tag).Keys.ToList();
}
added++;
log($"{def.Name} {it.File.Version}: {Describe(it)}");
}
return added;
}
static string Describe(Identified it) => it.Source switch
{
"github" => $"GitHub {it.Repo} ({it.Why})",
"cvrmg" => $"CVRMG ({it.Why})",
_ => $"kept as a local snapshot ({it.Why})",
};
ModDef FindOrCreate(AppConfig cfg, Identified it)
{
ModDef? def = it.Source switch
{
"github" => cfg.Mods.FirstOrDefault(m => m.Source == "github" && m.Repo.Equals(it.Repo, StringComparison.OrdinalIgnoreCase) && Norm(m.AssetFilter) == Norm(it.AssetName)),
"cvrmg" => cfg.Mods.FirstOrDefault(m => m.CvrmgId == it.Cvrmg!.Id && m.Source == "cvrmg"),
_ => cfg.Mods.FirstOrDefault(m => m.Local && Norm(m.FileName) == Norm(it.File.FileName)),
};
if (def != null) return def;
def = new ModDef
{
Name = it.Cvrmg?.Name is { Length: > 0 } n ? n : it.File.MelonName.Length > 0 ? it.File.MelonName : it.File.AssemblyName,
Type = it.Folder,
FileName = it.File.FileName,
Author = it.File.MelonAuthor,
Source = it.Source,
Local = it.Source == "local",
};
if (it.Cvrmg != null) CvrmgService.ApplyMeta(def, it.Cvrmg);
if (def.SourceLink.Length == 0 && it.File.MelonLink.Length > 0) def.SourceLink = it.File.MelonLink;
switch (it.Source)
{
case "github":
def.Repo = it.Repo;
def.AssetFilter = it.AssetName;
def.SourceLink = $"https://github.com/{it.Repo}";
def.Releases = FilterReleases(repoReleases.GetValueOrDefault(it.Repo) ?? new(), def.AssetFilter);
break;
case "cvrmg":
def.Releases = new() { CvrmgService.ToRelease(it.Cvrmg!, it.File.FileName) };
break;
}
cfg.Mods.Add(def);
return def;
}
public static List<ReleaseInfo> FilterReleases(IEnumerable<ReleaseInfo> releases, string assetFilter) =>
string.IsNullOrWhiteSpace(assetFilter)
? releases.ToList()
: releases.Where(r => r.Assets.Any(a => ModStore.AssetMatches(a.Name, assetFilter))).ToList();
}