From ec20a53a61cc4e6c8051699aae54334958e28d40 Mon Sep 17 00:00:00 2001 From: std-external <201375710+std-external@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:12:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(search):=20=E9=95=9C=E5=83=8F=E6=B2=A1?= =?UTF-8?q?=E7=BB=99=E5=87=BA=E6=96=87=E4=BB=B6=E5=AF=B9=E5=BA=94=E7=9A=84?= =?UTF-8?q?=20Mod=20=E6=97=B6=E4=B8=8D=E5=86=8D=E6=8A=8A=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=B8=85=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 社区搜索里点下载显示「文件列表为空」:云端结果的文件带着完整的 GameBanana file id 和镜像下载地址,但 picker 只保留 mods 非空的文件, 而镜像现在把 mods 返回成空数组(/api/v2/mod/list 只剩它自己托管的 Mod,文件与 Mod 的对应关系没了),于是每个 Mod 的文件都被过滤掉。 现在 mods 为空也照常列出文件,名字退回文件说明(空则用投稿名), 没有 mods 信息就不按 Mod 去重、不高亮「已安装」;只有 mods 能给出去重 依据时才沿用旧逻辑。 顺带修掉单文件快捷路径把 id "-1" 当 GameBanana file id 用的问题: 只由镜像托管的文件现在把 URL 交给下载器,不再请求 gamebanana-files/-1(返回「文件未找到」)。 --- src/celemod-ui/package.json | 1 + src/celemod-ui/src/components/ModList.tsx | 71 ++++---------- src/celemod-ui/src/downloadFiles.test.ts | 110 ++++++++++++++++++++++ src/celemod-ui/src/downloadFiles.ts | 97 +++++++++++++++++++ 4 files changed, 227 insertions(+), 52 deletions(-) create mode 100644 src/celemod-ui/src/downloadFiles.test.ts create mode 100644 src/celemod-ui/src/downloadFiles.ts diff --git a/src/celemod-ui/package.json b/src/celemod-ui/package.json index cbed3c0..c6d5aff 100644 --- a/src/celemod-ui/package.json +++ b/src/celemod-ui/package.json @@ -16,6 +16,7 @@ "test:catalog-download": "node --import tsx --test src/catalogDownload.test.ts", "test:crash-trace": "node --import tsx --test src/components/crashTrace.test.ts", "test:deep-link": "node --import tsx --test src/deepLink.test.ts", + "test:download-files": "node --import tsx --test src/downloadFiles.test.ts", "test:mod-updates": "node --import tsx --test src/modUpdates.test.ts", "test:update-notes": "node --import tsx --test src/components/UpdateNotes.test.tsx" }, diff --git a/src/celemod-ui/src/components/ModList.tsx b/src/celemod-ui/src/components/ModList.tsx index 92d4114..7a90e06 100644 --- a/src/celemod-ui/src/components/ModList.tsx +++ b/src/celemod-ui/src/components/ModList.tsx @@ -27,6 +27,7 @@ import { PopupContext, createPopup } from "./Popup"; import { ProgressIndicator } from "./Progress"; import { sanitizeDescriptionHtml } from "../sanitizeDescriptionHtml"; import { getAvailableModPageUrl, getOtherModPageSource } from "../modPage"; +import { pickDownloadFiles } from "../downloadFiles"; // @ts-ignore import celemodIcon from "../resources/Celemod.png"; @@ -348,8 +349,14 @@ export const Mod = memo( down(mod.name, downloadInfo); } else { if (downloadInfo.length === 1) { + const [file] = downloadInfo; ctx.hide(); - down(downloadInfo[0].name, downloadInfo[0].id); + // Files only the mirror hosts have no GameBanana file id: + // hand their URL to the downloader instead of id "-1". + down( + file.name, + parseInt(file.id) === -1 ? file.url : file.id, + ); } else if (downloadInfo.length === 0) { ctx.setError(_i18n.t("文件列表为空")); } else { @@ -696,61 +703,21 @@ export const ModList = (props: { const mod = { name: mod2.name, downloadKey: String(mod2.gameBananaId ?? mod2.id ?? mod2.name), - downloadUrl: () => { - const dedup = new Set(); - if (!mod2.gameBananaId || mod2.gameBananaId < 0) { - const file = mod2.files[0]; - if (!file?.url) return []; - return [ - { - id: file.gameBananaId > 0 ? file.gameBananaId.toString() : "-1", - name: file.mods[0]?.name ?? mod2.name, - size: formatSize(file.size), - url: file.url, - isPrimary: true, - isInstalled: - file.mods.length > 0 && - file.mods.every((fileMod) => - installedModVersions - .get(fileMod.name.toLocaleLowerCase()) - ?.has(fileMod.version), - ), - }, - ]; - } - const files = mod2.files.filter((file) => { - if (file.mods.length === 0) return false; - if (dedup.has(file.mods[0].id)) return false; - dedup.add(file.mods[0].id); - return true; - }); - const normalizeName = (value: string) => - value.toLocaleLowerCase().replace(/[^a-z0-9]/g, ""); - const submissionName = normalizeName(mod2.name); - const primaryIndex = Math.max( - 0, - files.findIndex((file) => - file.mods.some( - (fileMod) => normalizeName(fileMod.name) === submissionName, - ), - ), - ); - - return Promise.resolve( - files.map((file, index) => ({ - id: file.gameBananaId.toString(), - name: file.mods[0].name, - size: formatSize(file.size), - url: file.url, - isPrimary: index === primaryIndex, - isInstalled: file.mods.every((fileMod) => + downloadUrl: () => + pickDownloadFiles(mod2).map((file) => ({ + id: file.id, + name: file.name, + size: formatSize(file.size), + url: file.url, + isPrimary: file.isPrimary, + isInstalled: + file.mods.length > 0 && + file.mods.every((fileMod) => installedModVersions .get(fileMod.name.toLocaleLowerCase()) ?.has(fileMod.version), ), - })), - ); - }, + })), previewUrl: mod2?.screenshots?.[0]?.url ?? celemodIcon, author: mod2.submitter, isInstalled: diff --git a/src/celemod-ui/src/downloadFiles.test.ts b/src/celemod-ui/src/downloadFiles.test.ts new file mode 100644 index 0000000..cfc03f2 --- /dev/null +++ b/src/celemod-ui/src/downloadFiles.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { pickDownloadFiles } from "./downloadFiles"; +import type { File as SubmissionFile } from "./api/wegfan"; + +const file = ( + overrides: Partial = {}, +): SubmissionFile => + ({ + id: "1", + createTime: new Date(0), + updateTime: new Date(0), + deleteTime: null, + url: "https://celeste.weg.fan/api/v2/download/files/1", + description: "v1.0.0", + downloads: 0, + size: 1024, + gameBananaId: 100, + mods: [], + ...overrides, + }) as SubmissionFile; + +const mod = (id: string, name: string, version = "1.0.0") => ({ + id, + name, + version, + createTime: new Date(0), + updateTime: new Date(0), + deleteTime: null, + xxHash: [], +}); + +test("keeps the newest file of every Mod and marks the matching one primary", () => { + const entries = pickDownloadFiles({ + name: "CelesteNet", + gameBananaId: 53695, + files: [ + file({ gameBananaId: 3, mods: [mod("m1", "CelesteNet", "1.2.0")] }), + file({ gameBananaId: 2, mods: [mod("m1", "CelesteNet", "1.1.0")] }), + file({ gameBananaId: 1, mods: [mod("m2", "CelesteNet.GH")] }), + ], + }); + + assert.deepEqual( + entries.map((entry) => entry.id), + ["3", "1"], + ); + assert.equal(entries[0].isPrimary, true); + assert.equal(entries[1].isPrimary, false); + assert.equal(entries[0].name, "CelesteNet"); +}); + +test("still offers the files when the mirror reports no Mods for them", () => { + const entries = pickDownloadFiles({ + name: "Darkmoon Ruins", + gameBananaId: 393245, + files: [ + file({ gameBananaId: 1059878, description: "", mods: [] }), + file({ gameBananaId: 1000000, description: "v0.9", mods: [] }), + file({ gameBananaId: 999999, description: "v0.8", mods: undefined }), + ], + }); + + // Nothing links the files to a Mod, so no file may be dropped and the first + // one (the newest the mirror lists) is the primary download. + assert.deepEqual( + entries.map((entry) => entry.id), + ["1059878", "1000000", "999999"], + ); + assert.equal(entries[0].isPrimary, true); + assert.deepEqual( + entries.map((entry) => entry.name), + ["Darkmoon Ruins", "v0.9", "v0.8"], + ); + assert.deepEqual(entries[0].mods, []); +}); + +test("hands the mirror URL to the downloader for WEGFan-only submissions", () => { + const entries = pickDownloadFiles({ + name: "Chinese Translation", + gameBananaId: null, + files: [ + file({ + url: "https://celeste.weg.fan/api/v2/download/files/188553683618142080", + gameBananaId: null as unknown as number, + mods: [mod("m1", "CelesteV1410SimplifiedChineseTranslation")], + }), + ], + }); + + assert.equal(entries.length, 1); + assert.equal(entries[0].id, "-1"); + assert.equal( + entries[0].url, + "https://celeste.weg.fan/api/v2/download/files/188553683618142080", + ); + assert.equal(entries[0].name, "CelesteV1410SimplifiedChineseTranslation"); + assert.equal(entries[0].isPrimary, true); +}); + +test("offers nothing when the submission has no file at all", () => { + assert.deepEqual( + pickDownloadFiles({ name: "Empty", gameBananaId: 1234, files: [] }), + [], + ); + assert.deepEqual( + pickDownloadFiles({ name: "Empty", gameBananaId: null, files: [] }), + [], + ); +}); diff --git a/src/celemod-ui/src/downloadFiles.ts b/src/celemod-ui/src/downloadFiles.ts new file mode 100644 index 0000000..152fcc9 --- /dev/null +++ b/src/celemod-ui/src/downloadFiles.ts @@ -0,0 +1,97 @@ +import type { File as SubmissionFile } from "./api/wegfan"; + +/** A Mod the mirror reports as packed inside a file archive. */ +export interface FileMod { + id: string; + name: string; + version: string; +} + +/** The part of a submission the download picker works with. */ +export interface DownloadFileSource { + name: string; + gameBananaId?: number | null; + files?: SubmissionFile[] | null; +} + +/** A file offered for download, before it is formatted for the picker. */ +export interface DownloadFileEntry { + /** GameBanana file id, "-1" when only the mirror knows the file. */ + id: string; + name: string; + size: number; + url: string; + isPrimary: boolean; + mods: FileMod[]; +} + +const normalizeName = (value: string) => + value.toLocaleLowerCase().replace(/[^a-z0-9]/g, ""); + +const fileLabel = (file: SubmissionFile, submission: DownloadFileSource) => + file.mods?.[0]?.name ?? (file.description?.trim() || submission.name); + +/** + * The files a submission offers for download. + * + * The mirror reports the Mods packed inside a file in `mods`; the picker uses + * that to label a file and to hide the older files of the same Mod. Those + * entries only exist for files the mirror's Mod catalog knows about, so `mods` + * comes back empty for everything else (WEGFan currently only lists its own + * Mods). Such files must still be offered, otherwise the picker shows nothing + * and the download button reports 文件列表为空 instead of handing the file to + * the downloader. + */ +export const pickDownloadFiles = ( + submission: DownloadFileSource, +): DownloadFileEntry[] => { + const files = submission.files ?? []; + + // WEGFan-only submissions have no GameBanana file id; the picker hands the + // mirror URL to the downloader instead. + if (!submission.gameBananaId || submission.gameBananaId < 0) { + const file = files[0]; + if (!file?.url) return []; + return [ + { + id: file.gameBananaId > 0 ? file.gameBananaId.toString() : "-1", + name: fileLabel(file, submission), + size: file.size, + url: file.url, + isPrimary: true, + mods: file.mods ?? [], + }, + ]; + } + + const seenMods = new Set(); + const entries: DownloadFileEntry[] = []; + for (const file of files) { + const mods = file.mods ?? []; + if (mods.length > 0) { + // Several files of one Mod: the mirror lists the newest one first. + if (seenMods.has(mods[0].id)) continue; + seenMods.add(mods[0].id); + } + entries.push({ + id: file.gameBananaId > 0 ? file.gameBananaId.toString() : "-1", + name: fileLabel(file, submission), + size: file.size, + url: file.url, + isPrimary: false, + mods, + }); + } + + const submissionName = normalizeName(submission.name); + const primaryIndex = Math.max( + 0, + entries.findIndex((entry) => + entry.mods.some((mod) => normalizeName(mod.name) === submissionName), + ), + ); + return entries.map((entry, index) => ({ + ...entry, + isPrimary: index === primaryIndex, + })); +};