feat(plugins): add marketplace sort modes with release dates

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-24 19:16:02 +08:00
1 parent 4c1eaa6b16
commit 4b9a1c4d5c
14 files changed
+373 -6

No files matched your search

+174 -2
View File
@@ -1,6 +1,17 @@
import { describe, expect, it } from "vitest";
import { buildInstalledUpdateIndex, buildMarketplacePluginListings, compareVersions, filterMarketplacePluginListings, marketplaceHomepageUrl, pluginSourceChange, selectMarketplaceArtifact } from "./pluginMarketplace";
import type { InstalledPlugin, PluginRepositoryCatalogResult } from "@/types/database";
import {
buildInstalledUpdateIndex,
buildMarketplacePluginListings,
compareVersions,
filterMarketplacePluginListings,
formatMarketplaceReleasedDate,
marketplaceHomepageUrl,
pluginSourceChange,
selectMarketplaceArtifact,
sortMarketplacePluginListings,
type MarketplacePluginListing,
} from "./pluginMarketplace";
import type { InstalledPlugin, PluginMarketplacePlugin, PluginRepositoryCatalogResult } from "@/types/database";
const result: PluginRepositoryCatalogResult = {
repository: { id: "dbx-official", name: "DBX Marketplace", kind: "official", enabled: true, managed: true },
@@ -50,6 +61,12 @@ function installed(version: string): InstalledPlugin {
};
}
function installedFor(pluginId: string, version: string): InstalledPlugin {
const plugin = installed(version);
plugin.manifest.id = pluginId;
return plugin;
}
function repositoryResult(repositoryId: string, repositoryName: string, latestVersion: string, verified = true): PluginRepositoryCatalogResult {
const copy = structuredClone(result);
copy.repository = { id: repositoryId, name: repositoryName, kind: verified ? "official" : "custom", enabled: true, managed: !verified ? false : true };
@@ -171,3 +188,158 @@ describe("compareVersions", () => {
expect(compareVersions("1.1.0", "0.9")).toBeGreaterThan(0);
});
});
describe("sortMarketplacePluginListings", () => {
type DatedPluginSpec = { id: string; name: string; latestVersion: string; versions: Array<{ version: string; releasedAt?: string }> };
function datedPlugin(spec: DatedPluginSpec): PluginMarketplacePlugin {
return {
id: spec.id,
name: spec.name,
description: "",
publisher: "DBX",
verified: true,
tags: [],
permissions: [],
latestVersion: spec.latestVersion,
versions: spec.versions.map((version) => ({
version: version.version,
...(version.releasedAt ? { releasedAt: version.releasedAt } : {}),
artifacts: [{ target: "darwin-arm64", url: `https://plugins.example.com/${spec.id}.dbxp`, sha256: "a".repeat(64), signingKeyId: "dbx.release" }],
})),
};
}
function datedCatalog(plugins: DatedPluginSpec[]): PluginRepositoryCatalogResult {
return {
repository: { id: "dbx-official", name: "DBX Marketplace", kind: "official", enabled: true, managed: true },
target: "darwin-arm64",
catalog: { catalogVersion: 1, repository: { id: "dbx-official", name: "DBX Marketplace" }, plugins: plugins.map(datedPlugin) },
};
}
const ids = (listings: MarketplacePluginListing[]) => listings.map((listing) => listing.plugin.id);
// Exactly what the card renders for a listing: the latestVersion entry's date, undated → the
// sort's -Infinity bucket. Used to pin sort/display coherence without restating the production
// comparator.
const displayedTime = (listing: MarketplacePluginListing) => {
const time = Date.parse(listing.latestVersionReleasedAt || "");
return Number.isNaN(time) ? Number.NEGATIVE_INFINITY : time;
};
const displayedTimes = (listings: MarketplacePluginListing[]) => listings.map(displayedTime);
it("surfaces the latest version's release date for display and blanks unparsable input", () => {
const listings = buildMarketplacePluginListings(
[
datedCatalog([
{
id: "a.hello",
name: "Alpha",
latestVersion: "2.0.0",
versions: [
{ version: "1.0.0", releasedAt: "2026-01-01T00:00:00Z" },
{ version: "2.0.0", releasedAt: "2026-03-01T00:00:00Z" },
],
},
]),
],
[],
"en",
);
expect(listings[0].latestVersionReleasedAt).toBe("2026-03-01T00:00:00Z");
expect(formatMarketplaceReleasedDate(listings[0].latestVersionReleasedAt, "en")).not.toBe("");
expect(formatMarketplaceReleasedDate(undefined, "en")).toBe("");
expect(formatMarketplaceReleasedDate("not-a-date", "en")).toBe("");
});
it("sorts recently-updated by the displayed latest-version date and sinks undated listings", () => {
// Beta's OLDER 0.9.0 entry carries the newest date in the catalog, but its card displays the
// latestVersion (1.0.0) date. The sort key must be that same displayed value, so Beta ties with
// Alpha instead of ranking above it while showing an older badge.
const contradictory = datedCatalog([
{ id: "a.old", name: "Alpha", latestVersion: "1.0.0", versions: [{ version: "1.0.0", releasedAt: "2025-01-01T00:00:00Z" }] },
{
id: "b.new",
name: "Beta",
latestVersion: "1.0.0",
versions: [
{ version: "1.0.0", releasedAt: "2025-01-01T00:00:00Z" },
{ version: "0.9.0", releasedAt: "2026-05-01T00:00:00Z" },
],
},
{ id: "c.none", name: "Gamma", latestVersion: "1.0.0", versions: [{ version: "1.0.0" }] },
]);
const tied = sortMarketplacePluginListings(buildMarketplacePluginListings([contradictory], [], "en"), "recently-updated");
// Equal displayed dates → name order; the undated listing sinks to the tail.
expect(ids(tied)).toEqual(["a.old", "b.new", "c.none"]);
// The coherence invariant itself: the ranking is exactly the descending order of the field the
// card renders, with undated listings last. Nothing here looks at Beta's newer 0.9.0 date.
const dated = datedCatalog([
{ id: "a.new", name: "Alpha", latestVersion: "1.0.0", versions: [{ version: "1.0.0", releasedAt: "2026-06-01T00:00:00Z" }] },
{ id: "b.old", name: "Beta", latestVersion: "1.0.0", versions: [{ version: "1.0.0", releasedAt: "2025-01-01T00:00:00Z" }] },
{ id: "c.none", name: "Gamma", latestVersion: "1.0.0", versions: [{ version: "1.0.0" }] },
]);
const listings = buildMarketplacePluginListings([dated], [], "en");
const sorted = sortMarketplacePluginListings(listings, "recently-updated");
expect(ids(sorted)).toEqual(["a.new", "b.old", "c.none"]);
expect(sorted.map((listing) => listing.latestVersionReleasedAt)).toEqual(["2026-06-01T00:00:00Z", "2025-01-01T00:00:00Z", undefined]);
// The coherence invariant, stated fixture-independently: walking the sorted list never shows a
// badge date newer than the entry above it, and the undated listing holds the tail.
const displayed = displayedTimes(sorted);
expect(displayed.every((time, index) => index === 0 || displayed[index - 1] >= time)).toBe(true);
expect(sorted[sorted.length - 1].latestVersionReleasedAt).toBeUndefined();
});
it("sorts recently-listed by the earliest version date, including a non-latest version", () => {
// Unlike "recently updated", this mode intentionally scans every version: no card text claims
// to show a first-listed date, so min(releasedAt) cannot contradict anything on screen.
const catalog = datedCatalog([
{
id: "a.veteran",
name: "Alpha",
latestVersion: "3.0.0",
versions: [
{ version: "1.0.0", releasedAt: "2024-01-01T00:00:00Z" },
{ version: "3.0.0", releasedAt: "2026-01-01T00:00:00Z" },
],
},
{ id: "b.newcomer", name: "Beta", latestVersion: "1.0.0", versions: [{ version: "1.0.0", releasedAt: "2025-06-01T00:00:00Z" }] },
// Newest latest-version date of the three, oldest first-listed date is still the veteran's.
{
id: "c.young",
name: "Gamma",
latestVersion: "1.0.0",
versions: [
{ version: "1.0.0", releasedAt: "2026-08-01T00:00:00Z" },
{ version: "0.1.0", releasedAt: "2024-06-01T00:00:00Z" },
],
},
]);
const listings = buildMarketplacePluginListings([catalog], [], "en");
expect(ids(sortMarketplacePluginListings(listings, "recently-listed"))).toEqual(["b.newcomer", "c.young", "a.veteran"]);
});
it("puts update-bearing listings first, then the newest-updated order; name mode keeps A–Z", () => {
// Both plugins have exactly one version, so the latestVersion entry and the single version share
// a date: updates-first's tie-break uses the same displayed value as "recently-updated".
const catalog = datedCatalog([
{ id: "a.alpha", name: "Alpha", latestVersion: "2.0.0", versions: [{ version: "2.0.0", releasedAt: "2026-06-01T00:00:00Z" }] },
{ id: "b.zulu", name: "Zulu", latestVersion: "2.0.0", versions: [{ version: "2.0.0", releasedAt: "2025-01-01T00:00:00Z" }] },
]);
const listings = buildMarketplacePluginListings([catalog], [installedFor("b.zulu", "1.0.0")], "en");
// The builder output itself is the long-standing default: localized name order.
expect(ids(listings)).toEqual(["a.alpha", "b.zulu"]);
const updatesFirst = sortMarketplacePluginListings(listings, "updates-first");
expect(ids(updatesFirst)).toEqual(["b.zulu", "a.alpha"]);
// The update-bearing listing leads even though its displayed date is the older one.
expect(displayedTimes(updatesFirst)).toEqual([Date.parse("2025-01-01T00:00:00Z"), Date.parse("2026-06-01T00:00:00Z")]);
expect(ids(sortMarketplacePluginListings(listings, "name"))).toEqual(["a.alpha", "b.zulu"]);
});
});
@@ -3,6 +3,8 @@ import { uuid } from "@/lib/common/utils";
export type MarketplacePluginStatus = "install" | "installed" | "update" | "unsupported";
export type MarketplacePluginSortMode = "name" | "recently-updated" | "recently-listed" | "updates-first";
export const UNIVERSAL_PLUGIN_TARGET = "universal";
export interface MarketplacePluginListing {
@@ -16,6 +18,9 @@ export interface MarketplacePluginListing {
installed?: InstalledPlugin;
verified: boolean;
status: MarketplacePluginStatus;
// releasedAt of the catalog's latestVersion entry, when provided; shown on the card
// next to the version badge so time-based sort modes have a visible key.
latestVersionReleasedAt?: string;
}
/**
@@ -51,6 +56,7 @@ export function buildMarketplacePluginListings(results: readonly PluginRepositor
installed,
verified: plugin.verified && listingRepositoryCanVerify(result.repository),
status,
latestVersionReleasedAt: latestVersion?.releasedAt || undefined,
};
}),
)
@@ -163,6 +169,53 @@ export function filterMarketplacePluginListings(listings: readonly MarketplacePl
});
}
// Missing or unparsable releasedAt sorts below every dated listing (-Infinity tail).
function releaseTime(releasedAt: string | undefined): number {
const time = Date.parse(releasedAt || "");
return Number.isNaN(time) ? Number.NEGATIVE_INFINITY : time;
}
// "Recently updated" must use the SAME value the card renders (latestVersionReleasedAt, the
// catalog's latestVersion entry): the ranking can then never contradict the date on the badge.
// Third-party catalogs are not required to keep latestVersion at the newest date, so a catalog
// that dates an older version later than its current one sorts by the date it advertises for its
// current version — the honest reading of "recently updated". The official store satisfies the
// latestVersion-is-newest invariant 30/30 (verified live 2026-09-24), so real data is unaffected.
function latestVersionReleaseTime(listing: MarketplacePluginListing): number {
return releaseTime(listing.latestVersionReleasedAt);
}
// "Recently listed" scans EVERY version: nothing on the card claims to display a first-listed
// date, so min(releasedAt) across the catalog is the only signal available (min ≈ first listed,
// the docs-site heuristic) and cannot contradict the badge.
function firstReleaseTime(listing: MarketplacePluginListing): number {
const times = listing.plugin.versions.map((version) => Date.parse(version.releasedAt || "")).filter((time) => !Number.isNaN(time));
return times.length ? Math.min(...times) : Number.NEGATIVE_INFINITY;
}
// Copies before sorting: the builder output is also consumed elsewhere (batch selection,
// the installed-tab update index) and must keep its name order there.
export function sortMarketplacePluginListings(listings: readonly MarketplacePluginListing[], mode: MarketplacePluginSortMode): MarketplacePluginListing[] {
const byName = (left: MarketplacePluginListing, right: MarketplacePluginListing) => left.name.localeCompare(right.name);
if (mode === "name") return [...listings].sort(byName);
const releaseTimeFor = mode === "recently-listed" ? firstReleaseTime : latestVersionReleaseTime;
if (mode === "updates-first") {
return [...listings].sort((left, right) => {
const updatable = (listing: MarketplacePluginListing) => (listing.status === "update" ? 0 : 1);
if (updatable(left) !== updatable(right)) return updatable(left) - updatable(right);
return releaseTimeFor(right) - releaseTimeFor(left) || byName(left, right);
});
}
return [...listings].sort((left, right) => releaseTimeFor(right) - releaseTimeFor(left) || byName(left, right));
}
export function formatMarketplaceReleasedDate(releasedAt: string | undefined, locale: string): string {
if (!releasedAt) return "";
const date = new Date(releasedAt);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(locale.replace("_", "-"), { dateStyle: "medium" }).format(date);
}
function marketplacePluginLocalization(plugin: PluginMarketplacePlugin, locale: string): { name: string; description: string } {
const normalizedLocale = locale.replace("_", "-").toLowerCase();
const entries = Object.entries(plugin.localizations || {});