Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/celemod-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
71 changes: 19 additions & 52 deletions src/celemod-ui/src/components/ModList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down
110 changes: 110 additions & 0 deletions src/celemod-ui/src/downloadFiles.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {},
): 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: [] }),
[],
);
});
97 changes: 97 additions & 0 deletions src/celemod-ui/src/downloadFiles.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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,
}));
};
Loading