mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat(search): add freshness-aware discovery ranking
feat(search): add freshness-aware discovery ranking
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
### Changes
|
||||
|
||||
- Web: organization publishers can upload durable PNG, JPEG, or WebP logos from settings instead of relying on hotlinked image URLs.
|
||||
- Web/API: make default skill and plugin discovery freshness-aware, add seven-day trending views for both catalogs, and use verified status plus usage as search tie-breakers within direct matches.
|
||||
|
||||
## 0.23.0 - 2026-06-23
|
||||
|
||||
|
||||
Vendored
+2
@@ -133,6 +133,7 @@ import type * as packageInspectorHttp from "../packageInspectorHttp.js";
|
||||
import type * as packageInspectorNode from "../packageInspectorNode.js";
|
||||
import type * as packagePublishTokens from "../packagePublishTokens.js";
|
||||
import type * as packages from "../packages.js";
|
||||
import type * as packageLeaderboards from "../packageLeaderboards.js";
|
||||
import type * as publisherAbuse from "../publisherAbuse.js";
|
||||
import type * as publisherAbuseDevSeed from "../publisherAbuseDevSeed.js";
|
||||
import type * as publishers from "../publishers.js";
|
||||
@@ -287,6 +288,7 @@ declare const fullApi: ApiFromModules<{
|
||||
packageInspectorNode: typeof packageInspectorNode;
|
||||
packagePublishTokens: typeof packagePublishTokens;
|
||||
packages: typeof packages;
|
||||
packageLeaderboards: typeof packageLeaderboards;
|
||||
publisherAbuse: typeof publisherAbuse;
|
||||
publisherAbuseDevSeed: typeof publisherAbuseDevSeed;
|
||||
publishers: typeof publishers;
|
||||
|
||||
@@ -32,8 +32,12 @@ vi.mock("./_generated/api", () => ({
|
||||
internal: {
|
||||
githubSkillSyncNode: { syncGitHubSkillSourcesInternal: mocks.githubSkillSyncRef },
|
||||
leaderboards: { rebuildTrendingLeaderboardAction: Symbol("trending-leaderboard") },
|
||||
packageLeaderboards: {
|
||||
rebuildTrendingLeaderboardAction: Symbol("package-trending-leaderboard"),
|
||||
},
|
||||
statsMaintenance: {
|
||||
runSkillStatBackfillInternal: Symbol("skill-stats-backfill"),
|
||||
runRecommendationScoreBackfillInternal: Symbol("recommendation-score-refresh"),
|
||||
updateGlobalStatsAction: Symbol("global-stats-update"),
|
||||
},
|
||||
skillStatEvents: {
|
||||
|
||||
@@ -19,6 +19,20 @@ if (process.env.CLAWHUB_DISABLE_CRONS !== "1") {
|
||||
{ limit: 200 },
|
||||
);
|
||||
|
||||
crons.interval(
|
||||
"package-trending-leaderboard",
|
||||
{ minutes: 60 },
|
||||
internal.packageLeaderboards.rebuildTrendingLeaderboardAction,
|
||||
{ limit: 200 },
|
||||
);
|
||||
|
||||
crons.interval(
|
||||
"recommendation-score-refresh",
|
||||
{ hours: 6 },
|
||||
internal.statsMaintenance.runRecommendationScoreBackfillInternal,
|
||||
{ batchSize: 500, maxBatches: 50 },
|
||||
);
|
||||
|
||||
crons.interval(
|
||||
"skill-stats-backfill",
|
||||
{ hours: 6 },
|
||||
|
||||
@@ -310,7 +310,13 @@ async function getOptionalViewerUserIdForRequest(ctx: ActionCtx, request: Reques
|
||||
const PACKAGE_FAMILY_VALUES = ["skill", "code-plugin", "bundle-plugin"] as const;
|
||||
const PLUGIN_EXPORT_FAMILY_VALUES = ["code-plugin", "bundle-plugin"] as const;
|
||||
const PACKAGE_CHANNEL_VALUES = ["official", "community", "private"] as const;
|
||||
const PACKAGE_LIST_SORT_VALUES = ["updated", "recommended", "downloads", "installs"] as const;
|
||||
const PACKAGE_LIST_SORT_VALUES = [
|
||||
"updated",
|
||||
"recommended",
|
||||
"downloads",
|
||||
"installs",
|
||||
"trending",
|
||||
] as const;
|
||||
const PACKAGE_SCAN_STATUS_VALUES = [
|
||||
"clean",
|
||||
"suspicious",
|
||||
@@ -1587,6 +1593,13 @@ async function listPackages(
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
if (effectiveSort === "trending" && includeSkills) {
|
||||
return text(
|
||||
"Trending sort is only supported for plugin package endpoints; use /api/v1/skills?sort=trending for skills.",
|
||||
400,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
|
||||
if (effectiveFamily === "skill") {
|
||||
const cursor = isLegacyInstallSortRequest
|
||||
@@ -1737,6 +1750,32 @@ async function listPackages(
|
||||
);
|
||||
}
|
||||
|
||||
if (!effectiveFamily && options?.pluginFamilies?.length && effectiveSort === "trending") {
|
||||
const result = await runQueryRef<{
|
||||
page: CatalogListItem[];
|
||||
isDone: boolean;
|
||||
continueCursor: string | null;
|
||||
}>(ctx, internalRefs.packages.listPageForViewerInternal, {
|
||||
channel: channelParam.value,
|
||||
isOfficial: isOfficial.value,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
category,
|
||||
topic,
|
||||
excludedScanStatuses: excludedScanStatuses.value,
|
||||
sort: "trending",
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
paginationOpts: { cursor: rawCursor, numItems: limit },
|
||||
});
|
||||
return json(
|
||||
{
|
||||
items: result.page,
|
||||
nextCursor: result.isDone ? null : result.continueCursor,
|
||||
},
|
||||
200,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
|
||||
if (!effectiveFamily && options?.pluginFamilies?.length) {
|
||||
const shouldMarkDefaultDownloadCursor =
|
||||
!sortParam.value && pluginDefaultSort === RECOMMENDED_FALLBACK_SORT;
|
||||
|
||||
@@ -44,4 +44,32 @@ describe("recommendationScore", () => {
|
||||
|
||||
expect(secondThousand - firstThousand).toBeLessThan(firstThousand);
|
||||
});
|
||||
|
||||
it("gives fresh items a bounded discovery boost", () => {
|
||||
const now = Date.UTC(2026, 5, 25);
|
||||
const fresh = computeRecommendationScore(
|
||||
{ downloads: 0, installs: 0, stars: 0 },
|
||||
{ createdAt: now, updatedAt: now, now },
|
||||
);
|
||||
const stale = computeRecommendationScore(
|
||||
{ downloads: 0, installs: 0, stars: 0 },
|
||||
{ createdAt: now - 365 * 86_400_000, updatedAt: now - 365 * 86_400_000, now },
|
||||
);
|
||||
|
||||
expect(fresh).toBeGreaterThan(stale);
|
||||
expect(fresh).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it("reduces historical download dominance", () => {
|
||||
const recentInstall = computeRecommendationScore(
|
||||
{ downloads: 100, installs: 100, stars: 5 },
|
||||
{ updatedAt: Date.UTC(2026, 5, 25), now: Date.UTC(2026, 5, 25) },
|
||||
);
|
||||
const staleDownload = computeRecommendationScore(
|
||||
{ downloads: 1_000, installs: 0, stars: 0 },
|
||||
{ updatedAt: Date.UTC(2024, 0, 1), now: Date.UTC(2026, 5, 25) },
|
||||
);
|
||||
|
||||
expect(recentInstall).toBeGreaterThan(staleDownload);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,23 +4,53 @@ export type RecommendationStats = {
|
||||
stars: number;
|
||||
};
|
||||
|
||||
const DOWNLOAD_WEIGHT = 100;
|
||||
const INSTALL_WEIGHT = 160;
|
||||
const STAR_WEIGHT = 120;
|
||||
export type RecommendationContext = {
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
now?: number;
|
||||
};
|
||||
|
||||
const DOWNLOAD_WEIGHT = 55;
|
||||
const INSTALL_WEIGHT = 150;
|
||||
const STAR_WEIGHT = 115;
|
||||
const FRESHNESS_WEIGHT = 70;
|
||||
const NOVELTY_WEIGHT = 100;
|
||||
const FRESHNESS_HALF_LIFE_DAYS = 120;
|
||||
const NOVELTY_WINDOW_DAYS = 45;
|
||||
const DAY_MS = 24 * 60 * 60 * 1_000;
|
||||
|
||||
// Bump this when changing weights, then run statsMaintenance:runRecommendationScoreBackfillInternal.
|
||||
export const RECOMMENDATION_SCORE_VERSION = 3;
|
||||
export const RECOMMENDATION_SCORE_VERSION = 4;
|
||||
|
||||
function safeCount(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return 0;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function computeRecommendationScore(stats: RecommendationStats) {
|
||||
function getAgeDays(timestamp: number | undefined, now: number) {
|
||||
if (!Number.isFinite(timestamp)) return null;
|
||||
return Math.max(0, (now - (timestamp as number)) / DAY_MS);
|
||||
}
|
||||
|
||||
export function computeRecommendationScore(
|
||||
stats: RecommendationStats,
|
||||
context: RecommendationContext = {},
|
||||
) {
|
||||
const downloads = Math.sqrt(safeCount(stats.downloads)) * DOWNLOAD_WEIGHT;
|
||||
const installs = Math.sqrt(safeCount(stats.installs)) * INSTALL_WEIGHT;
|
||||
const stars = Math.sqrt(safeCount(stats.stars)) * STAR_WEIGHT;
|
||||
return Math.round(downloads + installs + stars);
|
||||
const now = Number.isFinite(context.now) ? (context.now as number) : Date.now();
|
||||
const updatedAgeDays = getAgeDays(context.updatedAt, now);
|
||||
const createdAgeDays = getAgeDays(context.createdAt, now);
|
||||
const freshness =
|
||||
updatedAgeDays === null
|
||||
? 0
|
||||
: Math.exp((-Math.LN2 * updatedAgeDays) / FRESHNESS_HALF_LIFE_DAYS) * FRESHNESS_WEIGHT;
|
||||
const novelty =
|
||||
createdAgeDays === null || createdAgeDays > NOVELTY_WINDOW_DAYS
|
||||
? 0
|
||||
: (1 - createdAgeDays / NOVELTY_WINDOW_DAYS) * NOVELTY_WEIGHT;
|
||||
return Math.round(downloads + installs + stars + freshness + novelty);
|
||||
}
|
||||
|
||||
export function compareRecommendationStats(a: RecommendationStats, b: RecommendationStats) {
|
||||
|
||||
@@ -134,6 +134,10 @@ export const RETENTION_POLICIES = {
|
||||
retention: "Processed and older than 7 days.",
|
||||
}),
|
||||
packageDailyStats: permanent("Daily aggregate package stats are product analytics."),
|
||||
packageLeaderboards: derived(
|
||||
"Package trending snapshots can be rebuilt from packageDailyStats.",
|
||||
"packageDailyStats",
|
||||
),
|
||||
packageTrustedPublishers: permanent("Trusted publishing configuration."),
|
||||
packagePublishTokens: ephemeral("Package publish tokens expire and can be revoked.", {
|
||||
expirationField: "expiresAt",
|
||||
|
||||
@@ -93,11 +93,17 @@ export function extractDigestFields(skill: Doc<"skills">): SkillSearchDigestFiel
|
||||
statsStars,
|
||||
statsInstallsCurrent,
|
||||
statsInstallsAllTime,
|
||||
recommendedScore: computeRecommendationScore({
|
||||
downloads: statsDownloads,
|
||||
installs: statsInstallsAllTime,
|
||||
stars: statsStars,
|
||||
}),
|
||||
recommendedScore: computeRecommendationScore(
|
||||
{
|
||||
downloads: statsDownloads,
|
||||
installs: statsInstallsAllTime,
|
||||
stars: statsStars,
|
||||
},
|
||||
{
|
||||
createdAt: skill.createdAt,
|
||||
updatedAt: skill.updatedAt,
|
||||
},
|
||||
),
|
||||
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION,
|
||||
skillId: skill._id,
|
||||
normalizedSlug: normalizeSkillSearchText(skill.slug),
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/* @vitest-environment node */
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
rebuildTrendingLeaderboardAction,
|
||||
rebuildTrendingLeaderboardInternal,
|
||||
} from "./packageLeaderboards";
|
||||
|
||||
const mutationHandler = (
|
||||
rebuildTrendingLeaderboardInternal as unknown as {
|
||||
_handler: (ctx: unknown, args: { limit?: number }) => Promise<unknown>;
|
||||
}
|
||||
)._handler;
|
||||
const actionHandler = (
|
||||
rebuildTrendingLeaderboardAction as unknown as {
|
||||
_handler: (ctx: unknown, args: { limit?: number }) => Promise<unknown>;
|
||||
}
|
||||
)._handler;
|
||||
|
||||
describe("packageLeaderboards", () => {
|
||||
it("schedules a bounded leaderboard rebuild", async () => {
|
||||
const runAfter = vi.fn().mockResolvedValue("job-1");
|
||||
const result = await mutationHandler(
|
||||
{
|
||||
db: {
|
||||
get: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
query: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
system: { get: vi.fn(), query: vi.fn() },
|
||||
delete: vi.fn(),
|
||||
},
|
||||
scheduler: { runAfter },
|
||||
},
|
||||
{ limit: 500 },
|
||||
);
|
||||
|
||||
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), { limit: 200 });
|
||||
expect(result).toEqual({ ok: true, count: 0, scheduled: true, days: 7 });
|
||||
});
|
||||
|
||||
it("aggregates recent installs and downloads into a weighted top list", async () => {
|
||||
const runQuery = vi.fn(async (_ref: unknown, args: Record<string, unknown>) => {
|
||||
if (args.day === Math.floor(Date.now() / 86_400_000)) {
|
||||
return {
|
||||
rows: [
|
||||
{ packageId: "packages:one", installs: 2, downloads: 1 },
|
||||
{ packageId: "packages:two", installs: 0, downloads: 8 },
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
};
|
||||
}
|
||||
return { rows: [], isDone: true, continueCursor: "" };
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue({ ok: true });
|
||||
|
||||
const result = await actionHandler({ runQuery, runMutation }, { limit: 5 });
|
||||
|
||||
expect(result).toEqual({ ok: true, count: 2 });
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
expect.objectContaining({ packageId: "packages:two", score: 8 }),
|
||||
expect.objectContaining({ packageId: "packages:one", score: 7 }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { internalAction, internalMutation, internalQuery } from "./functions";
|
||||
import { getTrendingRange, topN, TRENDING_DAYS } from "./lib/leaderboards";
|
||||
|
||||
const MAX_TRENDING_LIMIT = 200;
|
||||
const DAILY_STATS_PAGE_SIZE = 1_000;
|
||||
const KEEP_LEADERBOARD_ENTRIES = 3;
|
||||
export const PACKAGE_TRENDING_LEADERBOARD_KIND = "package_trending";
|
||||
|
||||
export const getDailyStatsPage = internalQuery({
|
||||
args: {
|
||||
day: v.number(),
|
||||
cursor: v.union(v.string(), v.null()),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, { day, cursor, limit }) => {
|
||||
const page = await ctx.db
|
||||
.query("packageDailyStats")
|
||||
.withIndex("by_day", (q) => q.eq("day", day))
|
||||
.paginate({
|
||||
cursor,
|
||||
numItems: Math.min(limit ?? DAILY_STATS_PAGE_SIZE, DAILY_STATS_PAGE_SIZE),
|
||||
});
|
||||
|
||||
return {
|
||||
rows: page.page.map((row) => ({
|
||||
packageId: row.packageId,
|
||||
installs: row.installs,
|
||||
downloads: row.downloads,
|
||||
})),
|
||||
isDone: page.isDone,
|
||||
continueCursor: page.continueCursor,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const writeTrendingLeaderboard = internalMutation({
|
||||
args: {
|
||||
items: v.array(
|
||||
v.object({
|
||||
packageId: v.id("packages"),
|
||||
score: v.number(),
|
||||
installs: v.number(),
|
||||
downloads: v.number(),
|
||||
}),
|
||||
),
|
||||
startDay: v.number(),
|
||||
endDay: v.number(),
|
||||
},
|
||||
handler: async (ctx, { items, startDay, endDay }) => {
|
||||
await ctx.db.insert("packageLeaderboards", {
|
||||
kind: PACKAGE_TRENDING_LEADERBOARD_KIND,
|
||||
generatedAt: Date.now(),
|
||||
rangeStartDay: startDay,
|
||||
rangeEndDay: endDay,
|
||||
items,
|
||||
});
|
||||
|
||||
const recent = await ctx.db
|
||||
.query("packageLeaderboards")
|
||||
.withIndex("by_kind", (q) => q.eq("kind", PACKAGE_TRENDING_LEADERBOARD_KIND))
|
||||
.order("desc")
|
||||
.take(KEEP_LEADERBOARD_ENTRIES + 5);
|
||||
for (const entry of recent.slice(KEEP_LEADERBOARD_ENTRIES)) {
|
||||
await ctx.db.delete(entry._id);
|
||||
}
|
||||
return { ok: true as const, count: items.length };
|
||||
},
|
||||
});
|
||||
|
||||
export const rebuildTrendingLeaderboardAction = internalAction({
|
||||
args: { limit: v.optional(v.number()) },
|
||||
handler: async (ctx, args): Promise<{ ok: true; count: number }> => {
|
||||
const limit = Math.min(Math.max(args.limit ?? MAX_TRENDING_LIMIT, 1), MAX_TRENDING_LIMIT);
|
||||
const now = Date.now();
|
||||
const { startDay, endDay } = getTrendingRange(now);
|
||||
const totals = new Map<Id<"packages">, { installs: number; downloads: number }>();
|
||||
|
||||
for (let day = startDay; day <= endDay; day += 1) {
|
||||
let cursor: string | null = null;
|
||||
let isDone = false;
|
||||
while (!isDone) {
|
||||
const page = (await ctx.runQuery(internal.packageLeaderboards.getDailyStatsPage, {
|
||||
day,
|
||||
cursor,
|
||||
limit: DAILY_STATS_PAGE_SIZE,
|
||||
})) as {
|
||||
rows: Array<{ packageId: Id<"packages">; installs: number; downloads: number }>;
|
||||
isDone: boolean;
|
||||
continueCursor: string;
|
||||
};
|
||||
for (const row of page.rows) {
|
||||
const current = totals.get(row.packageId) ?? { installs: 0, downloads: 0 };
|
||||
current.installs += row.installs;
|
||||
current.downloads += row.downloads;
|
||||
totals.set(row.packageId, current);
|
||||
}
|
||||
cursor = page.continueCursor;
|
||||
isDone = page.isDone;
|
||||
}
|
||||
}
|
||||
|
||||
const entries = Array.from(totals, ([packageId, entry]) => ({
|
||||
packageId,
|
||||
installs: entry.installs,
|
||||
downloads: entry.downloads,
|
||||
score: entry.installs * 3 + entry.downloads,
|
||||
})).sort((a, b) => b.score - a.score || b.downloads - a.downloads || b.installs - a.installs);
|
||||
|
||||
await ctx.runMutation(internal.packageLeaderboards.writeTrendingLeaderboard, {
|
||||
items: topN(
|
||||
entries,
|
||||
limit,
|
||||
(a, b) => a.score - b.score || a.downloads - b.downloads || a.installs - b.installs,
|
||||
).sort((a, b) => b.score - a.score || b.downloads - a.downloads || b.installs - a.installs),
|
||||
startDay,
|
||||
endDay,
|
||||
});
|
||||
return { ok: true as const, count: Math.min(entries.length, limit) };
|
||||
},
|
||||
});
|
||||
|
||||
export const rebuildTrendingLeaderboardInternal = internalMutation({
|
||||
args: { limit: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.scheduler.runAfter(0, internal.packageLeaderboards.rebuildTrendingLeaderboardAction, {
|
||||
limit: Math.min(Math.max(args.limit ?? MAX_TRENDING_LIMIT, 1), MAX_TRENDING_LIMIT),
|
||||
});
|
||||
return { ok: true as const, count: 0, scheduled: true as const, days: TRENDING_DAYS };
|
||||
},
|
||||
});
|
||||
@@ -6446,7 +6446,8 @@ describe("packages public queries", () => {
|
||||
isOfficial: false,
|
||||
tags: {},
|
||||
stats: { downloads: 0, installs: 0, stars: 0, versions: 0 },
|
||||
recommendedScore: 0,
|
||||
recommendedScore: 170,
|
||||
recommendedScoreVersion: 4,
|
||||
}),
|
||||
);
|
||||
expect(insert).not.toHaveBeenCalledWith("packageReleases", expect.anything());
|
||||
|
||||
+119
-31
@@ -117,6 +117,7 @@ import { matchesAllTokens, matchesExploratoryTokenPrefixes, tokenize } from "./l
|
||||
import { hashSkillFiles } from "./lib/skills";
|
||||
import { buildDeterministicPackageZip } from "./lib/skillZip";
|
||||
import { runStaticPublishScan } from "./lib/staticPublishScan";
|
||||
import { PACKAGE_TRENDING_LEADERBOARD_KIND } from "./packageLeaderboards";
|
||||
import schema from "./schema";
|
||||
|
||||
const MAX_PUBLIC_LIST_PAGE_SIZE = 200;
|
||||
@@ -150,17 +151,26 @@ const INITIAL_PACKAGE_VT_SCAN_DELAY_MS = 30_000;
|
||||
const PLUGIN_EXPORT_FAMILIES = ["code-plugin", "bundle-plugin"] as const;
|
||||
const GET_PAGE_TIEBREAKER_FIELD_COUNT = 2;
|
||||
|
||||
function computePackageRecommendationScore(stats: Doc<"packages">["stats"]) {
|
||||
return computeRecommendationScore({
|
||||
downloads: stats.downloads,
|
||||
installs: stats.installs,
|
||||
stars: stats.stars,
|
||||
});
|
||||
function computePackageRecommendationScore(
|
||||
stats: Doc<"packages">["stats"],
|
||||
context?: { createdAt?: number; updatedAt?: number; now?: number },
|
||||
) {
|
||||
return computeRecommendationScore(
|
||||
{
|
||||
downloads: stats.downloads,
|
||||
installs: stats.installs,
|
||||
stars: stats.stars,
|
||||
},
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
function computePackageRecommendationPatch(stats: Doc<"packages">["stats"]) {
|
||||
function computePackageRecommendationPatch(
|
||||
stats: Doc<"packages">["stats"],
|
||||
context?: { createdAt?: number; updatedAt?: number; now?: number },
|
||||
) {
|
||||
return {
|
||||
recommendedScore: computePackageRecommendationScore(stats),
|
||||
recommendedScore: computePackageRecommendationScore(stats, context),
|
||||
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION,
|
||||
};
|
||||
}
|
||||
@@ -901,7 +911,7 @@ type PublicPageCursorState = {
|
||||
pageSize: number | null;
|
||||
done: boolean;
|
||||
mode?: "packages" | "digest";
|
||||
sort?: "updated" | "downloads" | "recommended" | "installs";
|
||||
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
|
||||
packageIndex?: "family-official-downloads";
|
||||
};
|
||||
const PUBLIC_PAGE_CURSOR_PREFIX = "pkgpage:";
|
||||
@@ -1548,7 +1558,8 @@ function decodePublicPageCursor(raw: string | null | undefined): PublicPageCurso
|
||||
parsed.sort === "updated" ||
|
||||
parsed.sort === "downloads" ||
|
||||
parsed.sort === "recommended" ||
|
||||
parsed.sort === "installs"
|
||||
parsed.sort === "installs" ||
|
||||
parsed.sort === "trending"
|
||||
? parsed.sort
|
||||
: undefined,
|
||||
packageIndex:
|
||||
@@ -1683,12 +1694,30 @@ function packageSearchMatch(
|
||||
}
|
||||
|
||||
function comparePackageSearchMatches<
|
||||
T extends PackageSearchMatch & { package: Pick<PackageDigestLike, "isOfficial" | "updatedAt"> },
|
||||
T extends PackageSearchMatch & {
|
||||
package: {
|
||||
isOfficial: boolean;
|
||||
updatedAt: number;
|
||||
verificationTier?: PackageDigestLike["verificationTier"] | null;
|
||||
stats?: { downloads: number; installs: number; stars: number } | null;
|
||||
};
|
||||
},
|
||||
>(a: T, b: T) {
|
||||
const verificationRank = (tier: PackageDigestLike["verificationTier"] | null) => {
|
||||
if (tier === "rebuild-verified") return 4;
|
||||
if (tier === "provenance-verified") return 3;
|
||||
if (tier === "source-linked") return 2;
|
||||
if (tier === "structural") return 1;
|
||||
return 0;
|
||||
};
|
||||
return (
|
||||
a.rankTier - b.rankTier ||
|
||||
b.score - a.score ||
|
||||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
|
||||
verificationRank(b.package.verificationTier) - verificationRank(a.package.verificationTier) ||
|
||||
(b.package.stats?.stars ?? 0) - (a.package.stats?.stars ?? 0) ||
|
||||
(b.package.stats?.installs ?? 0) - (a.package.stats?.installs ?? 0) ||
|
||||
(b.package.stats?.downloads ?? 0) - (a.package.stats?.downloads ?? 0) ||
|
||||
b.package.updatedAt - a.package.updatedAt
|
||||
);
|
||||
}
|
||||
@@ -1888,7 +1917,7 @@ function buildPackagePluginCategoryDigestQuery(
|
||||
family?: PackageFamily;
|
||||
channel?: PackageChannel;
|
||||
isOfficial?: boolean;
|
||||
sort?: "updated" | "downloads" | "recommended" | "installs";
|
||||
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
|
||||
},
|
||||
) {
|
||||
const family = args.family;
|
||||
@@ -2114,7 +2143,7 @@ function buildPackageTopicDigestQuery(
|
||||
family?: PackageFamily;
|
||||
channel?: PackageChannel;
|
||||
isOfficial?: boolean;
|
||||
sort?: "updated" | "downloads" | "recommended" | "installs";
|
||||
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
|
||||
},
|
||||
) {
|
||||
const family = args.family;
|
||||
@@ -2291,7 +2320,7 @@ async function mayHaveVisiblePackageCategoryDigest(
|
||||
category: PluginCategorySlug;
|
||||
topic?: string;
|
||||
excludedScanStatuses?: PackageListScanStatus[];
|
||||
sort?: "updated" | "downloads" | "recommended" | "installs";
|
||||
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
|
||||
viewerUserId?: Id<"users">;
|
||||
},
|
||||
) {
|
||||
@@ -2337,7 +2366,7 @@ async function takeVisiblePackageCategoryDigestPage(
|
||||
category: PluginCategorySlug;
|
||||
topic?: string;
|
||||
excludedScanStatuses?: PackageListScanStatus[];
|
||||
sort?: "updated" | "downloads" | "recommended" | "installs";
|
||||
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
|
||||
viewerUserId?: Id<"users">;
|
||||
numItems: number;
|
||||
},
|
||||
@@ -2440,7 +2469,7 @@ async function fetchHighlightedPackagePage(
|
||||
category?: string;
|
||||
topic?: string;
|
||||
officialFirst?: boolean;
|
||||
sort?: "updated" | "downloads" | "recommended" | "installs";
|
||||
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
|
||||
viewerUserId?: Id<"users">;
|
||||
numItems: number;
|
||||
},
|
||||
@@ -3062,6 +3091,7 @@ export const listPublicPage = query({
|
||||
v.literal("downloads"),
|
||||
v.literal("recommended"),
|
||||
v.literal("installs"),
|
||||
v.literal("trending"),
|
||||
),
|
||||
),
|
||||
paginationOpts: paginationOptsValidator,
|
||||
@@ -3539,6 +3569,7 @@ export const listPageForViewerInternal = internalQuery({
|
||||
v.literal("downloads"),
|
||||
v.literal("recommended"),
|
||||
v.literal("installs"),
|
||||
v.literal("trending"),
|
||||
),
|
||||
),
|
||||
viewerUserId: v.optional(v.id("users")),
|
||||
@@ -3592,7 +3623,7 @@ async function listPackagePageImpl(
|
||||
topic?: string;
|
||||
officialFirst?: boolean;
|
||||
excludedScanStatuses?: PackageListScanStatus[];
|
||||
sort?: "updated" | "downloads" | "recommended" | "installs";
|
||||
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
|
||||
viewerUserId?: Id<"users">;
|
||||
paginationOpts: { cursor: string | null; numItems: number };
|
||||
},
|
||||
@@ -3615,6 +3646,45 @@ async function listPackagePageImpl(
|
||||
return { page: [], isDone: true, continueCursor: "" };
|
||||
}
|
||||
|
||||
if (args.sort === "trending") {
|
||||
const leaderboard = await ctx.db
|
||||
.query("packageLeaderboards")
|
||||
.withIndex("by_kind", (q) => q.eq("kind", PACKAGE_TRENDING_LEADERBOARD_KIND))
|
||||
.order("desc")
|
||||
.first();
|
||||
if (!leaderboard) return { page: [], isDone: true, continueCursor: "" };
|
||||
|
||||
const cursorState = decodePublicPageCursor(args.paginationOpts.cursor);
|
||||
const startIndex = cursorState.sort === "trending" ? cursorState.offset : 0;
|
||||
const page: PublicPackageListItem[] = [];
|
||||
let nextOffset = startIndex;
|
||||
for (let index = startIndex; index < leaderboard.items.length; index += 1) {
|
||||
const entry = leaderboard.items[index];
|
||||
nextOffset = index + 1;
|
||||
const pkg = await ctx.db.get(entry.packageId);
|
||||
if (!pkg || pkg.softDeletedAt) continue;
|
||||
if (!(await canViewerReadPackage(ctx, pkg, viewerUserId, membershipCache))) continue;
|
||||
if (!packageMatchesListFilters(pkg, { ...args, category, topic })) continue;
|
||||
page.push(await toPublicPackageListItemFromPackage(ctx, pkg));
|
||||
if (page.length >= targetCount) break;
|
||||
}
|
||||
const isDone = nextOffset >= leaderboard.items.length;
|
||||
return {
|
||||
page,
|
||||
isDone,
|
||||
continueCursor: isDone
|
||||
? ""
|
||||
: encodePublicPageCursor({
|
||||
cursor: null,
|
||||
offset: nextOffset,
|
||||
pageSize: targetCount,
|
||||
done: false,
|
||||
mode: "packages",
|
||||
sort: "trending",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (args.officialFirst && category && typeof args.isOfficial !== "boolean") {
|
||||
return await listOfficialFirstPackageCategoryPage(ctx, {
|
||||
...args,
|
||||
@@ -3910,7 +3980,7 @@ async function listOfficialFirstPackageCategoryPage(
|
||||
category: PluginCategorySlug;
|
||||
topic?: string;
|
||||
excludedScanStatuses?: PackageListScanStatus[];
|
||||
sort?: "updated" | "downloads" | "recommended" | "installs";
|
||||
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
|
||||
viewerUserId?: Id<"users">;
|
||||
paginationOpts: { cursor: string | null; numItems: number };
|
||||
},
|
||||
@@ -4420,7 +4490,11 @@ export const processPackageStatEventsInternal = internalMutation({
|
||||
};
|
||||
await ctx.db.patch(pkg._id, {
|
||||
stats: nextStats,
|
||||
...computePackageRecommendationPatch(nextStats),
|
||||
...computePackageRecommendationPatch(nextStats, {
|
||||
createdAt: pkg.createdAt ?? pkg._creationTime,
|
||||
updatedAt: pkg.updatedAt,
|
||||
now,
|
||||
}),
|
||||
});
|
||||
packagesUpdated += 1;
|
||||
}
|
||||
@@ -7827,12 +7901,19 @@ export const reservePackageNameInternal = internalMutation({
|
||||
isOfficial: false,
|
||||
tags: {},
|
||||
stats: { downloads: 0, installs: 0, stars: 0, versions: 0 },
|
||||
...computePackageRecommendationPatch({
|
||||
downloads: 0,
|
||||
installs: 0,
|
||||
stars: 0,
|
||||
versions: 0,
|
||||
}),
|
||||
...computePackageRecommendationPatch(
|
||||
{
|
||||
downloads: 0,
|
||||
installs: 0,
|
||||
stars: 0,
|
||||
versions: 0,
|
||||
},
|
||||
{
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
now,
|
||||
},
|
||||
),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
@@ -8902,12 +8983,19 @@ export const insertReleaseInternal = internalMutation({
|
||||
verification: args.verification,
|
||||
scanStatus: args.verification?.scanStatus,
|
||||
stats: { downloads: 0, installs: 0, stars: 0, versions: 0 },
|
||||
...computePackageRecommendationPatch({
|
||||
downloads: 0,
|
||||
installs: 0,
|
||||
stars: 0,
|
||||
versions: 0,
|
||||
}),
|
||||
...computePackageRecommendationPatch(
|
||||
{
|
||||
downloads: 0,
|
||||
installs: 0,
|
||||
stars: 0,
|
||||
versions: 0,
|
||||
},
|
||||
{
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
now,
|
||||
},
|
||||
),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
+19
-1
@@ -1806,7 +1806,24 @@ const packageDailyStats = defineTable({
|
||||
downloads: v.number(),
|
||||
installs: v.number(),
|
||||
updatedAt: v.number(),
|
||||
}).index("by_package_day", ["packageId", "day"]);
|
||||
})
|
||||
.index("by_package_day", ["packageId", "day"])
|
||||
.index("by_day", ["day"]);
|
||||
|
||||
const packageLeaderboards = defineTable({
|
||||
kind: v.string(),
|
||||
generatedAt: v.number(),
|
||||
rangeStartDay: v.number(),
|
||||
rangeEndDay: v.number(),
|
||||
items: v.array(
|
||||
v.object({
|
||||
packageId: v.id("packages"),
|
||||
score: v.number(),
|
||||
installs: v.number(),
|
||||
downloads: v.number(),
|
||||
}),
|
||||
),
|
||||
}).index("by_kind", ["kind", "generatedAt"]);
|
||||
|
||||
const packageTrustedPublishers = defineTable({
|
||||
packageId: v.id("packages"),
|
||||
@@ -2914,6 +2931,7 @@ export default defineSchema({
|
||||
skillCardGenerationJobs,
|
||||
packageStatEvents,
|
||||
packageDailyStats,
|
||||
packageLeaderboards,
|
||||
packageTrustedPublishers,
|
||||
packagePublishTokens,
|
||||
packagePublishUploadTickets,
|
||||
|
||||
+9
-1
@@ -228,6 +228,14 @@ function comparePopularityStats(a: PopularityStats, b: PopularityStats) {
|
||||
return b.stars - a.stars || (b.installsAllTime ?? 0) - (a.installsAllTime ?? 0);
|
||||
}
|
||||
|
||||
function compareSkillTrustAndUsage(a: SkillSearchEntry, b: SkillSearchEntry) {
|
||||
return (
|
||||
Number(Boolean(b.owner?.official)) - Number(Boolean(a.owner?.official)) ||
|
||||
comparePopularityStats(a.skill.stats, b.skill.stats) ||
|
||||
(b.skill.stats.downloads ?? 0) - (a.skill.stats.downloads ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
function mergeUniqueBySkillId(primary: SkillSearchEntry[], fallback: SkillSearchEntry[]) {
|
||||
if (fallback.length === 0) return primary;
|
||||
const out = [...primary];
|
||||
@@ -488,7 +496,7 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
(a, b) =>
|
||||
a.rankTier - b.rankTier ||
|
||||
b.score - a.score ||
|
||||
comparePopularityStats(a.skill.stats, b.skill.stats) ||
|
||||
compareSkillTrustAndUsage(a, b) ||
|
||||
b.skill.updatedAt - a.skill.updatedAt,
|
||||
)
|
||||
.slice(0, limit);
|
||||
|
||||
@@ -146,8 +146,8 @@ describe("skills.listPublicPageV4", () => {
|
||||
|
||||
expect(result.page.map((entry) => entry.skill.slug)).toEqual([
|
||||
"downloads-skill",
|
||||
"updated-skill",
|
||||
"installs-skill",
|
||||
"updated-skill",
|
||||
"stars-skill",
|
||||
]);
|
||||
});
|
||||
|
||||
+27
-5
@@ -5848,9 +5848,23 @@ export const listPublicTrendingPage = query({
|
||||
args: {
|
||||
limit: v.optional(v.number()),
|
||||
nonSuspiciousOnly: v.optional(v.boolean()),
|
||||
categorySlug: v.optional(v.string()),
|
||||
topic: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const limit = clampInt(args.limit ?? 25, 1, MAX_PUBLIC_LIST_LIMIT);
|
||||
const normalizedCategorySlug = args.categorySlug?.trim().toLowerCase();
|
||||
const categorySlug =
|
||||
args.categorySlug === undefined
|
||||
? undefined
|
||||
: normalizedCategorySlug && isSkillCategorySlug(normalizedCategorySlug)
|
||||
? normalizedCategorySlug
|
||||
: null;
|
||||
if (args.categorySlug !== undefined && categorySlug === null) {
|
||||
return { items: [], nextCursor: null };
|
||||
}
|
||||
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
|
||||
if (args.topic !== undefined && !topic) return { items: [], nextCursor: null };
|
||||
const kind = args.nonSuspiciousOnly
|
||||
? TRENDING_NON_SUSPICIOUS_LEADERBOARD_KIND
|
||||
: TRENDING_LEADERBOARD_KIND;
|
||||
@@ -5870,6 +5884,8 @@ export const listPublicTrendingPage = query({
|
||||
.unique();
|
||||
if (!digest) continue;
|
||||
if (args.nonSuspiciousOnly && digest.isSuspicious) continue;
|
||||
if (categorySlug && !resolveStoredSkillCategories(digest).includes(categorySlug)) continue;
|
||||
if (topic && !getCatalogTopicSlugs(digest.topics).includes(topic)) continue;
|
||||
const item = await buildPublicSkillEntryFromDigest(ctx, digest);
|
||||
if (!item) continue;
|
||||
items.push(item);
|
||||
@@ -6965,11 +6981,17 @@ function readDigestRecommendationScore(digest: Doc<"skillSearchDigest">): number
|
||||
(digest.recommendedScoreVersion === RECOMMENDATION_SCORE_VERSION
|
||||
? digest.recommendedScore
|
||||
: undefined) ??
|
||||
computeRecommendationScore({
|
||||
downloads: readDigestRankStat(digest, "downloads"),
|
||||
installs: readDigestRankStat(digest, "installsAllTime"),
|
||||
stars: readDigestRankStat(digest, "stars"),
|
||||
})
|
||||
computeRecommendationScore(
|
||||
{
|
||||
downloads: readDigestRankStat(digest, "downloads"),
|
||||
installs: readDigestRankStat(digest, "installsAllTime"),
|
||||
stars: readDigestRankStat(digest, "stars"),
|
||||
},
|
||||
{
|
||||
createdAt: digest.createdAt,
|
||||
updatedAt: digest.updatedAt,
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+22
-10
@@ -408,19 +408,31 @@ function buildSkillStatPatch(skill: Doc<"skills">) {
|
||||
}
|
||||
|
||||
function computeSkillDigestRecommendationScore(digest: Doc<"skillSearchDigest">) {
|
||||
return computeRecommendationScore({
|
||||
downloads: digest.statsDownloads ?? digest.stats.downloads,
|
||||
installs: digest.statsInstallsAllTime ?? digest.stats.installsAllTime ?? 0,
|
||||
stars: digest.statsStars ?? digest.stats.stars,
|
||||
});
|
||||
return computeRecommendationScore(
|
||||
{
|
||||
downloads: digest.statsDownloads ?? digest.stats.downloads,
|
||||
installs: digest.statsInstallsAllTime ?? digest.stats.installsAllTime ?? 0,
|
||||
stars: digest.statsStars ?? digest.stats.stars,
|
||||
},
|
||||
{
|
||||
createdAt: digest.createdAt,
|
||||
updatedAt: digest.updatedAt,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function computePackageRecommendationScore(pkg: Doc<"packages">) {
|
||||
return computeRecommendationScore({
|
||||
downloads: pkg.stats.downloads,
|
||||
installs: pkg.stats.installs,
|
||||
stars: pkg.stats.stars,
|
||||
});
|
||||
return computeRecommendationScore(
|
||||
{
|
||||
downloads: pkg.stats.downloads,
|
||||
installs: pkg.stats.installs,
|
||||
stars: pkg.stats.stars,
|
||||
},
|
||||
{
|
||||
createdAt: pkg.createdAt ?? pkg._creationTime,
|
||||
updatedAt: pkg.updatedAt,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+6
-2
@@ -548,7 +548,7 @@ Query params:
|
||||
- `family` (optional): `skill`, `code-plugin`, or `bundle-plugin`
|
||||
- `channel` (optional): `official`, `community`, or `private`
|
||||
- `isOfficial` (optional): `true` or `false`
|
||||
- `sort` (optional): `updated` (default), `recommended`, `downloads`, legacy alias `installs`
|
||||
- `sort` (optional): `updated` (default), `recommended`, `trending`, `downloads`, legacy alias `installs`
|
||||
- `category` (optional): plugin category filter. Supported only when the
|
||||
request is scoped to plugin packages (`/api/v1/plugins`,
|
||||
`/api/v1/code-plugins`, `/api/v1/bundle-plugins`, or package endpoints with
|
||||
@@ -598,7 +598,7 @@ Query params:
|
||||
- `limit` (optional): integer (1-100)
|
||||
- `cursor` (optional): pagination cursor
|
||||
- `isOfficial` (optional): `true` or `false`
|
||||
- `sort` (optional): `recommended` (default), `downloads`, `updated`, legacy alias `installs`
|
||||
- `sort` (optional): `recommended` (default), `trending`, `downloads`, `updated`, legacy alias `installs`
|
||||
- `category` (optional): plugin category filter. Current values:
|
||||
`channels`, `models`, `memory`, `context`, `voice`, `media`, `web`,
|
||||
`tools`, `runtime`, `gateway`, `security`, `other`.
|
||||
@@ -609,6 +609,10 @@ Legacy v1 filter aliases remain accepted on read endpoints:
|
||||
- `observability` and `deployment` resolve to `gateway`.
|
||||
- `dev-tools` resolves to `runtime`.
|
||||
|
||||
`trending` is a seven-day install/download leaderboard and does not use all-time totals.
|
||||
On the unified `/api/v1/packages` endpoint it is plugin-only; use
|
||||
`/api/v1/skills?sort=trending` for the skill catalog.
|
||||
|
||||
Legacy aliases are not accepted as stored or author-declared category values.
|
||||
|
||||
### `GET /api/v1/skills/export`
|
||||
|
||||
@@ -27,3 +27,19 @@ only category or topic filter because limited global results can under-fill scop
|
||||
stop at an explicit safety scan budget, but the result limit applies after scoped matches are found.
|
||||
|
||||
Search result counts in the web UI should describe what is known from the current request. Do not label a page-size-limited result length as a total corpus count. Prefer `N+`, "shown", or no count unless an indexed/materialized total is available.
|
||||
|
||||
## Browse Discovery Ranking
|
||||
|
||||
Browse defaults are a discovery surface, not a proxy for lifetime downloads. The materialized
|
||||
recommendation score combines sublinear installs, downloads, and stars with a decaying freshness
|
||||
signal and a bounded boost for newly published items. Download weight is intentionally lower than
|
||||
install, star, and freshness signals so a large historical footprint cannot permanently occupy the
|
||||
default page. Recommendation scores are refreshed by maintenance jobs because freshness changes even
|
||||
when an item receives no new events.
|
||||
|
||||
Trending is a separate seven-day activity leaderboard built from daily install and download
|
||||
aggregates. It must not be derived from all-time totals. Skills and plugins expose the same
|
||||
trending concept; suspicious or unavailable items are filtered before public display.
|
||||
|
||||
Publisher diversity is a product follow-up for browse ranking. The current contract guarantees
|
||||
freshness and bounded novelty, while preserving stable cursor pagination and trust filters.
|
||||
|
||||
@@ -408,13 +408,13 @@ describe("plugins route", () => {
|
||||
expect.objectContaining({
|
||||
cursor: "cursor:current",
|
||||
limit: 25,
|
||||
sort: "downloads",
|
||||
sort: "recommended",
|
||||
}),
|
||||
);
|
||||
expect(fetchPluginCatalogMock.mock.calls[0]?.[0]).not.toHaveProperty("family");
|
||||
});
|
||||
|
||||
it("uses downloads as the plugin browse ranking", async () => {
|
||||
it("uses recommendation ranking as the plugin browse default", async () => {
|
||||
fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: null });
|
||||
const { loadPluginsPageData } = await import("../routes/plugins/index");
|
||||
|
||||
@@ -422,7 +422,7 @@ describe("plugins route", () => {
|
||||
|
||||
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sort: "downloads",
|
||||
sort: "recommended",
|
||||
limit: 25,
|
||||
}),
|
||||
);
|
||||
@@ -434,7 +434,7 @@ describe("plugins route", () => {
|
||||
|
||||
await loadPluginsPageData({
|
||||
q: "security",
|
||||
sort: "downloads",
|
||||
sort: "recommended",
|
||||
cursor: "cursor:search",
|
||||
});
|
||||
|
||||
@@ -453,12 +453,12 @@ describe("plugins route", () => {
|
||||
const { loadPluginsPageData } = await import("../routes/plugins/index");
|
||||
|
||||
await loadPluginsPageData({
|
||||
sort: "downloads",
|
||||
sort: "recommended",
|
||||
});
|
||||
|
||||
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sort: "downloads",
|
||||
sort: "recommended",
|
||||
limit: 25,
|
||||
}),
|
||||
);
|
||||
@@ -578,7 +578,7 @@ describe("plugins route", () => {
|
||||
expect.objectContaining({
|
||||
category: "security",
|
||||
cursor: "cursor:next",
|
||||
sort: "downloads",
|
||||
sort: "recommended",
|
||||
}),
|
||||
);
|
||||
expect(navigateMock).not.toHaveBeenCalled();
|
||||
@@ -927,7 +927,7 @@ describe("plugins route", () => {
|
||||
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isOfficial: true,
|
||||
sort: "downloads",
|
||||
sort: "recommended",
|
||||
limit: 25,
|
||||
}),
|
||||
);
|
||||
@@ -959,7 +959,7 @@ describe("plugins route", () => {
|
||||
family: undefined,
|
||||
cursor: undefined,
|
||||
featured: true,
|
||||
sort: "downloads",
|
||||
sort: "recommended",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -982,7 +982,7 @@ describe("plugins route", () => {
|
||||
cursor: undefined,
|
||||
family: undefined,
|
||||
featured: undefined,
|
||||
sort: "downloads",
|
||||
sort: "recommended",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1318,7 +1318,7 @@ describe("plugins route", () => {
|
||||
featured: true,
|
||||
cursor: undefined,
|
||||
family: undefined,
|
||||
sort: "downloads",
|
||||
sort: "recommended",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1339,7 +1339,7 @@ describe("plugins route", () => {
|
||||
cursor: undefined,
|
||||
family: undefined,
|
||||
featured: undefined,
|
||||
sort: "downloads",
|
||||
sort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1443,7 +1443,7 @@ describe("plugins route", () => {
|
||||
const sortOptions = Array.from(
|
||||
screen.getByRole("radiogroup", { name: "Sort order" }).querySelectorAll('[role="radio"]'),
|
||||
).map((option) => option.textContent);
|
||||
expect(sortOptions).toEqual(["All", "Verified", "Updated"]);
|
||||
expect(sortOptions).toEqual(["All", "Trending", "Verified", "Updated"]);
|
||||
expect(screen.queryByRole("radio", { name: "Most downloaded" })).toBeNull();
|
||||
expect(screen.queryByRole("radio", { name: "Newest" })).toBeNull();
|
||||
expect(screen.queryByRole("radio", { name: "Name" })).toBeNull();
|
||||
|
||||
@@ -87,7 +87,7 @@ describe("SkillsIndex", () => {
|
||||
const sortOptions = Array.from(
|
||||
screen.getByRole("radiogroup", { name: "Skill view" }).querySelectorAll('[role="radio"]'),
|
||||
).map((option) => option.textContent);
|
||||
expect(sortOptions).toEqual(["All", "Top", "Most starred", "Featured"]);
|
||||
expect(sortOptions).toEqual(["All", "Trending", "Top", "Most starred", "Featured"]);
|
||||
});
|
||||
|
||||
it("offers Top without exposing downloads as a browse view", async () => {
|
||||
@@ -108,7 +108,7 @@ describe("SkillsIndex", () => {
|
||||
fireEvent.click(screen.getByRole("combobox", { name: "Sort" }));
|
||||
const sortOptions = screen.getAllByRole("option").map((option) => option.textContent);
|
||||
|
||||
expect(views).toEqual(["All", "Top", "Most starred", "Featured"]);
|
||||
expect(views).toEqual(["All", "Trending", "Top", "Most starred", "Featured"]);
|
||||
expect(sortOptions).toEqual(["Recently updated", "Newest", "Name"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ export type PackageVersionDetail = {
|
||||
};
|
||||
|
||||
type PluginFamily = "code-plugin" | "bundle-plugin";
|
||||
type PackageCatalogSort = "updated" | "recommended" | "downloads";
|
||||
type PackageCatalogSort = "updated" | "recommended" | "downloads" | "trending";
|
||||
|
||||
type PluginCatalogResult = {
|
||||
items: PackageListItem[];
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from "../../lib/packageApi";
|
||||
import { useMediaQuery } from "../../lib/useMediaQuery";
|
||||
|
||||
type VisiblePluginSort = "recommended" | "updated" | "downloads";
|
||||
type VisiblePluginSort = "recommended" | "updated" | "downloads" | "trending";
|
||||
type PluginSort = VisiblePluginSort | "relevance";
|
||||
type LegacyPluginSort = PluginSort | "newest" | "name" | "installs";
|
||||
type PluginBrowseTab = VisiblePluginSort | "official";
|
||||
@@ -52,7 +52,8 @@ type PluginView = "list" | "grid";
|
||||
type LegacyPluginView = PluginView | "cards";
|
||||
|
||||
const PLUGIN_BROWSE_TABS = [
|
||||
{ value: "downloads", label: "All" },
|
||||
{ value: "recommended", label: "All" },
|
||||
{ value: "trending", label: "Trending" },
|
||||
{
|
||||
value: "official",
|
||||
label: "Verified",
|
||||
@@ -115,6 +116,7 @@ function parsePluginSort(value: unknown): LegacyPluginSort | undefined {
|
||||
value === "relevance" ||
|
||||
value === "updated" ||
|
||||
value === "downloads" ||
|
||||
value === "trending" ||
|
||||
value === "installs" ||
|
||||
value === "newest" ||
|
||||
value === "name"
|
||||
@@ -151,7 +153,7 @@ function normalizeActivePluginSort(sort: LegacyPluginSort | undefined): PluginSo
|
||||
function getDefaultPluginBrowseSort(
|
||||
_args: Pick<PluginsPageDataRequest, "category" | "featured" | "official">,
|
||||
): VisiblePluginSort {
|
||||
return "downloads";
|
||||
return "recommended";
|
||||
}
|
||||
|
||||
function hasPersistentPluginBrowseFilter(
|
||||
@@ -179,6 +181,7 @@ export async function loadPluginsPageData(
|
||||
...(!args.q &&
|
||||
(args.sort === "downloads" ||
|
||||
args.sort === "updated" ||
|
||||
args.sort === "trending" ||
|
||||
!args.sort ||
|
||||
args.sort === "recommended")
|
||||
? { sort: args.sort ?? getDefaultPluginBrowseSort(args) }
|
||||
@@ -271,6 +274,7 @@ export const Route = createFileRoute("/plugins/")({
|
||||
search.sort !== "recommended" &&
|
||||
search.sort !== "updated" &&
|
||||
search.sort !== "downloads" &&
|
||||
search.sort !== "trending" &&
|
||||
!(hasQuery && search.sort === "relevance");
|
||||
const staleFeatured = Boolean(hasQuery && search.featured);
|
||||
if (incompatibleSort || staleFeatured) {
|
||||
@@ -300,7 +304,7 @@ function PluginsIndexPending() {
|
||||
<BrowseTabs
|
||||
ariaLabel="Sort order"
|
||||
options={PLUGIN_BROWSE_TABS}
|
||||
value="downloads"
|
||||
value="recommended"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<BrowseActions>
|
||||
@@ -430,11 +434,7 @@ function PluginsIndex() {
|
||||
: search.sort === "relevance" || search.sort === "newest" || search.sort === "name"
|
||||
? "recommended"
|
||||
: (search.sort ?? (hasQuery ? "recommended" : getDefaultPluginBrowseSort(search)));
|
||||
const activeBrowseTab: PluginBrowseTab = search.official
|
||||
? "official"
|
||||
: activeSort === "recommended"
|
||||
? "downloads"
|
||||
: activeSort;
|
||||
const activeBrowseTab: PluginBrowseTab = search.official ? "official" : activeSort;
|
||||
const visibleItems = useMemo(() => {
|
||||
return hasQuery ? sortPluginSearchItems(items, activeSort) : items;
|
||||
}, [activeSort, hasQuery, items]);
|
||||
@@ -453,11 +453,11 @@ function PluginsIndex() {
|
||||
return;
|
||||
}
|
||||
|
||||
handleSortChange(value ?? "downloads");
|
||||
handleSortChange(value ?? "recommended");
|
||||
};
|
||||
|
||||
const handleSortChange = (value: string) => {
|
||||
const nextSort = parsePluginSort(value) ?? "downloads";
|
||||
const nextSort = parsePluginSort(value) ?? "recommended";
|
||||
|
||||
void navigate({
|
||||
search: (prev: PluginSearchState) => {
|
||||
@@ -466,8 +466,8 @@ function PluginsIndex() {
|
||||
const sort =
|
||||
isExplicitFilteredRecommendation || nextSort === "downloads"
|
||||
? nextSort
|
||||
: nextSort === "updated"
|
||||
? "updated"
|
||||
: nextSort === "updated" || nextSort === "trending"
|
||||
? nextSort
|
||||
: undefined;
|
||||
const nextSearch: PluginSearchState = {
|
||||
...prev,
|
||||
|
||||
@@ -7,10 +7,11 @@ export const sortKeys = [
|
||||
"stars",
|
||||
"name",
|
||||
"updated",
|
||||
"trending",
|
||||
] as const;
|
||||
|
||||
export type SortKey = (typeof sortKeys)[number];
|
||||
export type ListSortKey = Exclude<SortKey, "relevance" | "recommended" | "default">;
|
||||
export type ListSortKey = Exclude<SortKey, "relevance" | "recommended" | "default" | "trending">;
|
||||
export type SortDir = "asc" | "desc";
|
||||
|
||||
export function parseSort(value: unknown): SortKey {
|
||||
@@ -27,5 +28,7 @@ export function parseDir(value: unknown, sort: SortKey): SortDir {
|
||||
}
|
||||
|
||||
export function toListSort(sort: SortKey): ListSortKey | undefined {
|
||||
return sort === "relevance" || sort === "recommended" || sort === "default" ? undefined : sort;
|
||||
return sort === "relevance" || sort === "recommended" || sort === "default" || sort === "trending"
|
||||
? undefined
|
||||
: sort;
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export function useSkillsBrowseModel({
|
||||
: requestedSort === "recommended" && hasQuery
|
||||
? "relevance"
|
||||
: (requestedSort ?? (hasQuery ? "relevance" : "recommended"));
|
||||
const listSort = toListSort(sort);
|
||||
const listSort = sort === "trending" ? undefined : toListSort(sort);
|
||||
const dir = sort === "relevance" ? "desc" : parseDir(search.dir, sort);
|
||||
const searchKey = hasQuery
|
||||
? `${trimmedQuery}::${featuredOnly ? "1" : "0"}::${activeCategory?.slug ?? ""}::${activeTopic ?? ""}`
|
||||
@@ -105,6 +105,20 @@ export function useSkillsBrowseModel({
|
||||
let pageCursor = cursor;
|
||||
let consecutiveEmptyPages = 0;
|
||||
try {
|
||||
if (sort === "trending") {
|
||||
const result = await convexHttp.query(api.skills.listPublicTrendingPage, {
|
||||
limit: pageSize,
|
||||
nonSuspiciousOnly: true,
|
||||
categorySlug: activeCategory?.slug,
|
||||
topic: activeTopic,
|
||||
});
|
||||
if (generation !== fetchGeneration.current) return;
|
||||
setListResults(result.items);
|
||||
setListCursor(null);
|
||||
setListAutoLoadPaused(false);
|
||||
setListStatus("done");
|
||||
return;
|
||||
}
|
||||
while (true) {
|
||||
const result = await convexHttp.query(api.skills.listPublicPageV4, {
|
||||
cursor: pageCursor ?? undefined,
|
||||
@@ -158,6 +172,7 @@ export function useSkillsBrowseModel({
|
||||
excludeCategoryKeywords,
|
||||
featuredOnly,
|
||||
listSort,
|
||||
sort,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
|
||||
const SKILLS_VIEW_OPTIONS = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "trending", label: "Trending" },
|
||||
{ value: "top", label: "Top" },
|
||||
{ value: "stars", label: "Most starred" },
|
||||
{ value: "featured", label: "Featured" },
|
||||
@@ -86,11 +87,13 @@ export function SkillsIndex() {
|
||||
|
||||
const activeView = model.featuredOnly
|
||||
? "featured"
|
||||
: model.sort === "downloads"
|
||||
? "top"
|
||||
: model.sort === "stars"
|
||||
? "stars"
|
||||
: "all";
|
||||
: model.sort === "trending"
|
||||
? "trending"
|
||||
: model.sort === "downloads"
|
||||
? "top"
|
||||
: model.sort === "stars"
|
||||
? "stars"
|
||||
: "all";
|
||||
const activeSort = ["updated", "newest", "name"].includes(model.sort) ? model.sort : undefined;
|
||||
const hasActiveFilters = model.hasQuery || Boolean(model.activeCategory) || model.featuredOnly;
|
||||
const totalSkillsCount = useQuery(api.skills.countPublicSkills, {});
|
||||
@@ -109,6 +112,15 @@ export function SkillsIndex() {
|
||||
(value: string) => {
|
||||
void navigate({
|
||||
search: (prev: SkillsSearchState) => {
|
||||
if (value === "trending") {
|
||||
return {
|
||||
...prev,
|
||||
sort: "trending",
|
||||
dir: "desc",
|
||||
featured: undefined,
|
||||
highlighted: undefined,
|
||||
};
|
||||
}
|
||||
if (value === "top" || value === "stars") {
|
||||
const sort = value === "top" ? "downloads" : "stars";
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user