Files
MRCC/launcher/PCL-CE/Plain Craft Launcher 2/Modules/Minecraft/ModComp.cs
T

3824 lines
154 KiB
C#
Raw Normal View History

using System.Collections;
using System.Collections.Concurrent;
using System.IO;
using System.IO.Compression;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Media;
using Dapper;
using Microsoft.Data.Sqlite;
using PCL.Core.App;
using PCL.Core.Logging;
using PCL.Core.Utils;
using PCL.Core.Utils.Hash;
using PCL.Network;
using PCL.Network.Loaders;
using ProtoBuf;
using PCL.Core.App.Localization;
using PCL.Core.UI;
namespace PCL;
public static class ModComp
{
public enum CompLoaderType
{
// https://docs.curseforge.com/?http#tocS_ModLoaderType
/// <summary>
/// 模组加载器
/// </summary>
Any = 0,
/// <summary>
/// 模组加载器
/// </summary>
Forge = 1,
/// <summary>
/// 模组加载器
/// </summary>
LiteLoader = 3,
/// <summary>
/// 模组加载器
/// </summary>
Fabric = 4,
/// <summary>
/// 模组加载器
/// </summary>
Quilt = 5,
/// <summary>
/// 模组加载器
/// </summary>
NeoForge = 6,
/// <summary>
/// 材质包
/// </summary>
Minecraft = 7,
/// <summary>
/// 光影包
/// </summary>
Canvas = 8,
/// <summary>
/// 光影包
/// </summary>
Iris = 9,
/// <summary>
/// 光影包
/// </summary>
OptiFine = 10,
/// <summary>
/// 光影包
/// </summary>
Vanilla = 11,
/// <summary>
/// LabyMod 客户端
/// </summary>
LabyMod = 12
}
/// <summary>
/// 搜索结果排序方式
/// </summary>
public enum CompSortType
{
/// <summary>
/// 默认
/// </summary>
Default = 1,
/// <summary>
/// 相关性 (CurseForge Name (4) / Modrinth relevance)
/// </summary>
Relevance = 2,
/// <summary>
/// 下载量 (CurseForge TotalDownloads (6) / Modrinth downloads)
/// </summary>
Downloads = 3,
/// <summary>
/// 关注量 (CurseForge Popularity (2) / Modrinth follows)
/// </summary>
Follows = 4,
/// <summary>
/// 最新发布 (CurseForge ReleasedDate (11) / Modrinth newest)
/// </summary>
Newest = 5,
/// <summary>
/// 最近更新 (CurseForge LastUpdated (3) / Modrinth updated)
/// </summary>
Updated = 6
}
[Flags]
public enum CompSourceType
{
CurseForge = 1,
Modrinth = 2,
Any = CurseForge | Modrinth
}
public enum CompType
{
/// <summary>
/// 允许任意种类,或种类未知。
/// </summary>
Any = -1,
/// <summary>
/// Mod。
/// </summary>
Mod = 0,
/// <summary>
/// 整合包。
/// </summary>
ModPack = 1,
/// <summary>
/// 资源包。
/// </summary>
ResourcePack = 2,
/// <summary>
/// 光影包。
/// </summary>
Shader = 3,
/// <summary>
/// CurseForge:数据包。
/// Modrinth:数据包,或数据包与 Mod 的混合。
/// </summary>
DataPack = 4,
/// <summary>
/// 服务端插件。
/// </summary>
Plugin = 5,
/// <summary>
/// 投影原理图。
/// </summary>
Schematic = 6,
/// <summary>
/// 世界。
/// </summary>
World = 7
}
public enum CompDepsInstallTypes
{
/// <summary>
/// 无法解析依赖
/// </summary>
Unresolved = 0,
/// <summary>
/// 用户选择安装前置
/// </summary>
WithDeps = 1,
/// <summary>
/// 用户选择只安装本体,不安装前置
/// </summary>
WithoutDeps = 2,
/// <summary>
/// 用户取消安装
/// </summary>
Cancel = 3,
}
public enum DownloadReason
{
Standalone,
Dependency,
ModPack,
Update
}
public static string GetCompTypeName(CompType type) => Lang.Text(type switch
{
CompType.Mod => "Download.Comp.Type.Mod",
CompType.ModPack => "Download.Comp.Type.Modpack",
CompType.ResourcePack => "Download.Comp.Type.ResourcePack",
CompType.Shader => "Download.Comp.Type.Shader",
CompType.DataPack => "Download.Comp.Type.DataPack",
CompType.Plugin => "Download.Comp.Type.Plugin",
CompType.World => "Download.Comp.Type.World",
CompType.Schematic => "Download.Comp.Type.Schematic",
_ => "Download.Comp.Type.Unknown"
});
public static string GetCompLoadingName(CompType type) => Lang.Text(type switch
{
CompType.Mod => "Download.Comp.List.Loading.Mod",
CompType.ModPack => "Download.Comp.List.Loading.Modpack",
CompType.ResourcePack => "Download.Comp.List.Loading.ResourcePack",
CompType.Shader => "Download.Comp.List.Loading.Shader",
CompType.DataPack => "Download.Comp.List.Loading.DataPack",
CompType.Plugin => "Download.Comp.List.Loading.Plugin",
CompType.World => "Download.Comp.List.Loading.World",
CompType.Schematic => "Download.Comp.List.Loading.Schematic",
_ => "Download.Comp.List.Loading.Unknown"
});
public static string GetCompSearchName(CompType type) => Lang.Text(type switch
{
CompType.Mod => "Download.Comp.List.Search.Mod",
CompType.ModPack => "Download.Comp.List.Search.Modpack",
CompType.ResourcePack => "Download.Comp.List.Search.ResourcePack",
CompType.Shader => "Download.Comp.List.Search.Shader",
CompType.DataPack => "Download.Comp.List.Search.DataPack",
CompType.Plugin => "Download.Comp.List.Search.Plugin",
CompType.World => "Download.Comp.List.Search.World",
CompType.Schematic => "Download.Comp.List.Search.Schematic",
_ => "Download.Comp.List.Search.Unknown"
});
#region CompFavorites | 收藏
public class CompFavorites
{
private static List<FavData> _FavoritesList;
/// <summary>
/// 收藏的工程列表
/// </summary>
public static List<FavData> FavoritesList
{
get
{
if (_FavoritesList is null)
{
var rawData = States.Game.CompFavorites;
List<FavData> rawList = null;
// 尝试作为新格式解析
try
{
rawList = JsonSerializer.Deserialize<List<FavData>>(rawData, JsonCompat.SerializerOptions);
}
catch (Exception ex1)
{
// 尝试作为旧格式(HashSet)迁移
try
{
var migrate = JsonSerializer.Deserialize<HashSet<string>>(rawData, JsonCompat.SerializerOptions);
if (migrate is not null) rawList = new List<FavData> { GetNewFav(Lang.Text("Download.Comp.Detail.Favorites.DefaultName"), migrate) };
}
catch (Exception ex2)
{
// 两种都失败,使用默认
}
}
// 最终兜底:确保至少有一个收藏夹
if (rawList is null || rawList.Count == 0) rawList = new List<FavData> { GetNewFav(Lang.Text("Download.Comp.Detail.Favorites.DefaultName"), null) };
_FavoritesList = rawList;
Save();
}
return _FavoritesList;
}
set
{
_FavoritesList = value;
foreach (var item in _FavoritesList)
item.Notes = item.Notes.Where(n => !string.IsNullOrWhiteSpace(n.Value)).ToDictionary();
var rawList = JsonSerializer.Serialize(_FavoritesList, JsonCompat.SerializerOptions);
States.Game.CompFavorites = JsonSerializer.Serialize(_FavoritesList, JsonCompat.SerializerOptions);
}
}
public static string GetShareCode(HashSet<string> data)
{
try
{
return JsonSerializer.Serialize(data, JsonCompat.SerializerOptions);
}
catch (Exception ex)
{
ModBase.Log(ex, "[CompFavorites] 生成分享出错");
}
return "";
}
public static HashSet<string> GetIdsByShareCode(string code)
{
try
{
return JsonSerializer.Deserialize<HashSet<string>>(code, JsonCompat.SerializerOptions);
}
catch (Exception ex)
{
ModBase.Log(ex, "[CompFavorites] 通过分享获取 ID 出错");
}
return new HashSet<string>();
}
/// <summary>
/// 显示收藏菜单。
/// </summary>
/// <param name="project"></param>
/// <param name="pos"></param>
public static void ShowMenu(CompProject project, UIElement pos, Action closedCallBack = null)
{
var body = new ContextMenu();
foreach (var i in FavoritesList)
{
var item = new MyMenuItem();
item.MaxWidth = 240d;
var hasFavs = i.Favs.Contains(project.Id);
if (hasFavs)
{
item.Header = Lang.Text("Download.Comp.Detail.Favorites.UnfavoriteContextMenu", i.Name);
item.SvgIcon = "lucide/heart-filled";
}
else
{
item.Header = Lang.Text("Download.Comp.Detail.Favorites.FavoriteContextMenu", i.Name);
item.SvgIcon = "lucide/heart";
}
item.Click += (_, _) =>
{
try
{
if (hasFavs)
{
i.Favs.Remove(project.Id);
HintService.Hint(Lang.Text("Download.Comp.Detail.Favorites.Remove", project.TranslatedName, i.Name), HintType.Success);
}
else
{
i.Favs.Add(project.Id);
HintService.Hint(Lang.Text("Download.Comp.Detail.Favorites.Add", project.TranslatedName, i.Name), HintType.Success);
}
Save();
}
catch (Exception ex)
{
ModBase.Log(ex, "[CompFavorites] 改变收藏项出错");
}
};
body.Items.Add(item);
}
body.Closed += (_, _) => closedCallBack?.Invoke();
body.Placement = PlacementMode.Bottom;
body.PlacementTarget = pos;
body.IsOpen = true;
}
/// <summary>
/// 显示收藏菜单。
/// </summary>
public static void ShowMenu(List<CompProject> project, UIElement pos, Action closedCallBack = null)
{
var body = new ContextMenu();
foreach (var i in FavoritesList)
{
var item = new MyMenuItem
{
MaxWidth = 240d,
Header = Lang.Text("Download.Comp.Detail.Favorites.FavoriteContextMenu", i.Name),
SvgIcon = "lucide/heart"
};
item.Click += (_, _) =>
{
try
{
var count = i.Favs.Count;
project.Select(p => p.Id).ToList().ForEach(x => i.Favs.Add(x));
Save();
var successCount = i.Favs.Count - count;
var failedCount = project.Count - successCount;
HintService.Hint(
Lang.Text(failedCount > 0
? "Download.Comp.Detail.Favorites.BulkAddWithFailures"
: "Download.Comp.Detail.Favorites.BulkAdd", successCount, i.Name, failedCount),
HintType.Success);
}
catch (Exception ex)
{
ModBase.Log(ex, "[CompFavorites] 改变收藏项出错");
}
};
body.Items.Add(item);
}
body.Closed += (_, _) => closedCallBack?.Invoke();
body.Placement = PlacementMode.Bottom;
body.PlacementTarget = pos;
body.IsOpen = true;
}
/// <summary>
/// 保存收藏夹数据
/// </summary>
public static void Save()
{
FavoritesList = _FavoritesList;
}
/// <summary>
/// 获取一个新的收藏夹
/// </summary>
/// <param name="name"></param>
/// <param name="favList">没有传 Nothing</param>
/// <returns></returns>
public static FavData GetNewFav(string name, HashSet<string> favList)
{
var res = new FavData { Name = name, Id = Guid.NewGuid().ToString() };
if (favList is null)
res.Favs = new HashSet<string>();
else
res.Favs = favList;
return res;
}
public static bool IsFavourite(string id)
{
if (FavoritesList is null)
return false;
foreach (var i in FavoritesList)
if (i.Favs.Contains(id))
return true;
return false;
}
public class FavData
{
/// <summary>
/// 收藏夹名称
/// </summary>
/// <returns></returns>
[JsonPropertyName("Name")]
public string Name { get; set; }
/// <summary>
/// Guid
/// </summary>
/// <returns></returns>
[JsonPropertyName("Id")]
public string Id { get; set; }
/// <summary>
/// 收藏的工程 ID 列表
/// </summary>
/// <returns></returns>
[JsonPropertyName("Favs")]
public HashSet<string> Favs { get; set; } = new();
/// <summary>
/// 备注
/// </summary>
/// <returns></returns>
[JsonPropertyName("Notes")]
public Dictionary<string, string> Notes { get; set; } = new();
}
}
#endregion
#region CompProject | 项目信息
public class CompRequest
{
/// <summary>
/// 通过项目 Id 判断是否来自 CurseForge
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
public static bool IsFromCurseForge(string id)
{
var res = 0;
return int.TryParse(id, out res); // CurseForge 数字 ID Modrinth 乱序 ID
}
/// <summary>
/// 通过一堆 ID 从 Modrinth 那获取项目信息
/// </summary>
/// <param name="ids"></param>
/// <returns></returns>
public static async Task<List<CompProject>> GetListByIdsFromModrinthAsync(List<string> ids)
{
var res = new List<CompProject>();
try
{
await Task.Run(() =>
{
var rawProjectsData =
ModDownload.DlModRequest<JsonArray>($"https://api.modrinth.com/v2/projects?ids=[\"{ids.Join("\",\"")}\"]");
foreach (var rawData in (IEnumerable)rawProjectsData)
res.Add(new CompProject((JsonObject)rawData));
});
}
catch (Exception ex)
{
ModBase.Log(ex, "从 Modrinth 获取数据失败");
}
return res;
}
/// <summary>
/// 通过一堆 ID 从 CurseForge 那获取项目信息
/// </summary>
/// <param name="Ids"></param>
/// <returns></returns>
public static async Task<List<CompProject>> GetListByIdsFromCurseforgeAsync(List<string> ids)
{
var res = new List<CompProject>();
try
{
// 使用 Task.Run 将同步的 DlModRequest 包装为异步
await Task.Run(() =>
{
// 构建请求 Body,建议使用 string.Join
var jsonBody = "{\"modIds\": [" + string.Join(",", ids) + "]}";
// DlModRequest 返回 object,先强转 JsonObject,再获取 "data" 并强转为 JsonArray
var response = ModDownload.DlModRequest<JsonObject>(
"https://api.curseforge.com/v1/mods",
"POST",
jsonBody,
"application/json"
);
var rawProjectsData = (JsonArray)response["data"];
// 2. 使用 LINQ 快速转换并填充列表
if (rawProjectsData is not null)
{
var projectList = rawProjectsData
.Cast<JsonObject>()
.Select(data => new CompProject(data))
.ToList();
res.AddRange(projectList);
}
});
}
catch (Exception ex)
{
ModBase.Log(ex, "Failed to get project data from CurseForge");
}
return res;
}
public static List<CompProject> GetCompProjectsByIds(List<string> input)
{
return GetCompProjectsByIdsAsync(input).GetAwaiter().GetResult();
}
public static async Task<List<CompProject>> GetCompProjectsByIdsAsync(List<string> input)
{
if (input?.Any() == false)
return new List<CompProject>();
var modrinthIds = new List<string>();
var curseForgeIds = new List<string>();
foreach (var id in input)
if (IsFromCurseForge(id))
curseForgeIds.Add(id);
else
modrinthIds.Add(id);
var tasks = new List<Task<List<CompProject>>>();
if (curseForgeIds.Any()) tasks.Add(GetListByIdsFromCurseforgeAsync(curseForgeIds));
if (modrinthIds.Any()) tasks.Add(GetListByIdsFromModrinthAsync(modrinthIds));
await Task.WhenAll(tasks.ToArray());
var result = new List<CompProject>();
foreach (var task in tasks)
result.AddRange(task.Result);
return result;
}
}
#endregion
#region CompClipboard | 剪贴板识别
public class CompClipboard
{
// 剪贴板已读取内容
public static string? currentText;
// 识别剪贴板内容
public static void GetClipboardResource()
{
string? text = null;
ModBase.RunInUiWait(() => text = Clipboard.GetText());
if (string.IsNullOrEmpty(text) || text == currentText) return;
currentText = text;
// 在新线程中处理网络请求
ModBase.RunInNewThread(() =>
{
try
{
var projectId = ResolveLinkToProjectId(text);
if (string.IsNullOrEmpty(projectId)) return;
ModBase.Log($"[Clipboard] Found ProjectId: {projectId}");
// 3. UI 交互:跳转到详情页
System.Windows.Application.Current.Dispatcher.BeginInvoke(new Func<Task>(async () =>
{
if (ModMain.MyMsgBox(
Lang.Text("Download.Comp.Detail.Clipboard.Detected.Message"),
Lang.Text("Download.Comp.Detail.Clipboard.Detected.Title"),
Lang.Text("Common.Action.Confirm"), Lang.Text("Common.Action.Cancel"),
forceWait: true) == 1)
{
HintService.Hint(Lang.Text("Download.Comp.Detail.Clipboard.Fetching"));
var ids = new List<string> { projectId };
var compProjects = await CompRequest.GetCompProjectsByIdsAsync(ids);
if (compProjects.Count == 0)
{
HintService.Hint(Lang.Text("Download.Comp.Detail.Clipboard.InvalidContent"),
HintType.Error);
return;
}
ModMain.frmMain.PageChange(new FormMain.PageStackData
{
page = FormMain.PageType.CompDetail,
additional = (compProjects.First(), new List<string>(), string.Empty, CompLoaderType.Any,
CompType.Any, null)
});
}
}));
}
catch (Exception ex)
{
ModBase.Log(ex, "Error processing clipboard resource");
}
}, "Clipboard Resource Processing");
}
}
#endregion
#region CompDatabase | Mod 数据库
private static readonly Lazy<string> _dbInitializer = new(InitializeModDbAndGetConnectionString);
private static string CompDBConnectionString => _dbInitializer.Value;
private static string InitializeModDbAndGetConnectionString()
{
ModBase.Log("[DB] 解压 ModData (SQLite) 中");
using (var compressedDbData = ModBase.GetResourceStream("Resources/mcmod.buf"))
{
using (var trueDbFile = new GZipStream(compressedDbData, CompressionMode.Decompress))
{
using (var ms = new MemoryStream())
{
// 这里提取文件资源
trueDbFile.CopyTo(ms);
ms.Seek(0L, SeekOrigin.Begin);
var fileHash = ModBase.GetHexString(SHA1Provider.Instance.ComputeHash(ms));
var dbDir = Path.Combine(ModBase.pathTemp, "Cache");
var dbPath = Path.Combine(dbDir, $"ModData{fileHash}.sqlite");
if (File.Exists(dbPath) && !IsDatabaseValid(dbPath))
{
File.Delete(dbPath);
}
if (!File.Exists(dbPath))
{
ms.Seek(0L, SeekOrigin.Begin);
var entries = Serializer.Deserialize<List<CompDatabaseEntry>>(ms);
Directory.CreateDirectory(dbDir);
var tempPath = dbPath + ".tmp";
if (File.Exists(tempPath)) File.Delete(tempPath);
using (var buildDbConnection = new SqliteConnection($"Data Source=\"{tempPath}\";Pooling=False"))
{
buildDbConnection.Open();
// 不用事务的话构建会非常慢
using (var transaction = buildDbConnection.BeginTransaction())
{
buildDbConnection.Execute(@"
CREATE TABLE ModTranslation (
WikiId INTEGER,
ChineseName TEXT,
CurseForgeSlug TEXT,
ModrinthSlug TEXT
);
CREATE INDEX idx_curseforge ON ModTranslation (CurseForgeSlug);
CREATE INDEX idx_modrinth ON ModTranslation (ModrinthSlug);
CREATE INDEX idx_chinesename ON ModTranslation (ChineseName);
");
var insertSql =
@"INSERT INTO ModTranslation (WikiId, ChineseName, CurseForgeSlug, ModrinthSlug)
VALUES (@WikiId, @ChineseName, @CurseForgeSlug, @ModrinthSlug)";
foreach (var entry in entries)
buildDbConnection.Execute(insertSql, entry, transaction);
transaction.Commit();
}
}
// 构建完成的文件移入缓存位
File.Move(tempPath, dbPath, true);
}
return $"Data Source=\"{dbPath}\"";
}
}
}
}
/// <summary>
/// 验证 SQLite 数据库文件是否包含预期的表且非空
/// </summary>
private static bool IsDatabaseValid(string dbPath)
{
try
{
using (var conn = new SqliteConnection($"Data Source=\"{dbPath}\";Pooling=False;Mode=ReadOnly"))
{
conn.Open();
// 检查表是否存在
var tableCheck = conn.ExecuteScalar<int>(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='ModTranslation'");
if (tableCheck == 0) return false;
// 检查表中是否有数据
var rowCount = conn.ExecuteScalar<int>("SELECT COUNT(*) FROM ModTranslation");
return rowCount > 0;
}
}
catch (Exception ex)
{
ModBase.Log(ex, "检查模组翻译数据库有效性失败");
return false;
}
}
private static SqliteConnection CompDB
{
get
{
var conn = new SqliteConnection(CompDBConnectionString);
conn.Open();
return conn;
}
}
private static CompDatabaseEntry GetCompWikiEntryBySlug(string slug)
{
try
{
using (var conn = CompDB)
{
return conn.QueryFirstOrDefault<CompDatabaseEntry>(
"SELECT * FROM ModTranslation WHERE CurseForgeSlug = @s OR ModrinthSlug = @s LIMIT 1",
new { s = slug });
}
}
catch (Exception ex)
{
ModBase.Log(
ex,
"获取模组翻译信息失败",
ModBase.LogLevel.Hint,
userSummary: Lang.Text("Minecraft.Comp.Error.OperationFailed"));
return null;
}
}
[ProtoContract]
private class CompDatabaseEntry
{
/// <summary>
/// McMod 的对应 ID。
/// </summary>
[ProtoMember(1)]
public int WikiId { get; set; }
/// <summary>
/// 中文译名。空字符串代表没有翻译。
/// </summary>
[ProtoMember(2)]
public string ChineseName { get; set; } = "";
/// <summary>
/// CurseForge Slug(例如 advanced-solar-panels)。
/// </summary>
[ProtoMember(3)]
public string CurseForgeSlug { get; set; }
/// <summary>
/// Modrinth Slug(例如 advanced-solar-panels)。
/// </summary>
[ProtoMember(4)]
public string ModrinthSlug { get; set; }
public override string ToString()
{
return (CurseForgeSlug ?? "") + "&" + (ModrinthSlug ?? "") + "|" + WikiId + "|" + ChineseName;
}
}
#endregion
#region CompProject | 工程信息
// 类定义
public class CompProject
{
/// <summary>
/// CurseForge 文件列表的数字 ID。Modrinth 工程的此项无效。
/// </summary>
public readonly List<int> CurseForgeFileIds;
/// <summary>
/// 英文描述。
/// </summary>
public readonly string Description;
/// <summary>
/// 下载量计数。注意,该计数仅为一个来源,无法反应两边加起来的下载量!
/// </summary>
public readonly int DownloadCount;
/// <summary>
/// 支持的 Drop 编号,从高到低排序,不为 Nothing。
/// 例如:26126.1.x)、1801.18.x)。
/// </summary>
public readonly List<int> Drops;
// 源信息
/// <summary>
/// 该工程信息来自 CurseForge 还是 Modrinth。
/// </summary>
public readonly bool FromCurseForge;
/// <summary>
/// CurseForge 工程的数字 ID。Modrinth 工程的乱码 ID。
/// </summary>
public readonly string Id;
/// <summary>
/// 最后一次更新的时间。可能为 Nothing。
/// </summary>
public readonly DateTime? LastUpdate;
/// <summary>
/// 支持的 Mod 加载器列表。可能为空。
/// </summary>
public readonly List<CompLoaderType> ModLoaders;
// 描述性信息
/// <summary>
/// 原始的英文名称。
/// </summary>
public readonly string RawName;
/// <summary>
/// 工程的短名。例如 technical-enchant。
/// </summary>
public readonly string Slug;
/// <summary>
/// 描述性标签的内容。已转换为中文。
/// </summary>
public readonly List<string> Tags;
/// <summary>
/// 工程的种类。
/// 由于 Modrinth 混合使用 Mod 和数据包,结果不一定准确。
/// </summary>
public readonly CompType Type;
/// <summary>
/// 来源网站的工程页面网址。确保格式一定标准。
/// CurseForgehttps://www.curseforge.com/minecraft/mc-mods/jei
/// Modrinthhttps://modrinth.com/mod/technical-enchant
/// </summary>
public readonly string Website;
private CompDatabaseEntry _DatabaseEntry;
// 数据库信息
private bool loadedDatabase;
/// <summary>
/// Logo 图片的下载地址。
/// 若为 Nothing 则没有,保证不为空字符串。
/// </summary>
public string LogoUrl;
// 实例化
/// <summary>
/// 从工程 Json 中初始化实例。若出错会抛出异常。
/// </summary>
public CompProject(JsonObject data)
{
var result = data.ContainsKey("Tags")
? _BuildFromCompJson(data)
: data.ContainsKey("summary")
? _BuildFromCurseForge(data)
: _BuildFromModrinth(data);
if (!data.ContainsKey("Tags"))
{
if (result.Tags.Count == 0)
result.Tags.Add(Lang.Text("Download.Comp.Category.Other"));
result.Tags = result.Tags.Distinct().ToList();
result.Tags.Sort();
result.ModLoaders = result.ModLoaders
.Distinct()
.OrderBy(t => t)
.ToList();
}
FromCurseForge = result.FromCurseForge;
Type = result.Type;
Slug = result.Slug;
Id = result.Id;
CurseForgeFileIds = result.CurseForgeFileIds;
RawName = result.RawName;
Description = result.Description;
Website = result.Website;
LastUpdate = result.LastUpdate;
DownloadCount = result.DownloadCount;
ModLoaders = result.ModLoaders;
Tags = result.Tags;
LogoUrl = result.LogoUrl;
Drops = result.Drops;
// 保存缓存
compProjectCache[Id] = this;
}
private sealed class CompProjectBuildResult
{
public bool FromCurseForge;
public CompType Type;
public string Slug;
public string Id;
public List<int> CurseForgeFileIds = [];
public string RawName;
public string Description;
public string Website;
public DateTime? LastUpdate;
public int DownloadCount;
public List<CompLoaderType> ModLoaders = [];
public List<string> Tags = [];
public string LogoUrl;
public List<int> Drops = [];
}
private static CompProjectBuildResult _BuildFromCompJson(JsonObject data)
{
var result = new CompProjectBuildResult
{
FromCurseForge = (string)data["DataSource"] == "CurseForge",
Type = (CompType)data["Type"].ToObject<int>(),
Slug = (string)data["Slug"],
Id = (string)data["Id"],
RawName = (string)data["RawName"],
Description = (string)data["Description"],
Website = (string)data["Website"],
DownloadCount = (int)data["DownloadCount"],
Tags = ((JsonArray)data["Tags"]).Select(t => t.ToString()).ToList()
};
if (data.TryGetPropertyValue("CurseForgeFileIds", out var id))
result.CurseForgeFileIds = ((JsonArray)id).Select(t => t.ToObject<int>()).ToList();
if (data.TryGetPropertyValue("LastUpdate", out var last))
result.LastUpdate = last?.ToObject<DateTime>();
if (data.TryGetPropertyValue("ModLoaders", out var loaders))
result.ModLoaders = ((JsonArray)loaders).Select(t => (CompLoaderType)t.ToObject<int>())
.ToList();
if (data.TryGetPropertyValue("LogoUrl", out var url))
result.LogoUrl = (string)url;
if (data.TryGetPropertyValue("Drops", out var drops))
result.Drops = ((JsonArray)drops).Select(t => t.ToObject<int>()).ToList();
return result;
}
private static CompProjectBuildResult _BuildFromCurseForge(JsonObject data)
{
var result = new CompProjectBuildResult
{
FromCurseForge = true,
// 简单信息
Id = data["id"].ToString(),
Slug = (string)data["slug"],
RawName = (string)data["name"],
Description = (string)data["summary"],
Website = (data["links"]?["websiteUrl"]?.ToString() ?? "").TrimEnd('/'),
LastUpdate = data["dateReleased"]?.ToObject<DateTime>(), // #1194
DownloadCount = (int)data["downloadCount"]
};
if (data["logo"] is JsonObject { Count: > 0 } logo)
result.LogoUrl = string.IsNullOrEmpty((string)logo["thumbnailUrl"])
? (string)logo["url"]
: (string)logo["thumbnailUrl"];
if (string.IsNullOrEmpty(result.LogoUrl))
result.LogoUrl = null;
// Type
result.Type = _GetCurseForgeTypeByWebsite(result.Website);
// FileIndexes / VanillaMajorVersions / ModLoaders
var files = new List<KeyValuePair<int, List<string>>>(); // FileId, GameVersions
foreach (var file in (data["latestFiles"] as JsonArray) ?? [])
{
var newFile = new CompFile((JsonObject)file, result.Type);
if (!newFile.Available)
continue;
result.ModLoaders.AddRange(newFile.ModLoaders);
var gameVersions = file["gameVersions"]?.ToObject<List<string>>() ?? [];
if (!gameVersions.Any(McInstanceInfo.IsFormatFit))
continue;
files.Add(new KeyValuePair<int, List<string>>((int)file["id"], gameVersions));
}
files.AddRange(
from File in (data["latestFilesIndexes"] as JsonArray) ?? []
let GameVersion = File["gameVersion"]?.ToString() ?? ""
where McInstanceInfo.IsFormatFit(GameVersion)
select new KeyValuePair<int, List<string>>((int)File["fileId"], new[] { GameVersion }.ToList())
);
result.CurseForgeFileIds = files
.Select(f => f.Key)
.Distinct()
.ToList();
result.Drops = files
.SelectMany(f => f.Value)
.Select(v => McInstanceInfo.VersionToDrop(v))
.Where(v => v > 0)
.Distinct()
.OrderByDescending(v => v)
.ToList();
result.ModLoaders = result.ModLoaders
.Distinct()
.OrderBy(t => t)
.ToList();
// Tags
var categories = ((data["categories"] as JsonArray) ?? [])
.Select(t => t["id"]?.ToObject<int?>())
.Where(t => t.HasValue)
.Select(t => t.Value)
.Distinct()
.OrderByDescending(t => t);
foreach (var category in categories)
if (curseForgeCategoryLangKeys.TryGetValue(category, out var langKey))
_AddTag(result.Tags, langKey);
return result;
}
private static CompProjectBuildResult _BuildFromModrinth(JsonObject data)
{
var projectType = data["project_type"]?.ToString() ?? "";
var slug = (string)data["slug"];
var result = new CompProjectBuildResult
{
FromCurseForge = false,
// 简单信息
Id = (string)(data["project_id"] ?? data["id"]), // 两个 API 会返回的 key 不一样
Slug = slug,
RawName = (string)data["title"],
Description = (string)data["description"],
LastUpdate = data["date_modified"]?.ToObject<DateTime>(),
DownloadCount = (int)data["downloads"],
LogoUrl = (string)data["icon_url"],
Website = $"https://modrinth.com/{projectType}/{slug}",
// Type
Type = projectType switch
{
"modpack" => CompType.ModPack,
"resourcepack" => CompType.ResourcePack,
"shader" => CompType.Shader,
_ => CompType.Mod // Modrinth 将数据包标为 Mod
}
};
if (string.IsNullOrEmpty(result.LogoUrl))
result.LogoUrl = null;
// GameVersions
// 搜索结果的键为 versions,获取特定工程的键为 game_versions
result.Drops = ((data["game_versions"] ?? data["versions"]) as JsonArray ?? [])
.Select(v => McInstanceInfo.VersionToDrop((string)v))
.Where(v => v > 0)
.Distinct()
.OrderByDescending(v => v)
.ToList();
// Tags & ModLoaders
foreach (var category in (data["loaders"] as JsonArray)?.Select(t => t.ToString()) ?? [])
if (modrinthLoaderTypes.TryGetValue(category ?? "", out var loader))
result.ModLoaders.Add(loader);
foreach (var category in (data["categories"] as JsonArray)?.Select(t => t.ToString()) ?? [])
{
if (string.IsNullOrEmpty(category)) continue;
// 加载器
if (modrinthLoaderTypes.TryGetValue(category, out var loader))
{
result.ModLoaders.Add(loader);
}
else
{
// Modrinth 将数据包标为 Mod,若包含数据包版本,则优先标为 DataPack
if (category.Equals("datapack", StringComparison.OrdinalIgnoreCase))
{
result.Type = CompType.DataPack;
}
else
{
// 这些分类在资源包中不显示
if (resourcePackHiddenCategoryLangKeys.TryGetValue(category, out var hiddenLangKey))
{
if (result.Type != CompType.ResourcePack)
_AddTag(result.Tags, hiddenLangKey);
}
else
{
if (modrinthCategoryLangKeys.TryGetValue(category, out var langKey))
_AddTag(result.Tags, langKey);
}
}
}
}
return result;
}
private static CompType _GetCurseForgeTypeByWebsite(string website)
{
var websiteLower = (website ?? "").ToLowerInvariant();
if (websiteLower.Contains("/mc-mods/") || websiteLower.Contains("/mod/"))
return CompType.Mod;
if (websiteLower.Contains("/modpacks/"))
return CompType.ModPack;
if (websiteLower.Contains("/resourcepacks/") || websiteLower.Contains("/texture-packs/"))
return CompType.ResourcePack;
if (websiteLower.Contains("/shaders/"))
return CompType.Shader;
if (websiteLower.Contains("/worlds/"))
return CompType.World;
return CompType.DataPack;
}
private static void _AddTag(List<string> tags, string langKey)
{
var tag = Lang.Text(langKey);
if (!tags.Contains(tag))
tags.Add(tag);
}
private static readonly Dictionary<string, CompLoaderType> modrinthLoaderTypes =
new(StringComparer.OrdinalIgnoreCase)
{
["forge"] = CompLoaderType.Forge,
["fabric"] = CompLoaderType.Fabric,
["quilt"] = CompLoaderType.Quilt,
["neoforge"] = CompLoaderType.NeoForge
};
private static readonly Dictionary<int, string> curseForgeCategoryLangKeys =
new()
{
// Mod
[406] = "Download.Comp.Category.WorldGen",
[407] = "Download.Comp.Category.Biomes",
[410] = "Download.Comp.Category.Dimensions",
[408] = "Download.Comp.Category.OresResources",
[409] = "Download.Comp.Category.Structures",
[412] = "Download.Comp.Category.Technology",
[415] = "Download.Comp.Category.PipesLogistics",
[4843] = "Download.Comp.Category.Automation",
[417] = "Download.Comp.Category.Energy",
[4558] = "Download.Comp.Category.Redstone",
[436] = "Download.Comp.Category.FoodCooking",
[416] = "Download.Comp.Category.Farming",
[414] = "Download.Comp.Category.Transportation",
[420] = "Download.Comp.Category.Storage",
[419] = "Download.Comp.Category.Magic",
[422] = "Download.Comp.Category.Adventure",
[424] = "Download.Comp.Category.Decoration",
[411] = "Download.Comp.Category.Mobs",
[434] = "Download.Comp.Category.Equipment",
[6814] = "Download.Comp.Category.Optimization",
[9026] = "Download.Comp.Category.Creative",
[423] = "Download.Comp.Category.Display",
[435] = "Download.Comp.Category.Server",
[5191] = "Download.Comp.Category.Tweaks",
[421] = "Download.Comp.Category.Library",
// 整合包
[4484] = "Download.Comp.Category.Multiplayer",
[4479] = "Download.Comp.Category.Modpack.Hardcore",
[4483] = "Download.Comp.Category.Combat",
[4478] = "Download.Comp.Category.Modpack.Quests",
[4472] = "Download.Comp.Category.Technology",
[4473] = "Download.Comp.Category.Magic",
[4475] = "Download.Comp.Category.Adventure",
[4476] = "Download.Comp.Category.Modpack.Exploration",
[4477] = "Download.Comp.Category.Modpack.MiniGame",
[4471] = "Download.Comp.Category.Modpack.SciFi",
[4736] = "Download.Comp.Category.Modpack.Skyblock",
[5128] = "Download.Comp.Category.Modpack.VanillaPlus",
[4487] = "Download.Comp.Category.Modpack.Ftb",
[4480] = "Download.Comp.Category.Modpack.MapBased",
[4481] = "Download.Comp.Category.Modpack.SmallLight",
[4482] = "Download.Comp.Category.Modpack.ExtraLarge",
// 资源包
[403] = "Download.Comp.Category.VanillaLike",
[400] = "Download.Comp.Category.Realistic",
[401] = "Download.Comp.Category.Modern",
[402] = "Download.Comp.Category.Medieval",
[399] = "Download.Comp.Category.Steampunk",
[5244] = "Download.Comp.Category.Fonts",
[404] = "Download.Comp.Category.Animated",
[4465] = "Download.Comp.Category.ModSupport",
[393] = "Download.Comp.Category.ResourcePack.Resolution16x",
[394] = "Download.Comp.Category.ResourcePack.Resolution32x",
[395] = "Download.Comp.Category.ResourcePack.Resolution64x",
[396] = "Download.Comp.Category.ResourcePack.Resolution128x",
[397] = "Download.Comp.Category.ResourcePack.Resolution256x",
[398] = "Download.Comp.Category.ResourcePack.Resolution512xOrHigher",
[5193] = "Download.Comp.Type.DataPack", // 有这个 Tag 的项会从资源包请求中被移除
// 光影包
[6553] = "Download.Comp.Category.Realistic",
[6554] = "Download.Comp.Category.Fantasy",
[6555] = "Download.Comp.Category.VanillaLike",
// 数据包
[6948] = "Download.Comp.Category.Adventure",
[6949] = "Download.Comp.Category.DataPack.Fantasy",
[6950] = "Download.Comp.Category.Library",
[6952] = "Download.Comp.Category.Magic",
[6946] = "Download.Comp.Category.Mod.ModRelated",
[6951] = "Download.Comp.Category.Technology",
[6953] = "Download.Comp.Category.Utility",
// 世界
[248] = "Download.Comp.Category.Adventure",
[249] = "Download.Comp.Category.World.Creative",
[250] = "Download.Comp.Category.Modpack.MiniGame",
[251] = "Download.Comp.Category.World.Parkour",
[252] = "Download.Comp.Category.World.Puzzle",
[253] = "Download.Comp.Category.World.Survival",
[4464] = "Download.Comp.Category.World.ModWorld"
};
private static readonly Dictionary<string, string> resourcePackHiddenCategoryLangKeys =
new(StringComparer.OrdinalIgnoreCase)
{
["decoration"] = "Download.Comp.Category.Decoration",
["mobs"] = "Download.Comp.Category.Mobs",
["equipment"] = "Download.Comp.Category.Equipment"
};
private static readonly Dictionary<string, string> modrinthCategoryLangKeys =
new(StringComparer.OrdinalIgnoreCase)
{
// 共用
["technology"] = "Download.Comp.Category.Technology",
["magic"] = "Download.Comp.Category.Magic",
["adventure"] = "Download.Comp.Category.Adventure",
["utility"] = "Download.Comp.Category.Utility",
["optimization"] = "Download.Comp.Category.Optimization",
["vanilla-like"] = "Download.Comp.Category.VanillaLike",
["realistic"] = "Download.Comp.Category.Realistic",
// Mod / 数据包
["worldgen"] = "Download.Comp.Category.WorldGen",
["food"] = "Download.Comp.Category.FoodCooking",
["game-mechanics"] = "Download.Comp.Category.GameMechanics",
["transportation"] = "Download.Comp.Category.Transportation",
["storage"] = "Download.Comp.Category.Storage",
["social"] = "Download.Comp.Category.Server",
["library"] = "Download.Comp.Category.Library",
// 整合包
["multiplayer"] = "Download.Comp.Category.Multiplayer",
["challenging"] = "Download.Comp.Category.Modpack.Hardcore",
["combat"] = "Download.Comp.Category.Combat",
["quests"] = "Download.Comp.Category.Modpack.Quests",
["kitchen-sink"] = "Download.Comp.Category.Modpack.KitchenSink",
["lightweight"] = "Download.Comp.Category.Modpack.SmallLight",
// 资源包
["simplistic"] = "Download.Comp.Category.Simplistic",
["tweaks"] = "Download.Comp.Category.Tweaks",
["8x-"] = "Download.Comp.Category.ResourcePack.Resolution8xOrLower",
["16x"] = "Download.Comp.Category.ResourcePack.Resolution16x",
["32x"] = "Download.Comp.Category.ResourcePack.Resolution32x",
["48x"] = "Download.Comp.Category.ResourcePack.Resolution48x",
["64x"] = "Download.Comp.Category.ResourcePack.Resolution64x",
["128x"] = "Download.Comp.Category.ResourcePack.Resolution128x",
["256x"] = "Download.Comp.Category.ResourcePack.Resolution256x",
["512x+"] = "Download.Comp.Category.ResourcePack.Resolution512xOrHigher",
["audio"] = "Download.Comp.Category.Audio",
["fonts"] = "Download.Comp.Category.Fonts",
["models"] = "Download.Comp.Category.Models",
["gui"] = "Download.Comp.Category.Gui",
["locale"] = "Download.Comp.Category.Locale",
["core-shaders"] = "Download.Comp.Category.CoreShaders",
["modded"] = "Download.Comp.Category.ModSupport",
// 光影包
["fantasy"] = "Download.Comp.Category.Fantasy",
["semi-realistic"] = "Download.Comp.Category.SemiRealistic",
["cartoon"] = "Download.Comp.Category.Cartoon",
// 暂时不添加性能负荷 Tag
// potato / low / medium / high
["colored-lighting"] = "Download.Comp.Category.ColoredLighting",
["path-tracing"] = "Download.Comp.Category.PathTracing",
["pbr"] = "Download.Comp.Category.Pbr",
["reflections"] = "Download.Comp.Category.Reflections",
["iris"] = "Download.Comp.Category.Iris",
["optifine"] = "Download.Comp.Category.Optifine",
["vanilla"] = "Download.Comp.Filter.Loader.VanillaAvailable"
};
/// <summary>
/// 关联的数据库条目。若为 Nothing 则没有。
/// </summary>
private CompDatabaseEntry DatabaseEntry
{
get
{
if (!loadedDatabase)
{
loadedDatabase = true;
if (Type == CompType.Mod || Type == CompType.DataPack)
_DatabaseEntry = GetCompWikiEntryBySlug(Slug);
}
return _DatabaseEntry;
}
set
{
loadedDatabase = true;
_DatabaseEntry = value;
}
}
/// <summary>
/// MC 百科的页面 ID。若为 0 则没有。
/// </summary>
public int WikiId => DatabaseEntry is null ? 0 : DatabaseEntry.WikiId;
/// <summary>
/// 翻译后的中文名。若数据库没有则等同于 RawName。
/// </summary>
public string TranslatedName =>
Lang.IsChineseMainland && DatabaseEntry?.ChineseName is { Length: > 0 } cn
? cn
: RawName;
/// <summary>
/// 中文描述。若为 Nothing 则没有。
/// </summary>
public Task<string> ChineseDescription => GetChineseDescriptionAsync();
private async Task<string> GetChineseDescriptionAsync()
{
var from = FromCurseForge ? "curseforge" : "modrinth";
var para = FromCurseForge ? "modId" : "project_id";
string result = null;
var descHash = $"{Id}{ModBase.GetStringMD5(Description)}";
var cacheFilePath = $@"{ModBase.pathTemp}Cache\CompTranslation.ini";
var cacheTranslation = ModBase.ReadIni(cacheFilePath, descHash);
if (!string.IsNullOrWhiteSpace(cacheTranslation))
{
result = ModBase.Base64Decode(cacheTranslation);
return result;
}
try
{
var jsonObject = (JsonObject)await
Requester.FetchJsonAsync($"https://mod.mcimirror.top/translate/{from}/{Id}");
if (jsonObject.ContainsKey("translated"))
{
result = jsonObject["translated"].ToString();
ModBase.WriteIni(cacheFilePath, descHash, ModBase.Base64Encode(result));
}
}
catch (HttpRequestException ex)
{
if (ex.Message.Contains("404"))
{
ModMain.MyMsgBox(Lang.Text("Download.Comp.Detail.DescriptionNoTranslation"), Lang.Text("Download.Comp.Detail.DescriptionTranslationFailed"), Lang.Text("Download.Comp.Detail.KnownButton"));
return null;
}
ModBase.Log(
ex,
"获取中文描述时出现错误",
ModBase.LogLevel.Hint,
userSummary: Lang.Text("Minecraft.Comp.Error.OperationFailed"));
}
catch (Exception ex)
{
ModBase.Log(
ex,
"获取中文描述时出现错误",
ModBase.LogLevel.Hint,
userSummary: Lang.Text("Minecraft.Comp.Error.OperationFailed"));
}
return result;
}
/// <summary>
/// 将当前实例转为可用于保存缓存的 Json。
/// </summary>
public JsonObject ToJson()
{
var json = new JsonObject();
json["DataSource"] = FromCurseForge ? "CurseForge" : "Modrinth";
json["Type"] = (int)Type;
json["Slug"] = Slug;
json["Id"] = Id;
if (CurseForgeFileIds is not null)
json["CurseForgeFileIds"] = new JsonArray(CurseForgeFileIds.Select(i => (JsonNode)i).ToArray());
json["RawName"] = RawName;
json["Description"] = Description;
json["Website"] = Website;
if (LastUpdate is not null)
json["LastUpdate"] = LastUpdate;
json["DownloadCount"] = DownloadCount;
if (ModLoaders is not null && ModLoaders.Any())
json["ModLoaders"] = new JsonArray(ModLoaders.Select(m => (JsonNode)(int)m).ToArray());
json["Tags"] = new JsonArray(Tags.Select(s => (JsonNode)s).ToArray());
if (LogoUrl is not null)
json["LogoUrl"] = LogoUrl;
if (Drops.Any())
json["Drops"] = new JsonArray(Drops.Select(i => (JsonNode)i).ToArray());
json["CacheTime"] = DateTime.Now; // 用于检查缓存时间
return json;
}
/// <summary>
/// 将当前工程信息实例化为控件。
/// </summary>
/// <param name="showQuickDownload">是否在卡片右侧显示快速下载按钮(仅搜索结果页应传 true)。</param>
public MyVirtualizingElement<MyCompItem> ToCompItem(bool showMcVersionDesc, bool showLoaderDesc,
bool showQuickDownload = false)
{
// --- 1. 获取版本描述 (核心算法优化) ---
string gameVersionDescription;
if (Drops is null || !Drops.Any())
{
gameVersionDescription = Lang.Text("Download.Comp.Detail.CompItem.SnapshotOnly");
}
else
{
var segments = new List<string>();
var isOld = false;
for (var i = 0; i < Drops.Count; i++)
{
int startDrop = Drops[i], endDrop = Drops[i];
if (startDrop < 100)
{
if (segments.Any() && !isOld) break;
isOld = true;
}
// 查找连续的版本段
for (var ii = i + 1; ii < Drops.Count; ii++)
{
if (ModDownload.AllDrops is null || ModDownload.AllDrops.IndexOf(Drops[ii]) !=
ModDownload.AllDrops.IndexOf(endDrop) + 1) break;
endDrop = Drops[ii];
i = ii;
}
// 将段转为文本的逻辑
var startName = McInstanceInfo.DropToVersion(startDrop);
var endName = McInstanceInfo.DropToVersion(endDrop);
if (startDrop == endDrop)
{
segments.Add(startName);
}
else if (ModDownload.AllDrops?.Any() == true && startDrop >= ModDownload.AllDrops.First())
{
if (endDrop < 100)
{
segments.Clear();
segments.Add(Lang.Text("Download.Comp.Detail.CompItem.AllVersions"));
break;
}
segments.Add(endName + "+");
}
else if (endDrop < 100)
{
segments.Add(startName + "-");
break;
}
else if (ModDownload.AllDrops is null ||
ModDownload.AllDrops.IndexOf(endDrop) - ModDownload.AllDrops.IndexOf(startDrop) == 1)
{
segments.Add($"{startName}, {endName}");
}
else
{
segments.Add($"{startName}~{endName}");
}
}
gameVersionDescription = string.Join(", ", segments);
}
// --- 2. 获取 Mod 加载器描述 (使用 Switch 表达式) ---
var modLoadersForDesc = ModLoaders.ToList();
if (Config.Download.Comp.IgnoreQuilt) modLoadersForDesc.Remove(CompLoaderType.Quilt);
var (fullDesc, partDesc) = modLoadersForDesc.Count switch
{
0 => ModLoaders.Count == 1 ? (Lang.Text("Download.Comp.Type.Only", ModLoaders.Single().ToString()), ModLoaders.Single().ToString()) : (Lang.Text("Download.Comp.Type.Unknown"), ""),
1 => (Lang.Text("Download.Comp.Type.Only", modLoadersForDesc.Single().ToString()), modLoadersForDesc.Single().ToString()),
_ => GetMultiLoaderDesc()
};
// 局部函数处理复杂的“任意”判断逻辑
(string, string) GetMultiLoaderDesc()
{
var newestDrop = Drops?.FirstOrDefault() ?? 9999;
var isAny = ModLoaders.Contains(CompLoaderType.Forge) &&
(newestDrop < 140 || ModLoaders.Contains(CompLoaderType.Fabric)) &&
(newestDrop < 200 || ModLoaders.Contains(CompLoaderType.NeoForge)) &&
(newestDrop < 140 || ModLoaders.Contains(CompLoaderType.Quilt) ||
Config.Download.Comp.IgnoreQuilt);
var joined = string.Join(" / ", modLoadersForDesc);
return isAny ? (Lang.Text("Download.Comp.Type.Any"), "") : (joined, joined);
}
// --- 3. 实例化 UI (精简布局逻辑) ---
return new MyVirtualizingElement<MyCompItem>(() =>
{
var newItem = new MyCompItem { Tag = this };
ApplyLogoToMyImage(newItem.PathLogo);
var title = GetControlTitle(true);
newItem.Title = title.Key;
if (string.IsNullOrEmpty(title.Value))
((StackPanel)newItem.LabTitleRaw.Parent).Children.Remove(newItem.LabTitleRaw);
else
newItem.SubTitle = title.Value;
newItem.Tags = Tags;
newItem.Description = Description.Replace("\r", "").Replace("\n", "");
newItem.ShowDownloadBtn = showQuickDownload;
// 下边栏逻辑切换
newItem.LabVersion.Text = (showMcVersionDesc, showLoaderDesc) switch
{
(true, true) =>
$"{(string.IsNullOrEmpty(partDesc) ? "" : partDesc + " ")}{gameVersionDescription}",
(true, false) => gameVersionDescription,
(false, true) => fullDesc,
_ => "" // 处理隐藏逻辑见下
};
if (!showMcVersionDesc && !showLoaderDesc)
{
((Grid)newItem.SvgIconVersion.Parent).Children.Remove(newItem.SvgIconVersion);
((Grid)newItem.LabVersion.Parent).Children.Remove(newItem.LabVersion);
newItem.ColumnVersion1.Width = new GridLength(0);
newItem.ColumnVersion2.MaxWidth = 0;
newItem.ColumnVersion3.Width = new GridLength(0);
}
newItem.LabSource.Text = FromCurseForge ? "CurseForge" : "Modrinth";
if (LastUpdate is not null)
{
newItem.LabTime.Text = Lang.TimeSpan(LastUpdate.Value - DateTime.Now, 1);
}
else
{
newItem.LabTime.Visibility = Visibility.Collapsed;
newItem.ColumnTime1.Width =
newItem.ColumnTime2.Width = newItem.ColumnTime3.Width = new GridLength(0);
}
// 下载量数值缩写
newItem.LabDownload.Text = Lang.CompactNumber(DownloadCount);
return newItem;
})
{ Height = 64 };
}
public MyListItem ToListItem()
{
var result = new MyListItem
{
Title = TranslatedName,
Info = Description.Replace("\r", "").Replace("\n", ""),
Logo = string.IsNullOrEmpty(LogoUrl) ? $"{ModBase.pathImage}Icons/NoIcon.png" : LogoUrl,
Tags = Tags,
Tag = this,
LogoCornerRadius = new CornerRadius(6)
};
return result;
}
public void ApplyLogoToMyImage(MyImage img)
{
if (string.IsNullOrEmpty(LogoUrl))
{
img.Source = ModBase.pathImage + "Icons/NoIcon.png";
}
else
{
img.Source = LogoUrl;
img.FallbackSource = ModDownload.DlSourceModGet(LogoUrl);
}
}
public KeyValuePair<string, string> GetControlTitle(bool hasModLoaderDescription)
{
// 参考 #1567 测试例
var title = RawName;
List<string> subtitleList = new();
if (TranslatedName == RawName)
{
// --- 场景 A: 没有中文翻译 ---
var nameLists = TranslatedName.Split(new[] { " | ", " - ", "(", ")", "[", "]", "{", "}" },
StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim(' ', '/', '\\', '"'))
.Where(w => !string.IsNullOrEmpty(w))
.ToList();
if (nameLists.Count <= 1) return BuildResult(title, "");
var normalNameList = new List<string>();
foreach (var name in nameLists)
{
var lowerName = name.ToLower();
// 匹配缩写 (全大写且不是特定词)
if (name.ToUpper() == name && name != "FPS" && name != "HUD")
subtitleList.Add(name);
// 匹配加载器标记 (Forge/Fabric/Quilt 且去掉后不含其他字母)
else if (IsModLoaderMarker(lowerName))
subtitleList.Add(name);
else
normalNameList.Add(name);
}
if (!normalNameList.Any() || !subtitleList.Any())
return BuildResult(title, "");
title = string.Join(" - ", normalNameList);
}
else
{
// --- 场景 B: 有中文翻译 ---
// 尝试拆分:Title (EnglishName) - Suffix
title = TranslatedName.BeforeFirst(" (").BeforeFirst(" - ");
var suffix = "";
if (TranslatedName.AfterLast(")").Contains(" - "))
suffix = TranslatedName.AfterLast(")").AfterLast(" - ");
var englishName = TranslatedName;
if (!string.IsNullOrEmpty(suffix))
englishName = englishName.Replace(" - " + suffix, "");
englishName = englishName.Replace(title, "").Trim('(', ')', ' ');
subtitleList = englishName.Split(new[] { " | ", " - ", "(", ")", "[", "]", "{", "}" },
StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim(' ', '/'))
.Where(w => !string.IsNullOrEmpty(w))
.ToList();
// 特殊逻辑:如果看起来不像版本标记或特定缩写,则保持原名
if (subtitleList.Count > 1 &&
!subtitleList.Any(s => IsModLoaderMarker(s.ToLower())) &&
!(subtitleList.Count == 2 && subtitleList.Last().ToUpper() == subtitleList.Last()))
subtitleList = new List<string> { englishName };
if (!string.IsNullOrEmpty(suffix)) subtitleList.Add(suffix);
}
// --- 后处理: 构建 Subtitle 字符串 ---
var finalSubtitles = new List<string>();
foreach (var rawEx in subtitleList.Distinct())
{
var ex = rawEx;
var lowerEx = ex.ToLower();
var isModLoader = lowerEx.Contains("forge") || lowerEx.Contains("fabric") || lowerEx.Contains("quilt");
if (!hasModLoaderDescription && isModLoader) continue;
if (ex.Length < 16 && lowerEx.Contains("fabric") && lowerEx.Contains("forge")) continue;
if (isModLoader && !ex.Contains("版") &&
lowerEx.Replace("forge", "").Replace("fabric", "").Replace("quilt", "").Length <= 3)
ex = ex.Replace("Edition", "", StringComparison.OrdinalIgnoreCase)
.Replace("edition", "", StringComparison.OrdinalIgnoreCase)
.Trim().Capitalize() + Lang.Text("Download.Comp.Detail.CompItem.EditionSuffix");
// 规范化名称大小写
ex = ex.Replace("forge", "Forge").Replace("neo", "Neo").Replace("fabric", "Fabric")
.Replace("quilt", "Quilt");
finalSubtitles.Add(ex.Trim());
}
var subtitleResult = finalSubtitles.Any() ? " | " + string.Join(" | ", finalSubtitles) : "";
return BuildResult(title, subtitleResult);
bool IsModLoaderMarker(string input)
{
return (input.Contains("forge") || input.Contains("fabric") || input.Contains("quilt")) &&
!input.Replace("forge", "").Replace("fabric", "").Replace("quilt", "").RegexCheck("[a-z]+");
}
KeyValuePair<string, string> BuildResult(string t, string s)
{
return new KeyValuePair<string, string>(t, s);
}
}
// 辅助函数
/// <summary>
/// 检查是否与某个 Project 是相同的工程,只是在不同的网站。
/// </summary>
public bool IsLike(CompProject project)
{
if ((Id ?? "") == (project.Id ?? ""))
return true; // 相同实例
// 提取字符串中的字母和数字
string GetRaw(string data)
{
var result = new StringBuilder();
foreach (var r in data.Where(c => char.IsLetterOrDigit(c)))
result.Append(r);
return result.ToString().ToLower();
}
;
// 来自不同的网站
if (FromCurseForge == project.FromCurseForge)
return false;
// Mod 加载器一致
if (ModLoaders.Count != project.ModLoaders.Count || ModLoaders.Except(project.ModLoaders).Any())
return false;
// 若不为光影,则要求 MC 版本一致
if (Type != CompType.Shader && (Drops.Count != project.Drops.Count || Drops.Except(project.Drops).Any()))
return false;
// 最近更新时间差距在一周以内
if (LastUpdate is not null && project.LastUpdate is not null &&
Math.Abs((LastUpdate - project.LastUpdate).Value.TotalDays) > 7d)
return false;
// MCMOD 翻译名 / 原名 / 描述文本 / Slug 的英文部分相同
if ((TranslatedName ?? "") == (project.TranslatedName ?? "") ||
(RawName ?? "") == (project.RawName ?? "") || (Description ?? "") == (project.Description ?? "") ||
(GetRaw(Slug) ?? "") == (GetRaw(project.Slug) ?? ""))
{
ModBase.Log($"[Comp] 将 {RawName} ({Slug}) 与 {project.RawName} ({project.Slug}) 认定为相似工程");
// 如果只有一个有 DatabaseEntry,设置给另外一个
if (DatabaseEntry is null && project.DatabaseEntry is not null)
DatabaseEntry = project.DatabaseEntry;
if (DatabaseEntry is not null && project.DatabaseEntry is null)
project.DatabaseEntry = DatabaseEntry;
return true;
}
return false;
}
public override string ToString()
{
return $"{Id} ({Slug}): {RawName}";
}
public override bool Equals(object obj)
{
var project = obj as CompProject;
return project is not null && (Id ?? "") == (project.Id ?? "");
}
public static bool operator ==(CompProject left, CompProject right)
{
return EqualityComparer<CompProject>.Default.Equals(left, right);
}
public static bool operator !=(CompProject left, CompProject right)
{
return !(left == right);
}
}
// 输入与输出
public class CompProjectRequest
{
/// <summary>
/// 筛选 MC 版本。
/// </summary>
public string gameVersion = null;
/// <summary>
/// 筛选 Mod 加载器类别。
/// </summary>
public CompLoaderType modLoader = CompLoaderType.Any;
/// <summary>
/// 搜索的文本内容。
/// </summary>
public string searchText;
/// <summary>
/// 在进行中文搜索时,CurseForge 的替代搜索文本。
/// 由于 CurseForge API 在有任意关键词未匹配的时候就不显示结果,所以不能使用与 Modrinth 相同的算法。
/// </summary>
public string curseForgeAltSearchText;
/// <summary>
/// 搜索结果排序方式。
/// </summary>
public CompSortType sort = CompSortType.Default;
/// <summary>
/// 允许的来源。
/// </summary>
public CompSourceType source = CompSourceType.Any;
// 结果要求
/// <summary>
/// 加载后应输出到的结果存储器。
/// </summary>
public CompProjectStorage storage;
/// <summary>
/// 筛选资源标签。空字符串代表不限制。格式例如 "406/worldgen",分别是 CurseForge 和 Modrinth 的 ID。
/// </summary>
public string tag = "";
/// <summary>
/// 应当尽量达成的结果数量。
/// </summary>
public int targetResultCount;
// 输入内容
/// <summary>
/// 筛选资源种类。
/// </summary>
public CompType type;
/// <summary>
/// 构造函数。
/// </summary>
public CompProjectRequest(CompType type, CompProjectStorage storage, int targetResultCount)
{
this.type = type;
this.storage = storage;
this.targetResultCount = targetResultCount;
}
/// <summary>
/// 根据加载位置记录,是否还可以继续获取内容。
/// </summary>
public bool CanContinue
{
get
{
if (tag.StartsWithF("/") || !source.HasFlag(CompSourceType.CurseForge))
storage.curseForgeTotal = 0;
if (tag.EndsWithF("/") || !source.HasFlag(CompSourceType.Modrinth))
storage.modrinthTotal = 0;
if (storage.curseForgeTotal == -1 || storage.modrinthTotal == -1)
return true;
return storage.curseForgeOffset < storage.curseForgeTotal ||
storage.modrinthOffset < storage.modrinthTotal;
}
}
// 构造请求
/// <summary>
/// 获取对应的 CurseForge API 请求链接。若返回 Nothing 则为不进行 CurseForge 请求。
/// </summary>
public string GetCurseForgeAddress()
{
if (!source.HasFlag(CompSourceType.CurseForge))
return null;
if (tag.StartsWithF("/"))
storage.curseForgeTotal = 0;
if (storage.curseForgeTotal > -1 && storage.curseForgeTotal <= storage.curseForgeOffset)
return null;
// 应用筛选参数
var address =
new StringBuilder(
$"https://api.curseforge.com/v1/mods/search?gameId=432&sortOrder=desc&pageSize={compPageSize}");
switch (type)
{
case CompType.Mod:
{
address.Append("&classId=6");
break;
}
case CompType.ModPack:
{
address.Append("&classId=4471");
break;
}
case CompType.DataPack:
{
address.Append("&classId=6945");
break;
}
case CompType.Shader:
{
address.Append("&classId=6552");
break;
}
case CompType.ResourcePack:
{
address.Append("&classId=12");
break;
}
case CompType.World:
{
address.Append("&classId=17");
break;
}
}
if (!string.IsNullOrEmpty(tag)) address.Append($"&categoryId={tag.BeforeFirst("/")}");
if (modLoader != CompLoaderType.Any)
address.Append("&modLoaderType=").Append(((int)modLoader).ToString());
if (!string.IsNullOrEmpty(gameVersion))
address.Append("&gameVersion=").Append(gameVersion);
if (!string.IsNullOrEmpty(curseForgeAltSearchText ?? searchText))
address.Append("&searchFilter=").Append(WebUtility.UrlEncode(curseForgeAltSearchText ?? searchText));
if (storage.curseForgeOffset > 0)
address.Append("&index=").Append(storage.curseForgeOffset);
switch (sort)
{
case CompSortType.Relevance:
{
address.Append("&sortField=4");
break;
}
case CompSortType.Downloads:
{
address.Append("&sortField=6");
break;
}
case CompSortType.Follows:
{
address.Append("&sortField=2");
break;
}
case CompSortType.Newest:
{
address.Append("&sortField=11");
break;
}
case CompSortType.Updated:
{
address.Append("&sortField=3");
break;
}
default:
{
address.Append("&sortField=2");
break;
}
}
return address.ToString();
}
/// <summary>
/// 获取对应的 Modrinth API 请求链接。若返回 Nothing 则为不进行 Modrinth 请求。
/// </summary>
public string GetModrinthAddress()
{
if (!source.HasFlag(CompSourceType.Modrinth))
return null;
if (tag.EndsWithF("/"))
storage.modrinthTotal = 0;
if (storage.modrinthTotal > -1 && storage.modrinthTotal <= storage.modrinthOffset)
return null;
// 应用筛选参数
var address = $"https://api.modrinth.com/v2/search?limit={compPageSize}";
switch (sort)
{
case CompSortType.Relevance:
{
address += "&index=relevance";
break;
}
case CompSortType.Downloads:
{
address += "&index=downloads";
break;
}
case CompSortType.Follows:
{
address += "&index=follows";
break;
}
case CompSortType.Newest:
{
address += "&index=newest";
break;
}
case CompSortType.Updated:
{
address += "&index=updated";
break;
}
default:
{
address += "&index=relevance";
break;
}
}
if (!string.IsNullOrEmpty(searchText))
address += "&query=" + WebUtility.UrlEncode(searchText);
if (storage.modrinthOffset > 0)
address += "&offset=" + storage.modrinthOffset;
// facets=[["categories:'game-mechanics'"],["categories:'forge'"],["versions:1.19.3"],["project_type:mod"]]
var facets = new List<string>();
facets.Add($"[\"project_type:{ModBase.GetStringFromEnum(type).ToLower()}\"]");
if (!string.IsNullOrEmpty(tag))
facets.Add($"[\"categories:'{tag.AfterLast("/")}'\"]");
if (modLoader != CompLoaderType.Any)
facets.Add($"[\"categories:'{ModBase.GetStringFromEnum(modLoader).ToLower()}'\"]");
if (!string.IsNullOrEmpty(gameVersion))
facets.Add($"[\"versions:'{gameVersion}'\"]");
address += "&facets=[" + string.Join(",", facets) + "]";
return address;
}
// 相同判断
public override bool Equals(object obj)
{
var request = obj as CompProjectRequest;
return request is not null && type == request.type && targetResultCount == request.targetResultCount &&
(tag ?? "") == (request.tag ?? "") && modLoader == request.modLoader && source == request.source &&
(gameVersion ?? "") == (request.gameVersion ?? "") &&
(searchText ?? "") == (request.searchText ?? "") && sort == request.sort;
}
public static bool operator ==(CompProjectRequest left, CompProjectRequest right)
{
return EqualityComparer<CompProjectRequest>.Default.Equals(left, right);
}
public static bool operator !=(CompProjectRequest left, CompProjectRequest right)
{
return !(left == right);
}
}
public class CompProjectStorage
{
// 加载位置记录
public int curseForgeOffset;
public int curseForgeTotal = -1;
/// <summary>
/// 当前的错误信息。如果没有则为 Nothing。
/// </summary>
public string errorMessage = null;
public int modrinthOffset;
public int modrinthTotal = -1;
// 结果列表
/// <summary>
/// 可供展示的所有工程的列表。
/// </summary>
public List<CompProject> results = new();
}
// 实际的获取
private const int compPageSize = 40;
/// <summary>
/// 已知工程信息的缓存。
/// </summary>
public static ConcurrentDictionary<string, CompProject> compProjectCache = new();
/// <summary>
/// CurseForge 分类 URL 段 → classId 映射。提为 static 避免每次解析重新分配。
/// </summary>
private static readonly Dictionary<string, string> curseForgeCategoryClassIds = new()
{
{ "mc-mods", "6" },
{ "modpacks", "4471" },
{ "texture-packs", "12" },
{ "shaders", "6552" }
};
private enum ResourceSite { None, CurseForge, Modrinth }
/// <summary>
/// 用 Uri 解析单个 token 是否为受支持的 CurseForge/Modrinth 资源链接。
/// 成功时输出站点、分类段与 slugquery、fragment 会被自动丢弃)。
/// </summary>
private static bool TryParseResourceLink(string token, out ResourceSite site, out string category, out string slug)
{
site = ResourceSite.None;
category = string.Empty;
slug = string.Empty;
// 容忍无协议前缀(如直接粘贴 www.curseforge.com/...):原样试,再补 https:// 试
if (!Uri.TryCreate(token, UriKind.Absolute, out var uri) &&
!Uri.TryCreate($"https://{token}", UriKind.Absolute, out uri)) return false;
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) return false;
// 仅取 path 段,天然忽略 ?query 与 #fragment
var segments = uri.AbsolutePath.Split(['/'], StringSplitOptions.RemoveEmptyEntries);
// CurseForge: /minecraft/{category}/{slug}
if (IsHostOf(uri.Host, "curseforge.com"))
{
if (segments.Length < 3 || !segments[0].Equals("minecraft", StringComparison.OrdinalIgnoreCase)) return false;
site = ResourceSite.CurseForge;
category = segments[1];
slug = segments[2];
return true;
}
// Modrinth: /{type}/{slug}
if (IsHostOf(uri.Host, "modrinth.com"))
{
if (segments.Length < 2) return false;
site = ResourceSite.Modrinth;
category = segments[0]; // 类型段,当前解析未使用
slug = segments[1];
return true;
}
return false;
}
/// <summary>host 等于 domain 或为其子域,避免 evilcurseforge.com 之类的伪装域名。</summary>
private static bool IsHostOf(string host, string domain) =>
host.Equals(domain, StringComparison.OrdinalIgnoreCase) ||
host.EndsWith($".{domain}", StringComparison.OrdinalIgnoreCase);
/// <summary>从文本中找出首个可识别的资源链接 token,找不到返回 null。</summary>
private static string? FindFirstResourceLinkToken(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return null;
foreach (var token in text.Split([' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries))
if (TryParseResourceLink(token, out _, out _, out _)) return token;
return null;
}
/// <summary>
/// 从单条 CurseForge / Modrinth 资源链接解析出 projectId。无法识别或获取失败时返回 null。
/// </summary>
public static string? ResolveLinkToProjectId(string url)
{
// 纯链接(搜索框已抽出的单 token)直接命中;含周围文本(如剪贴板整段)时回退取首个链接
if (!TryParseResourceLink(url, out var site, out var category, out var slug))
{
var token = FindFirstResourceLinkToken(url);
if (token is null || !TryParseResourceLink(token, out site, out category, out slug)) return null;
}
if (site == ResourceSite.CurseForge)
{
var encodedSlug = WebUtility.UrlEncode(slug);
var json = ModDownload.DlModRequest<JsonObject>(
$"https://api.curseforge.com/v1/mods/search?gameId=432&slug={encodedSlug}");
var dataArray = (JsonArray)json["data"];
if (!dataArray.Any()) return null;
var receivedClassId = ((JsonObject)dataArray[0])["classId"]?.ToString();
if (!curseForgeCategoryClassIds.TryGetValue(category, out var targetClassId) ||
receivedClassId == targetClassId)
return dataArray[0]["id"]?.ToString();
// 分类不符:带 classId 重搜。结果与首次查询语义不同,使用独立变量
var filteredJson = ModDownload.DlModRequest<JsonObject>(
$"https://api.curseforge.com/v1/mods/search?gameId=432&slug={encodedSlug}&classId={targetClassId}");
var filteredDatas = (JsonArray)filteredJson["data"];
return filteredDatas.Any() ? filteredDatas[0]["id"]?.ToString() : null;
}
// Modrinthslug 进 path,用 EscapeDataString
var mr = ModDownload.DlModRequest<JsonObject>(
$"https://api.modrinth.com/v2/project/{Uri.EscapeDataString(slug)}");
return mr["id"]?.ToString();
}
/// <summary>
/// 若输入文本中恰好含 1 条 CurseForge/Modrinth 资源链接,返回该链接;含 0 条或 ≥2 条时返回 null。
/// </summary>
public static string? TryExtractSingleResourceLink(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return null;
var tokens = text.Split([' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
string? found = null;
foreach (var token in tokens)
{
if (!TryParseResourceLink(token, out _, out _, out _)) continue;
if (found is not null) return null; // ≥2 条链接,一条也不识别
found = token;
}
return found; // 恰好 1 条返回该链接;0 条返回 null
}
/// <summary>
/// 根据搜索请求获取一系列的工程列表。需要基于加载器运行。
/// </summary>
public static void CompProjectsGet(ModLoader.LoaderTask<CompProjectRequest, int> task)
{
var request = task.input;
var storage = request.storage;
// === Issue #2942: 搜索框单条资源链接识别 ===
var singleLink = TryExtractSingleResourceLink(request.searchText);
if (singleLink is not null)
{
// 已得到结果则直接结束(幂等,避免重复获取)
if (storage.results.Any()) return;
CompProject? project;
try
{
var projectId = ResolveLinkToProjectId(singleLink);
project = string.IsNullOrEmpty(projectId)
? null
: CompRequest.GetCompProjectsByIds(new List<string> { projectId }).FirstOrDefault();
}
catch (Exception ex)
{
ModBase.Log(ex, "[Comp] 解析资源链接失败");
throw new Exception(Lang.Text("Download.Comp.Link.ResolveFailed"));
}
// 解析或获取失败 → 提示
if (project is null)
throw new Exception(Lang.Text("Download.Comp.Link.ResolveFailed"));
// 类型与当前页不符 → 按无结果处理(与既有"无匹配结果"一致)
if (request.type != CompType.Any && project.Type != request.type)
throw new Exception(Lang.Text("Download.Comp.List.NoMatchingResults"));
// 命中:单条结果,隐藏分页
storage.results.Add(project);
storage.curseForgeTotal = 0;
storage.modrinthTotal = 0;
return;
}
// === /Issue #2942 ===
#region 状态与版本初步检查
if (storage.results.Count >= request.targetResultCount)
{
LogWrapper.Info($"[Comp] 已有 {storage.results.Count} 个结果,多于所需的 {request.targetResultCount} 个结果,结束处理");
return;
}
if (!request.CanContinue)
{
if (!storage.results.Any()) throw new Exception(Lang.Text("Download.Comp.List.NoMatchingResults"));
LogWrapper.Info(
$"[Comp] 已有 {storage.results.Count} 个结果,少于所需的 {request.targetResultCount} 个结果,但无法继续获取,结束处理");
return;
}
// 拒绝不支持的版本
if (request.modLoader == CompLoaderType.Quilt &&
McVersionComparer.CompareVersion(request.gameVersion ?? "1.15", "1.14") == -1)
throw new Exception(Lang.Text("Minecraft.Error.QuiltUnsupported", request.gameVersion));
#endregion
#region 处理搜索文本 (内嵌关键词转换逻辑)
var rawFilter = (request.searchText ?? "").Trim();
request.searchText = rawFilter;
var rawFilterLower = rawFilter.ToLower();
LogWrapper.Info("[Comp] 工程列表搜索原始文本:" + rawFilter);
// 中文请求关键字处理
var isChineseSearch = Lang.IsChineseMainland &&
RegexPatterns.HasChineseChar.IsMatch(rawFilter) &&
!string.IsNullOrEmpty(rawFilter);
if (isChineseSearch && request.type is CompType.Mod or CompType.DataPack)
{
var searchEntries = new List<ModBase.SearchEntry<CompDatabaseEntry>>();
using (var conn = CompDB)
{
var likeEscaped = rawFilter.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_");
var searchRes = conn.Query<CompDatabaseEntry>(
"SELECT * FROM ModTranslation WHERE ChineseName LIKE @p ESCAPE '\\' OR CurseForgeSlug LIKE @p ESCAPE '\\' OR ModrinthSlug LIKE @p ESCAPE '\\'",
new { p = $"%{likeEscaped}%" }).ToList();
foreach (var searchItem in searchRes)
{
if (searchItem.ChineseName.Contains("动态的树")) continue;
searchEntries.Add(new ModBase.SearchEntry<CompDatabaseEntry>
{
item = searchItem,
searchSource = new List<ModBase.SearchSource>
{
new(searchItem.ChineseName.BeforeFirst(" (").Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries), 1),
new(searchItem.ChineseName.AfterFirst(" (") + (searchItem.CurseForgeSlug ?? "") + (searchItem.ModrinthSlug ?? ""), 0.5)
}
});
}
}
var searchResults = ModBase.Search(searchEntries, request.searchText, 40, 0.2);
if (!searchResults.Any()) throw new Exception(Lang.Text("Download.Comp.List.NoResults"));
string[] ExtractWords(ModBase.SearchEntry<CompDatabaseEntry> result)
{
var word = "";
if (result.item.CurseForgeSlug is not null)
word += result.item.CurseForgeSlug.Replace("-", " ").Replace("/", " ") + " ";
if (result.item.ModrinthSlug is not null)
word += result.item.ModrinthSlug.Replace("-", " ").Replace("/", " ") + " ";
word += result.item.ChineseName.AfterLast(" (").TrimEnd(')', ' ').BeforeFirst(" - ")
.Replace(":", "").Replace("(", "").Replace(")", "").ToLower().Replace("/", " ").Replace("-", " ");
var words = word.ToLower().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
words = words.Select(w => w.TrimStart('{', '[', '(').TrimEnd('}', ']', ')')).Where(
w =>
{
if (w.Length <= 1) return false;
if (!w.Any(char.IsLetterOrDigit)) return false;
if (new[] { "the", "of", "for", "mod", "and", "forge", "fabric", "quilt", "neoforge" }.Contains(w)) return false;
if (ModBase.Val(w) > 0) return false;
if (w.Split(' ').Length > 3 && w.Contains("ftb")) return false;
return true;
}).Distinct().ToArray();
return words;
}
var wordModCount = new Dictionary<string, HashSet<int>>();
foreach (var result in searchResults)
foreach (var word in ExtractWords(result))
{
if (!wordModCount.TryGetValue(word, out var mods))
wordModCount[word] = mods = new HashSet<int>();
mods.Add(result.item.WikiId);
}
if (wordModCount.Count == 0) throw new Exception(Lang.Text("Download.Comp.List.NoResults"));
static string NormalizeName(string s) =>
new string(s.Where(c => !char.IsWhiteSpace(c) && !char.IsSurrogate(c)).ToArray());
string CanonName(string name) => NormalizeName(name.BeforeFirst(" ("));
var normalizedQuery = NormalizeName(rawFilter);
var exactNameEntries = searchResults
.Where(r => CanonName(r.item.ChineseName) == normalizedQuery).ToList();
var exactNameMods = exactNameEntries.Select(r => r.item.WikiId).Distinct().Count();
if (exactNameMods == 1)
{
var canonicalEntry = exactNameEntries
.OrderByDescending(r => r.absoluteRight)
.ThenByDescending(r => r.similarity)
.ThenBy(r => (r.item.CurseForgeSlug ?? r.item.ModrinthSlug ?? r.item.ChineseName).Length)
.First();
var canonicalWords = ExtractWords(canonicalEntry);
request.searchText = canonicalWords.Any()
? string.Join(" ", canonicalWords)
: string.Join(" ", wordModCount.OrderByDescending(w => w.Value.Count).Take(2).Select(w => w.Key));
var cfSlugs = exactNameEntries.Select(r => r.item.CurseForgeSlug)
.Where(s => s is not null).Distinct().ToList();
if (cfSlugs.Count == 1)
request.curseForgeAltSearchText = cfSlugs[0];
}
else
{
var maxCount = wordModCount.Values.Max(mods => mods.Count);
if (maxCount <= 1)
{
var best = searchResults
.OrderByDescending(r => r.absoluteRight)
.ThenByDescending(r => r.similarity)
.ThenBy(r => (r.item.CurseForgeSlug ?? r.item.ModrinthSlug ?? r.item.ChineseName).Length)
.First();
request.searchText = string.Join(" ", ExtractWords(best));
}
else
{
var tied = wordModCount
.Where(w => w.Value.Count == maxCount)
.OrderBy(w => w.Key.Length)
.ToList();
var anchorMods = tied[0].Value;
request.searchText = string.Join(" ", tied
.Where(w => w.Value.Overlaps(anchorMods))
.Take(3)
.Select(w => w.Key));
}
}
LogWrapper.Debug("[Comp] 中文搜索基础关键词:" + request.searchText);
}
// 最终处理关键字:分割、去重
void processKeywords(ref string text)
{
if (text is null) return;
text = text.ToLowerInvariant();
var words = new List<string>();
foreach (var keyword in text.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries))
{
var cleanKeyword = keyword.Trim('[', ']');
if (string.IsNullOrEmpty(cleanKeyword)) continue;
if (new[] { "forge", "fabric", "for", "mod", "quilt" }.Contains(cleanKeyword))
{
LogWrapper.Debug("[Comp] 已跳过搜索关键词:" + cleanKeyword);
continue;
}
words.Add(cleanKeyword);
}
if (rawFilter.Length > 0 && !words.Any())
text = rawFilter;
else
text = string.Join(" ", words.Distinct());
// 例外项:OptiForge、OptiFabric(拆词后因为包含 Forge/Fabric 导致无法搜到实际的 Mod)
if (rawFilter.Replace(" ", "").ContainsF("optiforge", true)) text = "optiforge";
if (rawFilter.Replace(" ", "").ContainsF("optifabric", true)) text = "optifabric";
}
if (request.curseForgeAltSearchText is not null)
{
processKeywords(ref request.curseForgeAltSearchText);
LogWrapper.Debug("[Comp] 工程列表搜索最终文本(CurseForge):" + request.curseForgeAltSearchText);
}
processKeywords(ref request.searchText);
LogWrapper.Debug("[Comp] 工程列表搜索最终文本:" + request.searchText);
task.Progress = 0.1;
#endregion
var realResults = new List<CompProject>();
#region 网络请求与结果获取 (Retry 循环)
while (true)
{
var rawResults = new List<CompProject>();
Exception lastError = null;
var resultsLock = new object();
// 1.14 以下 Forge 筛选处理
var isOldForgeRequest = request.modLoader == CompLoaderType.Forge &&
McInstanceInfo.VersionToDrop(request.gameVersion, true) < 140;
if (isOldForgeRequest) request.modLoader = CompLoaderType.Any;
var curseForgeUrl = request.GetCurseForgeAddress();
var modrinthUrl = request.GetModrinthAddress();
if (isOldForgeRequest) request.modLoader = CompLoaderType.Forge;
var tasks = new List<Task>();
// CurseForge 线程内嵌
if (curseForgeUrl is not null)
tasks.Add(Task.Run(() =>
{
try
{
LogWrapper.Info("[Comp] 开始从 CurseForge 获取列表:" + curseForgeUrl);
var json = ModDownload.DlModRequest<JsonObject>(curseForgeUrl);
var projects = json["data"].AsArray().Select(j => new CompProject((JsonObject)j))
.Where(p => !(request.type == CompType.ResourcePack && p.Tags.Contains(Lang.Text("Download.Comp.Type.DataPack"))))
.ToList();
lock (resultsLock)
{
rawResults.AddRange(projects);
}
storage.curseForgeOffset += projects.Count;
storage.curseForgeTotal = json["pagination"]["totalCount"].ToObject<int>();
}
catch (Exception ex)
{
lastError = ex;
LogWrapper.Error(ex, "CurseForge 获取失败");
}
}));
// Modrinth 线程内嵌
if (modrinthUrl is not null)
tasks.Add(Task.Run(() =>
{
try
{
LogWrapper.Info("[Comp] 开始从 Modrinth 获取列表:" + modrinthUrl);
var json = ModDownload.DlModRequest<JsonObject>(modrinthUrl);
var projects = json["hits"].AsArray().Select(j => new CompProject((JsonObject)j)).ToList();
lock (resultsLock)
{
rawResults.AddRange(projects);
}
storage.modrinthOffset += projects.Count;
storage.modrinthTotal = json["total_hits"].ToObject<int>();
}
catch (Exception ex)
{
lastError = ex;
LogWrapper.Error(ex, "Modrinth 获取失败");
}
}));
Task.WaitAll(tasks.ToArray());
task.Progress += 0.4;
if (task.IsAborted) return;
// 过滤老版本 Forge
if (isOldForgeRequest)
rawResults = rawResults.Where(p => !p.ModLoaders.Any() || p.ModLoaders.Contains(CompLoaderType.Forge))
.ToList();
// 错误检查与空结果处理
if (!rawResults.Any())
{
if (lastError is not null) throw lastError;
// 处理各平台不兼容报错... (此处省略具体 Exception 文本以保持简略)
throw new Exception(Lang.Text("Download.Comp.List.NoResultsSimple"));
}
#region 去重与分页判断
// 优先保留 Modrinth 顺序并去重
var processedResults = rawResults.OrderBy(x => x.FromCurseForge)
.Where(r => !realResults.Any(b => r.IsLike(b)) && !storage.results.Any(b => r.IsLike(b)))
.ToList();
realResults.AddRange(processedResults);
LogWrapper.Info($"[Comp] 去重、筛选后累计新增结果 {processedResults.Count} 个(目前已有结果 {storage.results.Count} 个)");
if (realResults.Count + storage.results.Count < request.targetResultCount && request.CanContinue &&
lastError is null)
{
LogWrapper.Info("[Comp] 数量不足,继续加载下一页");
continue;
}
break;
#endregion
}
#endregion
#region 排序与最终输出
if (request.sort != CompSortType.Default)
{
if (task.IsAborted) throw new ThreadInterruptedException();
storage.results.AddRange(realResults); // 遵从API返回顺序
return;
}
var scores = new Dictionary<CompProject, double>();
Func<CompProject, double> getDownloadCountMult = p =>
{
switch (request.type)
{
case CompType.Mod:
case CompType.ModPack: return p.FromCurseForge ? 1 : 7;
case CompType.DataPack: return p.FromCurseForge ? 10 : 1;
case CompType.ResourcePack:
case CompType.Shader: return p.FromCurseForge ? 1 : 5;
default: return 1;
}
};
if (string.IsNullOrEmpty(rawFilter))
{
foreach (var res in realResults) scores.Add(res, res.DownloadCount * getDownloadCountMult(res));
}
else
{
var searchEntries = new List<ModBase.SearchEntry<CompProject>>();
foreach (var res in realResults)
{
scores.Add(res,
(Lang.IsChineseMainland && res.WikiId > 0 ? 0.2 : 0) +
Math.Log10(Math.Max(res.DownloadCount, 1) * getDownloadCountMult(res)) / 9);
searchEntries.Add(new ModBase.SearchEntry<CompProject>
{
item = res,
searchSource = new List<ModBase.SearchSource>
{
new((isChineseSearch ? res.TranslatedName : res.RawName).Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries), 1),
new(res.Description, 0.05)
}
});
}
var searchRes = ModBase.Search(searchEntries, rawFilter, 101, -1);
foreach (var item in searchRes)
scores[item.item] +=
(item.absoluteRight ? 10 : item.similarity) /
(searchRes.First().absoluteRight ? 10 : searchRes.First().similarity);
}
if (task.IsAborted) throw new ThreadInterruptedException();
storage.results.AddRange(scores.OrderByDescending(s => s.Value).Select(s => s.Key));
#endregion
}
#endregion
#region CompFile | 文件信息
// 类定义
public enum CompFileStatus
{
Release = 1, // 枚举值来源:https://docs.curseforge.com/#tocS_FileReleaseType
Beta = 2,
Alpha = 3
}
public class CompFile
{
/// <summary>
/// 该文件的所有必要依赖工程的 Project.Id。
/// </summary>
public readonly List<string> Dependencies = new();
/// <summary>
/// 下载量计数。注意,该计数仅为一个来源,无法反应两边加起来的下载量,且 CurseForge 可能错误地返回 0。
/// </summary>
public readonly int DownloadCount;
/// <summary>
/// 下载的文件名。
/// </summary>
public readonly string FileName;
/// <summary>
/// 该文件来自 CurseForge 还是 Modrinth。
/// </summary>
public readonly bool FromCurseForge;
// <summary>
// 未经处理的支持的游戏版本列表。
// </summary>
public readonly List<string> RawGameVersions = new();
/// <summary>
/// 支持的游戏版本列表。类型包括:"26.1.5""26.1""26.1 预览版""1.18.5""1.18""1.18 预览版""21w15a""未知版本"。
/// </summary>
public readonly List<string> GameVersions = new();
/// <summary>
/// 文件的 SHA1 或 MD5。
/// </summary>
public readonly string Hash;
/// <summary>
/// 用于唯一性鉴别该文件的 ID。CurseForge 中为 123456 的大整数,Modrinth 中为英文乱码的 Version 字段。
/// </summary>
public readonly string Id;
/// <summary>
/// 支持的 Mod 加载器列表。可能为空。
/// </summary>
public readonly List<CompLoaderType> ModLoaders = new();
/// <summary>
/// 该文件的所有可选依赖工程的 Project.Id。
/// </summary>
public readonly List<string> OptionalDependencies = new();
/// <summary>
/// 该文件所属项目的 ID。
/// </summary>
public readonly string ProjectId;
/// <summary>
/// 该文件的所有必要依赖工程的原始 ID。
/// 这些 ID 可能没有加载,在加载后会添加到 Dependencies 中(主要是因为 Modrinth 返回的是字符串 ID 而非 Slug,导致 Project.Id 查询不到)。
/// </summary>
public readonly List<string> RawDependencies = new();
/// <summary>
/// 该文件的所有可选依赖工程的原始 ID。
/// 这些 ID 可能没有加载,在加载后会添加到 OptionalDependencies 中(主要是因为 Modrinth 返回的是字符串 ID 而非 Slug,导致 Project.Id 查询不到)。
/// </summary>
public readonly List<string> RawOptionalDependencies = new();
/// <summary>
/// 发布时间。
/// </summary>
public readonly DateTime ReleaseDate;
/// <summary>
/// 发布状态:Release/Beta/Alpha。
/// </summary>
public readonly CompFileStatus Status;
// 源信息
/// <summary>
/// 文件的种类。
/// </summary>
public readonly CompType Type;
// 描述性信息
/// <summary>
/// 文件描述名(并非文件名,是自定义的字段)。对很多 Mod,这会给出 Mod 版本号。
/// </summary>
public string DisplayName;
/// <summary>
/// 文件所有可能的下载源。
/// </summary>
public List<string> DownloadUrls;
/// <summary>
/// Mod 版本号。
/// 不一定是标准格式。CurseForge 上默认为 Nothing。
/// </summary>
public string Version;
// 实例化
/// <summary>
/// 从文件 Json 中初始化实例。若出错会抛出异常。
/// </summary>
public CompFile(JsonObject data, CompType defaultType)
{
Type = defaultType;
if (data.ContainsKey("FromCurseForge"))
{
#region CompJson
FromCurseForge = data["FromCurseForge"].ToObject<bool>();
Id = data["Id"].ToString();
DisplayName = data["DisplayName"].ToString();
if (data.ContainsKey("Version"))
Version = data["Version"].ToString();
ReleaseDate = data["ReleaseDate"].ToObject<DateTime>();
DownloadCount = data["DownloadCount"].ToObject<int>();
Status = (CompFileStatus)data["Status"].ToObject<int>();
if (data.ContainsKey("FileName"))
FileName = data["FileName"].ToString();
if (data.ContainsKey("DownloadUrls"))
DownloadUrls = data["DownloadUrls"].ToObject<List<string>>();
if (data.ContainsKey("ModLoaders"))
ModLoaders = data["ModLoaders"].ToObject<List<CompLoaderType>>();
if (data.ContainsKey("Hash"))
Hash = data["Hash"].ToString();
if (data.ContainsKey("RawGameVersions"))
RawGameVersions = data["RawGameVersions"].ToObject<List<string>>();
if (data.ContainsKey("GameVersions"))
GameVersions = data["GameVersions"].ToObject<List<string>>();
if (data.ContainsKey("RawDependencies"))
RawDependencies = data["RawDependencies"].ToObject<List<string>>();
if (data.ContainsKey("Dependencies"))
Dependencies = data["Dependencies"].ToObject<List<string>>();
if (data.ContainsKey("RawOptionalDependencies"))
RawOptionalDependencies = data["RawOptionalDependencies"].ToObject<List<string>>();
if (data.ContainsKey("OptionalDependencies"))
OptionalDependencies = data["OptionalDependencies"].ToObject<List<string>>();
}
#endregion
else
{
FromCurseForge = data.ContainsKey("gameId");
if (FromCurseForge)
{
#region CurseForge
// 简单信息
Id = data["id"].ToString();
ProjectId = data["modId"].ToString();
DisplayName = data["displayName"].ToString().Replace(" ", "").Trim(' ');
Version = null;
ReleaseDate = data["fileDate"].ToObject<DateTime>();
Status = (CompFileStatus)data["releaseType"].ToObject<int>();
DownloadCount = (int)data["downloadCount"];
FileName = (string)data["fileName"];
Hash =
(string)((JsonArray)data["hashes"]).ToList().FirstOrDefault(s => s["algo"].ToObject<int>() == 1)?[
"value"];
if (Hash is null)
Hash = (string)((JsonArray)data["hashes"]).ToList()
.FirstOrDefault(s => s["algo"].ToObject<int>() == 2)?["value"];
// DownloadAddress
var url = data["downloadUrl"]?.ToString() ?? "";
// TODO: 移除龙猫写的直接下载,换用提醒用户手动下载相关模组
if (string.IsNullOrWhiteSpace(url))
url =
$"https://edge.forgecdn.net/files/{int.Parse(Id[..4])}/{int.Parse(Id[4..])}/{FileName}";
url = url.Replace(FileName, WebUtility.UrlEncode(FileName)); // 对文件名进行编码
url = url.Replace("+", "%20"); // 修正被编码成 + 的空格,CurseForge 会对 + 号也进行编码
DownloadUrls = ModDownload.DlSourceModDownloadGet(HandleCurseForgeDownloadUrls(url)); // 添加镜像源
// Dependencies
if (data.ContainsKey("dependencies"))
{
RawDependencies = data["dependencies"].AsArray()
.Where(d => d["relationType"].ToObject<int>() == 3 &&
d["modId"].ToObject<int>() != 306612 && d["modId"].ToObject<int>() != 634179)
.Select(d => d["modId"].ToString()).ToList(); // 种类为必要依赖
// 排除 Fabric API 和 Quilt API
RawOptionalDependencies = data["dependencies"].AsArray()
.Where(d => d["relationType"].ToObject<int>() == 2 &&
d["modId"].ToObject<int>() != 306612 && d["modId"].ToObject<int>() != 634179)
.Select(d => d["modId"].ToString()).ToList(); // 种类为可选依赖
// 排除 Fabric API 和 Quilt API
}
// GameVersions
RawGameVersions = data["gameVersions"].AsArray().Select(t => t.ToString().Trim().ToLower()).ToList();
GameVersions = RawGameVersions.Where(v => McInstanceInfo.IsFormatFit(v))
.Select(v => v.Replace("-snapshot", Lang.Text("Download.Comp.Detail.CompItem.PreviewSuffix"))).Distinct().ToList();
if (GameVersions.Count > 1)
{
GameVersions = GameVersions.Sort(McVersionComparer.CompareVersionGe).ToList();
if (Type == CompType.ModPack)
GameVersions = new List<string> { GameVersions[0] }; // 整合包理应只 "支持" 一个版本
}
else if (GameVersions.Count == 1)
{
GameVersions = GameVersions.ToList();
}
else
{
GameVersions = new List<string> { Lang.Text("Download.Comp.Detail.CompItem.UnknownVersion") };
}
// ModLoaders
ModLoaders = new List<CompLoaderType>();
if (RawGameVersions.Contains("forge"))
ModLoaders.Add(CompLoaderType.Forge);
if (RawGameVersions.Contains("fabric"))
ModLoaders.Add(CompLoaderType.Fabric);
if (RawGameVersions.Contains("quilt"))
ModLoaders.Add(CompLoaderType.Quilt);
if (RawGameVersions.Contains("neoforge"))
ModLoaders.Add(CompLoaderType.NeoForge);
}
#endregion
else
{
#region Modrinth
// 简单信息
Id = (string)data["id"];
ProjectId = (string)data["project_id"];
DisplayName = data["name"].ToString().Replace(" ", "").Trim(' ');
Version = (string)data["version_number"];
ReleaseDate = data["date_published"].ToObject<DateTime>();
Status = data["version_type"].ToString() == "release" ? CompFileStatus.Release :
data["version_type"].ToString() == "beta" ? CompFileStatus.Beta : CompFileStatus.Alpha;
DownloadCount = (int)data["downloads"];
if (((JsonArray)data["files"]).Any()) // 可能为空
{
var file = data["files"][0];
FileName = (string)file["filename"];
DownloadUrls = ModDownload.DlSourceModDownloadGet(file["url"].ToString()); // 同时添加了镜像源
Hash = (string)file["hashes"]["sha1"];
}
// ModLoaders
// 结果可能混杂着 Mod、数据包和服务端插件
var rawLoaders = data["loaders"].AsArray().Select(v => v.ToString()).ToList();
ModLoaders = new List<CompLoaderType>();
if (Type == CompType.Mod) // 以尽量宽容的方式检测加载器,以免同时兼容两种的项被删除
{
if (rawLoaders.Intersect(new[] { "bukkit", "folia", "paper", "purpur", "spigot" }).Any())
Type = CompType.Plugin; // Veinminer Enchantment 同时支持服务端与 Fabric
if (rawLoaders.Contains("datapack"))
Type = CompType.DataPack;
if (rawLoaders.Contains("forge"))
{
ModLoaders.Add(CompLoaderType.Forge);
Type = CompType.Mod;
}
if (rawLoaders.Contains("neoforge"))
{
ModLoaders.Add(CompLoaderType.NeoForge);
Type = CompType.Mod;
}
if (rawLoaders.Contains("fabric"))
{
ModLoaders.Add(CompLoaderType.Fabric);
Type = CompType.Mod;
}
if (rawLoaders.Contains("quilt"))
{
ModLoaders.Add(CompLoaderType.Quilt);
Type = CompType.Mod;
}
}
else if (Type == CompType.DataPack)
{
if (rawLoaders.Intersect(new[] { "bukkit", "folia", "paper", "purpur", "spigot" }).Any())
Type = CompType.Plugin;
if (rawLoaders.Contains("forge"))
{
ModLoaders.Add(CompLoaderType.Forge);
Type = CompType.Mod;
}
if (rawLoaders.Contains("neoforge"))
{
ModLoaders.Add(CompLoaderType.NeoForge);
Type = CompType.Mod;
}
if (rawLoaders.Contains("fabric"))
{
ModLoaders.Add(CompLoaderType.Fabric);
Type = CompType.Mod;
}
if (rawLoaders.Contains("quilt"))
{
ModLoaders.Add(CompLoaderType.Quilt);
Type = CompType.Mod;
}
if (rawLoaders.Contains("datapack"))
Type = CompType.DataPack;
}
// Dependencies
if (data.ContainsKey("dependencies"))
{
RawDependencies = data["dependencies"].AsArray()
.Where(d => (string)d["dependency_type"] == "required" &&
d["project_id"] is not null &&
(string)d["project_id"] != "P7dR8mSH" &&
(string)d["project_id"] != "qvIfYCYJ" && d["project_id"] is not null)
.Select(d => d["project_id"].ToString()).ToList(); // 种类为必要依赖
// 排除 Fabric API 和 Quilt API
// 有时候真的会空……
RawOptionalDependencies = data["dependencies"].AsArray()
.Where(d => (string)d["dependency_type"] == "optional" &&
d["project_id"] is not null &&
(string)d["project_id"] != "P7dR8mSH" &&
(string)d["project_id"] != "qvIfYCYJ" && d["project_id"] is not null)
.Select(d => d["project_id"].ToString()).ToList(); // 种类为可选依赖
// 排除 Fabric API 和 Quilt API
// 有时候真的会空……
}
// GameVersions
RawGameVersions = data["game_versions"].AsArray().Select(t => t.ToString().Trim().ToLower()).ToList();
GameVersions = RawGameVersions.Where(v => v.Contains(".")).Select(v =>
v.Contains("-") ? v.BeforeFirst("-") + Lang.Text("Download.Comp.Detail.CompItem.PreviewSuffix") : v.StartsWithF("b1.") ? Lang.Text("Download.Comp.Detail.CompItem.AncientVersion") : v).Distinct().ToList();
if (GameVersions.Count > 1)
{
GameVersions = GameVersions.Sort(McVersionComparer.CompareVersionGe).ToList();
if (Type == CompType.ModPack)
GameVersions = new List<string> { GameVersions[0] }; // 整合包理应只 “支持” 一个版本
}
else if (GameVersions.Count == 1)
{
}
// 无需处理
else if (RawGameVersions.Any(v => v.RegexCheck("[0-9]{2}w[0-9]{2}[a-z]")))
{
GameVersions = RawGameVersions.Where(v => v.RegexCheck("[0-9]{2}w[0-9]{2}[a-z]")).ToList();
}
else
{
GameVersions = new List<string> { Lang.Text("Download.Comp.Detail.CompItem.UnknownVersion") };
}
#endregion
}
}
}
/// <summary>
/// 发布状态的友好描述。例如:"正式版""Beta 版"。
/// </summary>
public string StatusDescription
{
get
{
switch (Status)
{
case CompFileStatus.Release:
{
return Lang.Text("Download.Comp.Detail.FileList.ReleaseType.Release");
}
case CompFileStatus.Beta:
{
return Lang.Text("Download.Comp.Detail.FileList.ReleaseType.Beta");
}
default:
{
return Lang.Text("Download.Comp.Detail.FileList.ReleaseType.Alpha");
}
}
}
}
// 下载信息
/// <summary>
/// 下载信息是否可用。
/// </summary>
public bool Available => FileName is not null && DownloadUrls is not null;
/// <summary>
/// 获取下载信息。
/// </summary>
/// <param name="localAddress">目标本地文件夹,或完整的文件路径。会自动判断类型。</param>
/// <param name="reason">下载原因。</param>
/// <returns>下载信息。</returns>
public DownloadFile ToNetFile(string localAddress, DownloadReason reason = DownloadReason.Standalone)
{
return ToNetFile(localAddress, reason, RawGameVersions.FirstOrDefault(), ModLoaders.FirstOrDefault());
}
/// <summary>
/// 获取下载信息。
/// </summary>
/// <param name="localAddress">目标本地文件夹,或完整的文件路径。会自动判断类型。</param>
/// <param name="reason">下载原因。</param>
/// <param name="version">实例版本。</param>
/// <param name="modLoader">实例的模组加载器。</param>
/// <returns>下载信息。</returns>
public DownloadFile ToNetFile(
string localAddress,
DownloadReason reason,
string? version,
CompLoaderType modLoader = CompLoaderType.Any)
{
if (DownloadUrls is null)
{
throw new InvalidCastException("DownloadUrls 为空");
}
return new DownloadFile(HandleModrinthDownloadUrls(DownloadUrls, reason, version, modLoader),
localAddress + (localAddress.EndsWithF(@"\") ? CompFileNameSanitize(FileName) : ""),
new ModBase.FileChecker(hash: Hash), true);
}
/// <summary>
/// 对之前错误的 CurseForge 的下载地址进行修正。
/// </summary>
public static string HandleCurseForgeDownloadUrls(string url)
{
return url.Replace("-service.overwolf.wtf", ".forgecdn.net").Replace("://media.", "://edge.")
.Replace("://mediafilez.", "://edge.");
}
/// <summary>
/// 对 Modrinth 下载链接添加上报参数。
/// </summary>
/// <param name="urls">下载链接。</param>
/// <param name="reason">下载原因。</param>
/// <param name="version">实例版本。</param>
/// <param name="modLoader">实例的模组加载器。</param>
/// <returns>处理后的下载链接。</returns>
public static IEnumerable<string> HandleModrinthDownloadUrls(
IEnumerable<string> urls,
DownloadReason reason = DownloadReason.Standalone,
string? version = null,
CompLoaderType modLoader = CompLoaderType.Any)
{
foreach (var url in urls)
{
if (!url.Contains("modrinth", StringComparison.InvariantCultureIgnoreCase))
{
yield return url;
continue;
}
var sb = new StringBuilder(url);
sb.Append(url.Contains('?') ? "&mr_download_reason=" : "?mr_download_reason=");
sb.Append(reason.ToString().ToLowerInvariant());
if (version is not null)
{
sb.Append("&mr_game_version=");
sb.Append(version);
}
if (modLoader != CompLoaderType.Any)
{
sb.Append("&mr_loader=");
sb.Append(modLoader.ToString().ToLowerInvariant());
}
yield return sb.ToString();
}
}
/// <summary>
/// 将当前实例转为可用于保存缓存的 Json。
/// </summary>
public JsonObject ToJson()
{
var json = new JsonObject();
json.Add("FromCurseForge", FromCurseForge);
json.Add("Id", Id);
if (Version is not null)
json.Add("Version", Version);
json.Add("DisplayName", DisplayName);
json.Add("ReleaseDate", ReleaseDate);
json.Add("DownloadCount", DownloadCount);
json.Add("ModLoaders", new JsonArray(ModLoaders.Select(m => (JsonNode)(int)m).ToArray()));
json.Add("RawGameVersions", new JsonArray(RawGameVersions.Select(s => (JsonNode)s).ToArray()));
json.Add("GameVersions", new JsonArray(GameVersions.Select(s => (JsonNode)s).ToArray()));
json.Add("Status", (int)Status);
if (FileName is not null)
json.Add("FileName", FileName);
if (DownloadUrls is not null)
json.Add("DownloadUrls", new JsonArray(DownloadUrls.Select(s => (JsonNode)s).ToArray()));
if (Hash is not null)
json.Add("Hash", Hash);
json.Add("RawDependencies", new JsonArray(RawDependencies.Select(s => (JsonNode)s).ToArray()));
json.Add("RawOptionalDependencies", new JsonArray(RawOptionalDependencies.Select(s => (JsonNode)s).ToArray()));
json.Add("Dependencies", new JsonArray(Dependencies.Select(s => (JsonNode)s).ToArray()));
json.Add("OptionalDependencies", new JsonArray(OptionalDependencies.Select(s => (JsonNode)s).ToArray()));
return json;
}
/// <summary>
/// 将当前文件信息实例化为控件。
/// </summary>
public MyVirtualizingElement<MyListItem> ToListItem(MyListItem.ClickEventHandler onClick,
MyIconButton.ClickEventHandler? onSaveClick = null,
bool badDisplayName = false)
{
return new MyVirtualizingElement<MyListItem>(() =>
{
// 1. 获取基础描述信息
var title = badDisplayName ? FileName : DisplayName;
var info = new List<string>();
// 2. 填充信息列表
if (title != FileName.BeforeLast("."))
info.Add(FileName.BeforeLast("."));
if (Dependencies.Any())
info.Add(Lang.Text("Download.Comp.Detail.FileList.DependencyCount", Dependencies.Count()));
// 简化后的游戏版本逻辑喵
var snapshotKeywords = new[] { "w", "snapshot", "rc", "pre", "experimental", "-" };
if (GameVersions.All(ver =>
!ver.Contains('.') || snapshotKeywords.Any(s => ver.ContainsF(s, true))))
info.Add(Lang.Text("Download.Comp.Detail.FileList.GameVersion", string.Join("、", GameVersions)));
if (DownloadCount > 0)
info.Add(Lang.Text("Common.Format.DownloadCount", Lang.CompactNumber(DownloadCount)));
info.Add(Lang.Text("Download.Comp.Detail.FileList.Updated", Lang.TimeSpan(ReleaseDate - DateTime.Now)));
if (Status != CompFileStatus.Release)
info.Add(StatusDescription);
// 3. 建立控件
var newItem = new MyListItem
{
Title = title,
SnapsToDevicePixels = true,
Height = 42,
Type = MyListItem.CheckType.Clickable,
Tag = this,
Info = string.Join(" | ", info),
// 使用 switch 表达式精简 Logo 选择喵!
Logo = Status switch
{
CompFileStatus.Release => ModBase.pathImage + "Icons/R.png",
CompFileStatus.Beta => ModBase.pathImage + "Icons/B.png",
_ => ModBase.pathImage + "Icons/A.png"
}
};
newItem.Click += onClick;
// 4. 建立另存为按钮
if (onSaveClick is not null)
{
var btnSave = new MyIconButton { SvgIcon = "lucide/save", ToolTip = Lang.Text("Download.Version.SaveAs") };
ToolTipService.SetPlacement(btnSave, PlacementMode.Center);
ToolTipService.SetVerticalOffset(btnSave, 30);
ToolTipService.SetHorizontalOffset(btnSave, 2);
btnSave.Click += onSaveClick;
newItem.Buttons = new[] { btnSave };
}
return newItem;
})
{ Height = 42 };
}
public override string ToString()
{
return $"{Id}: {FileName}";
}
}
// 获取
/// <summary>
/// 已知文件信息的缓存。
/// </summary>
public static ConcurrentDictionary<string, List<CompFile>> compFilesCache = new();
/// <summary>
/// 获取某个工程下的全部文件列表。
/// 必须在工作线程执行,失败会抛出异常。
/// </summary>
public static List<CompFile> CompFilesGet(string projectId, bool fromCurseForge)
{
// 1. 获取工程对象(使用 TryGetValue 提高效率并防止并发异常)
CompProject targetProject = null;
if (!compProjectCache.TryGetValue(projectId, out targetProject))
{
var url = fromCurseForge
? $"https://api.curseforge.com/v1/mods/{projectId}"
: $"https://api.modrinth.com/v2/project/{projectId}";
if (fromCurseForge)
{
var json = ModDownload.DlModRequest<JsonObject>(url);
targetProject = new CompProject((JsonObject)json["data"]);
}
else
{
targetProject = new CompProject(ModDownload.DlModRequest<JsonObject>(url));
}
// 假设 CompProject 构造函数内已处理缓存,否则此处应添加缓存逻辑
}
// 2. 获取并缓存文件列表
if (!compFilesCache.ContainsKey(projectId))
{
ModBase.Log("[Comp] 开始获取文件列表:" + projectId);
JsonArray resultJsonArray;
if (fromCurseForge)
{
// 注意:若 pageSize=10000 失效,需考虑分页逻辑
var response = ModDownload.DlModRequest<JsonObject>(
$"https://api.curseforge.com/v1/mods/{projectId}/files?pageSize=10000"
);
resultJsonArray = (JsonArray)response["data"];
}
else
{
resultJsonArray =
ModDownload.DlModRequest<JsonArray>($"https://api.modrinth.com/v2/project/{projectId}/version?include_changelog=false");
}
compFilesCache[projectId] = resultJsonArray.Select(a => new CompFile((JsonObject)a, targetProject.Type))
.Where(a => a.Available).GroupBy(a => a.Id).Select(g => g.First())
.ToList(); // 使用 GroupBy 实现更高效的 Distinct
}
var currentFiles = compFilesCache[projectId];
// 3. 提取所有需要获取信息的前置 ID(合并必要和可选)
var allRawDeps = currentFiles.SelectMany(f => f.RawDependencies.Concat(f.RawOptionalDependencies)).Distinct()
.ToList();
var undoneDeps = allRawDeps.Where(id => !compProjectCache.ContainsKey(id)).ToList();
// 4. 批量请求缺失的前置工程信息
if (undoneDeps.Any())
{
ModBase.Log($"[Comp] {projectId} 需要补全信息的依赖项共 {undoneDeps.Count} 个");
JsonArray projects;
if (fromCurseForge)
{
// 1. 获取响应并转为 JsonObject
var response = ModDownload.DlModRequest<JsonObject>(
"https://api.curseforge.com/v1/mods",
"POST",
"{\"modIds\": [" + string.Join(",", undoneDeps) + "]}",
"application/json"
);
// 2. 提取 data 数组
projects = (JsonArray)response["data"];
}
else
{
projects = ModDownload.DlModRequest<JsonArray>(
$"https://api.modrinth.com/v2/projects?ids=[\"{undoneDeps.Join("\",\"")}\"]");
}
foreach (var project in projects)
new CompProject((JsonObject)project);
}
// 5. 建立文件与依赖工程的关联映射
// 优化:预先筛选出存在于缓存中的依赖工程,避免在多层循环中重复查询字典
var availableDeps = allRawDeps.Where(id => compProjectCache.ContainsKey(id) && (id ?? "") != (projectId ?? ""))
.Select(id => compProjectCache[id]).ToList();
foreach (var file in currentFiles)
foreach (var dep in availableDeps)
{
// 处理必要依赖
if (file.RawDependencies.Contains(dep.Id))
if (!file.Dependencies.Contains(dep.Id))
file.Dependencies.Add(dep.Id);
// 处理可选依赖
if (file.RawOptionalDependencies.Contains(dep.Id))
if (!file.OptionalDependencies.Contains(dep.Id))
file.OptionalDependencies.Add(dep.Id);
}
return compFilesCache[projectId];
}
public static string CompFileNameGet(CompProject proj, CompFile file)
{
string fileName;
if ((proj.TranslatedName ?? "") == (proj.RawName ?? ""))
{
fileName = file.FileName;
}
else
{
var chineseName = proj.TranslatedName.BeforeFirst(" (").BeforeFirst(" - ").Replace(@"\", "")
.Replace("/", "").Replace("|", "").Replace(":", "").Replace("<", "").Replace(">", "")
.Replace("*", "").Replace("?", "").Replace("\"", "").Replace(" ", "");
fileName = Config.Download.Comp.NameFormatV2 switch
{
0 => $"【{chineseName}】{file.FileName}",
1 => $"[{chineseName}] {file.FileName}",
2 => $"{chineseName}-{file.FileName}",
3 => $"{file.FileName}-{chineseName}",
_ => file.FileName
};
}
if (file.Type == CompType.Mod)
fileName = fileName.Replace("~", "-"); // ~ 会导致 Mixin 加载失败
return CompFileNameSanitize(fileName);
}
public static string CompFileNameSanitize(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
return "download";
var sanitized = new StringBuilder(fileName.Length);
foreach (var c in fileName)
{
sanitized.Append(c switch
{
'\\' => '',
'/' => '',
':' => '',
'*' => '',
'?' => '',
'"' => '',
'<' => '',
'>' => '',
'|' => '',
_ when char.IsControl(c) => '_',
_ => c
});
}
var result = sanitized.ToString().Trim();
return result is "" or "." or ".." ? "download" : result;
}
#region 快速下载(资源卡片下载按钮)
/// <summary>
/// 资源卡片的快速下载入口:按 <see cref="Config.Download.Comp.QuickDownloadBehavior"/> 指定的行为,
/// 下载该资源最新(优先 Release、其次最新发布)的兼容版本到目标实例或文件夹。
/// 由 <see cref="MyCompItem"/> 上的快速下载按钮调用。快速下载不会自动安装前置。
/// </summary>
public static void QuickDownload(CompProject project)
{
ModBase.RunInNewThread(() =>
{
try
{
HintService.Hint(Lang.Text("Download.Comp.QuickDownload.Hint.Loading"), HintType.Info);
var files = FilterFilesByType(
CompFilesGet(project.Id, project.FromCurseForge).Where(f => f.Available).ToList(),
project.Type);
if (files.Count == 0)
{
HintService.Hint(Lang.Text("Download.Comp.QuickDownload.Hint.NoFile"), HintType.Info);
return;
}
var behavior = Config.Download.Comp.QuickDownloadBehavior;
if (behavior == 0)
{
// 总是询问:弹「方式选择」
int? choice = ModBase.RunInUiWait(() =>
{
var options = new List<IMyRadio>
{
new MyRadioBox { Text = Lang.Text("Download.Comp.QuickDownload.ChooseMethod.CurrentInstance") },
new MyRadioBox { Text = Lang.Text("Download.Comp.QuickDownload.ChooseMethod.AskInstance") },
new MyRadioBox { Text = Lang.Text("Download.Comp.QuickDownload.ChooseMethod.AskPath") }
};
return ModMain.MyMsgBoxSelect(options,
Lang.Text("Download.Comp.QuickDownload.ChooseMethod.Title"),
button1: Lang.Text("Common.Action.Continue"),
button2: Lang.Text("Common.Action.Cancel"));
});
if (choice is null) return; // 用户取消
behavior = choice.Value + 1; // 0→1 当前实例, 1→2 选实例, 2→3 选路径
}
switch (behavior)
{
case 1: // 下载到当前选中实例
_QuickDownloadToInstance(project, files, ModInstanceList.McMcInstanceSelected);
break;
case 2: // 询问并下载到选择的实例
{
var instance = _QuickDownloadPickInstance(project, files);
if (instance is null) return;
_QuickDownloadToInstance(project, files, instance);
break;
}
case 3: // 询问并下载到一个路径
_QuickDownloadToFolder(project, files);
break;
}
}
catch (Exception ex)
{
ModBase.Log(
ex,
"[Comp] 快速下载失败",
ModBase.LogLevel.Feedback,
userSummary: Lang.Text("Minecraft.Comp.Error.OperationFailed"));
}
}, "Comp QuickDownload");
}
/// <summary>下载到指定实例的最新兼容版本。</summary>
private static void _QuickDownloadToInstance(CompProject project, List<CompFile> files, McInstance? instance)
{
if (instance is null)
{
HintService.Hint(Lang.Text("Download.Comp.QuickDownload.Hint.NoInstance"), HintType.Info);
return;
}
if (!instance.IsLoaded) instance.Load();
var compatible = files
.Where(f => IsInstanceSuitableForFile(instance, f, _ResolveLoaders(f, project)))
.ToList();
var file = _PickLatestFile(compatible);
if (file is null)
{
HintService.Hint(Lang.Text("Download.Comp.QuickDownload.Hint.NoCompatibleFile"), HintType.Info);
return;
}
var folder = instance.PathIndie + _GetSubFolder(project.Type);
Directory.CreateDirectory(folder);
var target = Path.Combine(folder, CompFileNameGet(project, file));
_StartQuickDownload(file, target);
HintService.Hint(Lang.Text("Download.Comp.QuickDownload.Hint.DownloadStarted", project.RawName), HintType.Success);
}
/// <summary>弹实例列表让用户选择,返回选中的实例(兼容者优先、当前选中实例居首);取消或无兼容实例返回 null。</summary>
private static McInstance? _QuickDownloadPickInstance(CompProject project, List<CompFile> files)
{
var needLoad = ModInstanceList.mcInstanceListLoader.State != ModBase.LoadState.Finished;
if (needLoad)
{
HintService.Hint(Lang.Text("Download.Comp.QuickDownload.Hint.Loading"), HintType.Info);
ModLoader.LoaderFolderRun(ModInstanceList.mcInstanceListLoader, ModFolder.mcFolderSelected,
ModLoader.LoaderFolderRunType.ForceRun, 1, "versions\\", true);
}
var compatible = ModInstanceList.mcInstanceList.Values
.SelectMany(l => l)
.Where(v => v is not null && files.Any(f => IsInstanceSuitableForFile(v, f, _ResolveLoaders(f, project))))
.ToList();
if (compatible.Count == 0)
{
HintService.Hint(Lang.Text("Download.Comp.QuickDownload.Hint.NoCompatibleInstance"), HintType.Info);
return null;
}
// 当前选中实例排首位(呼应 issue「第一行应为当前所选实例」)
var current = ModInstanceList.McMcInstanceSelected;
if (current is not null)
compatible = compatible
.OrderBy(v => v == current ? 0 : 1)
.ThenBy(v => v.Name)
.ToList();
int? idx = ModBase.RunInUiWait(() =>
{
var options = compatible
.Select(v => (IMyRadio)new MyRadioBox { Text = v.Name })
.ToList();
return ModMain.MyMsgBoxSelect(options,
Lang.Text("Download.Comp.QuickDownload.ChooseInstance.Title"),
button1: Lang.Text("Common.Action.Continue"),
button2: Lang.Text("Common.Action.Cancel"));
});
if (idx is null) return null;
return compatible[idx.Value];
}
/// <summary>下载最新版本到用户选择的文件夹。</summary>
private static void _QuickDownloadToFolder(CompProject project, List<CompFile> files)
{
var file = _PickLatestFile(files);
if (file is null)
{
HintService.Hint(Lang.Text("Download.Comp.QuickDownload.Hint.NoFile"), HintType.Info);
return;
}
var saveFolder = ModBase.RunInUiWait(() =>
SystemDialogs.SelectFolder(Lang.Text("Download.Comp.QuickDownload.Hint.SelectFolder")));
if (string.IsNullOrWhiteSpace(saveFolder)) return; // 取消
var target = Path.Combine(saveFolder, CompFileNameGet(project, file));
_StartQuickDownload(file, target);
HintService.Hint(Lang.Text("Download.Comp.QuickDownload.Hint.DownloadStarted", project.RawName), HintType.Success);
}
/// <summary>构造并启动单文件下载任务(与详情页 Save_Click 末段一致)。</summary>
private static void _StartQuickDownload(CompFile file, string target)
{
var desc = file.Type switch
{
CompType.Mod => Lang.Text("Download.Comp.Type.Mod"),
CompType.ResourcePack => Lang.Text("Download.Comp.Type.ResourcePack"),
CompType.Shader => Lang.Text("Download.Comp.Type.Shader"),
CompType.DataPack => Lang.Text("Download.Comp.Type.DataPack"),
CompType.World => Lang.Text("Download.Comp.Type.World"),
_ => Lang.Text("Download.Comp.Type.Mod")
};
var loaderName = Lang.Text("Download.Comp.Detail.DownloadResource", desc,
ModBase.GetFileNameWithoutExtentionFromPath(target));
var loaders = new List<ModLoader.LoaderBase>
{
new LoaderDownload(Lang.Text("Download.Comp.Detail.DownloadFile"),
new List<DownloadFile>
{
file.Type == CompType.Mod
? file.ToNetFile(target)
: file.ToNetFile(target, DownloadReason.Standalone, null)
})
{
ProgressWeight = 6,
block = true
}
};
if (file.Type == CompType.World)
{
var extractDir = Path.GetDirectoryName(target);
loaders.Add(new ModLoader.LoaderTask<int, int>(
Lang.Text("Download.Comp.Detail.InstallWorld"),
_ => ModBase.ExtractFile(target, extractDir, Encoding.UTF8))
{
ProgressWeight = 0.1d,
block = true
});
loaders.Add(new ModLoader.LoaderTask<int, int>(
Lang.Text("Download.Comp.Detail.CleanCache"),
_ => System.IO.File.Delete(target)));
}
var loader = new ModLoader.LoaderCombo<int>(loaderName, loaders)
{
OnStateChanged = ModDownloadLib.LoaderStateChangedHintOnly
};
loader.Start(1);
ModLoader.LoaderTaskbarAdd(loader);
ModMain.frmMain.BtnExtraDownload.ShowRefresh();
ModMain.frmMain.BtnExtraDownload.Ribble();
}
/// <summary>根据资源类型返回实例内的目标子文件夹(与 Save_Click 一致)。</summary>
private static string _GetSubFolder(CompType type) => type switch
{
CompType.Mod => "mods\\",
CompType.ResourcePack => "resourcepacks\\",
CompType.Shader => "shaderpacks\\",
CompType.World => "saves\\",
_ => ""
};
/// <summary>取文件自身声明的加载器,缺失时回退到工程的加载器。</summary>
private static List<CompLoaderType> _ResolveLoaders(CompFile file, CompProject project)
=> file.ModLoaders.Count > 0 ? file.ModLoaders : project.ModLoaders;
/// <summary>
/// 按资源类型筛选文件,与详情页 GetResults 一致:Modrinth 会返回 Mod / 服务端插件 / 数据包混合的列表,
/// 需过滤回当前类型,避免快速下载到另一种产物。光影与资源包不筛(原版光影以资源包格式发布)。
/// </summary>
private static List<CompFile> FilterFilesByType(List<CompFile> files, CompType type)
{
if (type == CompType.Shader || type == CompType.ResourcePack)
return files;
return files.Where(f => f.Type == type).ToList();
}
/// <summary>判断某实例是否兼容该文件(基于 Save_Click 的 isVersionSuitable,补全了 Quilt 判定)。</summary>
public static bool IsInstanceSuitableForFile(McInstance? version, CompFile file, List<CompLoaderType> allowedLoaders)
{
if (version is null) return false;
if (!version.IsLoaded) version.Load();
// 只对 Mod 和数据包进行版本检测
if (file.Type == CompType.Mod || file.Type == CompType.DataPack)
if (file.GameVersions.Any(v => v.Contains(".")) &&
!file.GameVersions.Any(v => v.Contains(".") && v == version.Info.VanillaName))
return false;
// 加载器判定
if (allowedLoaders.Count == 0) return true; // 无要求
if (allowedLoaders.Contains(CompLoaderType.Forge) && version.Info.HasForge) return true;
if (allowedLoaders.Contains(CompLoaderType.Forge) && version.Info.HasCleanroom)
return true;
if (allowedLoaders.Contains(CompLoaderType.Fabric) &&
(version.Info.HasFabric || version.Info.HasLegacyFabric)) return true;
if (allowedLoaders.Contains(CompLoaderType.NeoForge) && version.Info.HasNeoForge) return true;
if (allowedLoaders.Contains(CompLoaderType.Quilt) && version.Info.HasQuilt) return true;
if (allowedLoaders.Contains(CompLoaderType.LiteLoader) && version.Info.HasLiteLoader) return true;
return false;
}
/// <summary>挑选最新文件:优先 Release,其次按发布日期最新。compatFilter 为空时不做兼容过滤。</summary>
private static CompFile? _PickLatestFile(List<CompFile> files, Func<CompFile, bool>? compatFilter = null)
{
var candidates = (compatFilter is null ? files : files.Where(compatFilter)).ToList();
if (candidates.Count == 0) return null;
return candidates
.OrderByDescending(f => f.Status == CompFileStatus.Release)
.ThenByDescending(f => f.ReleaseDate)
.First();
}
#endregion
/// <summary>
/// 预载包含大量 CompFile 的卡片,添加必要的元素和前置列表。
/// 前置列表(必要 / 可选)会被放入可折叠栏:必要前置默认展开,可选前置默认收起。
/// </summary>
public static void CompFilesCardPreload(StackPanel stack, List<CompFile> files)
{
// 获取卡片对应的前置 ID
// 如果为整合包就不会有 Dependencies 信息,所以不用管
var deps = files.SelectMany(f => f.Dependencies).Distinct().ToList();
var optionalDeps = files.SelectMany(f => f.OptionalDependencies).Distinct().ToList();
if (!deps.Any() && !optionalDeps.Any())
return;
// 必要前置:默认展开
_AddDependencyBar(stack, deps,
Lang.Text("Download.Comp.Detail.FileList.RequiredDependencies"), collapsed: false);
// 可选前置:默认收起(库 Mod 可能有大量可选前置,参见 Issue #2873
_AddDependencyBar(stack, optionalDeps,
Lang.Text("Download.Comp.Detail.FileList.OptionalDependencies"), collapsed: true);
// 添加结尾间隔(版本列表标题)
stack.Children.Add(new TextBlock
{
Text = Lang.Text("Download.Comp.Detail.FileList.VersionList"), FontSize = 14d,
HorizontalAlignment = HorizontalAlignment.Left, Margin = new Thickness(6d, 12d, 0d, 5d)
});
}
/// <summary>
/// 将一组前置依赖(按工程 ID)渲染为一个可折叠栏并加入 <paramref name="stack"/>。
/// 仅保留在 compProjectCache 中有信息的前置;若过滤后为空则不添加任何折叠栏。
/// </summary>
/// <param name="collapsed">是否默认收起。前置 item 全部加入即可,靠 MyVirtualizingElement 在可见时才实例化。</param>
private static void _AddDependencyBar(StackPanel stack, List<string> depIds, string title, bool collapsed)
{
if (depIds is null || !depIds.Any())
return;
depIds.Sort();
var projects = new List<CompProject>();
foreach (var dep in depIds)
{
if (compProjectCache.TryGetValue(dep, out var project))
projects.Add(project);
else
ModBase.Log($"[Comp] 未找到 ID {dep} 的前置信息", ModBase.LogLevel.Debug);
}
if (!projects.Any())
return;
var bar = new MyCollapseBar
{
Title = $"{title} ({projects.Count})",
IsCollapsed = collapsed
};
foreach (var project in projects)
bar.ContentPanel.Children.Add(project.ToCompItem(false, false));
stack.Children.Add(bar);
}
#endregion
}