feat(plugins): add marketplace sort modes with release dates
This commit is contained in:
1 parent
4c1eaa6b16
commit
4b9a1c4d5c
14 files changed
+373
-6
No files matched your search
+174
-2
@@ -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 || {});
|
||||
|
||||
Reference in new issue
Block a user