Files
MRCC/launcher/PCL-CE/Plain Craft Launcher 2/Modules/Minecraft/ModComp.cs
T
xyou f70b061d1a
CI / Go Backend (push) Canceled after 0s
feat: 项目初始化 + 3D方块世界原型 + AI助搭系统
初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器

HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块

Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚

AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
2026-08-08 14:07:56 +08:00

3824 lines
154 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}