CI / Go Backend (push) Canceled after 0s
初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器 HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块 Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚 AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
3824 lines
154 KiB
C#
3824 lines
154 KiB
C#
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。
|
||
/// 例如:261(26.1.x)、180(1.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>
|
||
/// 来源网站的工程页面网址。确保格式一定标准。
|
||
/// CurseForge:https://www.curseforge.com/minecraft/mc-mods/jei
|
||
/// Modrinth:https://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 资源链接。
|
||
/// 成功时输出站点、分类段与 slug(query、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;
|
||
}
|
||
|
||
// Modrinth:slug 进 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
|
||
}
|