From a9d48868a1ce6fe574b01a106d4cd8b205ac2f52 Mon Sep 17 00:00:00 2001 From: Mathieu Bastian Date: Sun, 13 Sep 2026 11:31:00 +0200 Subject: [PATCH 1/2] Simplify Gephi version filtering and downloads on the plugins page Since Gephi 0.10, plugin builds are no longer version-specific at the patch level, but the plugins page still showed/filtered on raw patch versions (e.g. 0.11.1), and pre-0.10 legacy plugins cluttered the default list, confusing users about compatibility with current Gephi. - Normalize displayed/filterable Gephi versions to major.minor for 0.10+ (legacy pre-0.10 versions keep full patch precision) - Hide plugins with no 0.10+ build by default, with a "Show older Gephi versions" toggle to reveal legacy plugins and their facets - Remove the now-redundant per-card version badges - On the plugin page, show a prominent download button in the header when the plugin's newest build matches the current latest Gephi release, and collapse pre-0.10 downloads under "Show older versions" Co-Authored-By: Claude Sonnet 5 --- src/components/plugins/PluginsFilters.tsx | 67 ++++++++++------ src/components/plugins/utils.ts | 30 +++++++ src/pages/desktop/plugins/[id].astro | 98 ++++++++++++++++++----- src/pages/desktop/plugins/index.astro | 12 +-- src/styles/_plugins.scss | 31 ++++--- 5 files changed, 172 insertions(+), 66 deletions(-) diff --git a/src/components/plugins/PluginsFilters.tsx b/src/components/plugins/PluginsFilters.tsx index 8513bc4..1bbcbd9 100644 --- a/src/components/plugins/PluginsFilters.tsx +++ b/src/components/plugins/PluginsFilters.tsx @@ -1,14 +1,15 @@ import { useDebounce } from "@ouestware/hooks"; -import { countBy, flatten, keys, omit, reverse, sortBy, sum, toPairs, values } from "lodash-es"; +import { countBy, flatten, omit, reverse, sortBy, sum, toPairs, uniq, values } from "lodash-es"; import { useEffect, useMemo, useState, type FC } from "react"; import { CheckboxInputGroup } from "./CheckboxInput"; import type { Plugin } from "../../type"; -import { pluginElementId } from "./utils"; +import { isLegacyGephiVersion, normalizeGephiVersion, pluginElementId, pluginHasModernVersion } from "./utils"; interface FilterStateType { query?: string; versions: string[]; categories: string[]; + showLegacy: boolean; } function searchToState(urlSearchParam: URLSearchParams): FilterStateType { @@ -24,6 +25,7 @@ function searchToState(urlSearchParam: URLSearchParams): FilterStateType { .get("categories") ?.split("|") .map((v) => decodeURIComponent(v)) || [], + showLegacy: urlSearchParam.get("legacy") === "1", }; } function stateToSearch(state: FilterStateType): string { @@ -33,31 +35,36 @@ function stateToSearch(state: FilterStateType): string { urlSearchParam.append("versions", sortBy(state.versions).map(encodeURIComponent).join("|")); if (state.categories.length > 0) urlSearchParam.append("categories", sortBy(state.categories).map(encodeURIComponent).join("|")); + if (state.showLegacy) urlSearchParam.append("legacy", "1"); return urlSearchParam.toString(); } +// Gephi version tags visible for filtering/faceting: normalized (major.minor +// for 0.10+, full patch for legacy), and excluding legacy tags entirely unless +// showLegacy is set. +function getVisibleVersionTags(p: Plugin, showLegacy: boolean): string[] { + const raw = Object.keys(p.versions); + const visible = showLegacy ? raw : raw.filter((v) => !isLegacyGephiVersion(v)); + return uniq(visible.map(normalizeGephiVersion)); +} + function filterPlugins(plugins: Plugin[], state: Partial) { const textRE = state.query ? new RegExp(`.*${state.query}.*`, "i") : null; - return textRE !== null || - (state.versions && state.versions.length > 0) || - (state.categories && state.categories.length > 0) - ? plugins.filter( - (p) => - (textRE === null || - textRE.test(p.name) || - textRE.test(p.short_description) || - textRE.test(p.long_description)) && - (state.versions === undefined || - state.versions.length === 0 || - keys(p.versions).some((v) => state.versions?.includes(v))) && - (state.categories === undefined || - state.categories.length === 0 || - state.categories.some((c) => c === p.category)), - ) - : plugins; + const showLegacy = !!state.showLegacy; + return plugins.filter( + (p) => + (showLegacy || pluginHasModernVersion(p)) && + (textRE === null || textRE.test(p.name) || textRE.test(p.short_description) || textRE.test(p.long_description)) && + (state.versions === undefined || + state.versions.length === 0 || + getVisibleVersionTags(p, showLegacy).some((v) => state.versions?.includes(v))) && + (state.categories === undefined || + state.categories.length === 0 || + state.categories.some((c) => c === p.category)), + ); } -function aggregatePlugins(field: "versions" | "categories", filteredPlugins: Plugin[]) { +function aggregatePlugins(field: "versions" | "categories", filteredPlugins: Plugin[], showLegacy: boolean) { const valuesCount = countBy( flatten( filteredPlugins.map((p) => { @@ -65,7 +72,7 @@ function aggregatePlugins(field: "versions" | "categories", filteredPlugins: Plu case "categories": return p.category; case "versions": - return keys(p.versions); + return getVisibleVersionTags(p, showLegacy); } }), ), @@ -103,11 +110,11 @@ export const PluginsFilters: FC<{ plugins: Plugin[] }> = ({ plugins }) => { const filteredPlugins = useMemo(() => filterPlugins(plugins, state), [state, plugins]); const versionsOptions = useMemo( - () => aggregatePlugins("versions", filterPlugins(plugins, omit(state, ["versions"]))), + () => aggregatePlugins("versions", filterPlugins(plugins, omit(state, ["versions"])), state.showLegacy), [state, plugins], ); const categoriesOptions = useMemo( - () => aggregatePlugins("categories", filterPlugins(plugins, omit(state, ["categories"]))), + () => aggregatePlugins("categories", filterPlugins(plugins, omit(state, ["categories"])), state.showLegacy), [state, plugins], ); @@ -150,6 +157,20 @@ export const PluginsFilters: FC<{ plugins: Plugin[] }> = ({ plugins }) => { })); }} /> +
+ { + setState((state) => ({ ...state, showLegacy: e.target.checked })); + }} + className="form-check-input" + /> + +
diff --git a/src/components/plugins/utils.ts b/src/components/plugins/utils.ts index ee9935c..7d197bf 100644 --- a/src/components/plugins/utils.ts +++ b/src/components/plugins/utils.ts @@ -1,3 +1,33 @@ +import type { Plugin } from "../../type"; + export function pluginElementId(id: string) { return `gephi-plugin-${id}`; } + +// Gephi 0.10+ no longer differentiates plugin builds at the patch level, so we +// only keep major.minor for those. Pre-0.10 ("legacy") builds did differentiate +// per patch, so we keep their full version string. +export function isLegacyGephiVersion(v: string): boolean { + const [major, minor] = v.split(".").map((n) => parseInt(n, 10) || 0); + return major === 0 && minor < 10; +} + +export function normalizeGephiVersion(v: string): string { + if (isLegacyGephiVersion(v)) return v; + const [major, minor] = v.split(".").map((n) => parseInt(n, 10) || 0); + return `${major}.${minor}`; +} + +export function pluginHasModernVersion(p: Plugin): boolean { + return Object.keys(p.versions).some((v) => !isLegacyGephiVersion(v)); +} + +export async function getLatestGephiVersion(): Promise { + try { + const resp = await fetch("https://api.github.com/repos/gephi/gephi/releases/latest"); + const data = (await resp.json()) as { tag_name: string }; + return normalizeGephiVersion(data.tag_name.replace(/^v/, "")); + } catch { + return null; + } +} diff --git a/src/pages/desktop/plugins/[id].astro b/src/pages/desktop/plugins/[id].astro index ee535a4..c7a5ee6 100644 --- a/src/pages/desktop/plugins/[id].astro +++ b/src/pages/desktop/plugins/[id].astro @@ -1,30 +1,32 @@ --- import { Icon } from "astro-icon/components"; -import { isEmpty } from "lodash-es"; import { marked } from "marked"; import { Image } from "astro:assets"; import Banner from "../../../components/Banner.astro"; import Layout from "../../../layouts/Layout.astro"; import type { Plugin } from "../../../type"; +import { getLatestGephiVersion, isLegacyGephiVersion, normalizeGephiVersion } from "../../../components/plugins/utils"; interface Props { plugin: Plugin; + latestGephiVersion: string | null; } export async function getStaticPaths() { - const pluginsDataRequest = await fetch( - "https://raw.githubusercontent.com/gephi/gephi-plugins/refs/heads/gh-pages/plugins/plugins.json", - ); + const [pluginsDataRequest, latestGephiVersion] = await Promise.all([ + fetch("https://raw.githubusercontent.com/gephi/gephi-plugins/refs/heads/gh-pages/plugins/plugins.json"), + getLatestGephiVersion(), + ]); if (pluginsDataRequest.ok) { const pluginsData = (await pluginsDataRequest.json()).plugins as Plugin[]; - return pluginsData.map((p) => ({ params: { id: p.id }, props: { plugin: p } })); + return pluginsData.map((p) => ({ params: { id: p.id }, props: { plugin: p, latestGephiVersion } })); } return []; } -const { plugin } = Astro.props; +const { plugin, latestGephiVersion } = Astro.props; let readme = ""; try { @@ -32,6 +34,22 @@ try { } catch (err) { console.log(`README.md markdown generation failed for plugin ${plugin.name}.`); } + +// Since Gephi 0.10, plugin builds are no longer version-specific at the patch level, so +// group download links by normalized version, keeping the most recently updated build per group. +const groupedVersions = Object.entries(plugin.versions).reduce>( + (acc, [v, info]) => { + const tag = normalizeGephiVersion(v); + if (!acc[tag] || new Date(info.last_update) > new Date(acc[tag].last_update)) acc[tag] = info; + return acc; + }, + {}, +); +const sortedVersionTags = Object.keys(groupedVersions).reverse(); +const modernVersionTags = sortedVersionTags.filter((v) => !isLegacyGephiVersion(v)); +const legacyVersionTags = sortedVersionTags.filter(isLegacyGephiVersion); +const topVersionTag = modernVersionTags[0]; +const topVersionIsLatestGephi = !!topVersionTag && topVersionTag === latestGephiVersion; --- @@ -47,6 +65,19 @@ try {

{plugin.short_description}

last updated on {plugin.last_update}

+ { + topVersionIsLatestGephi && ( + + ) + }
{ !!plugin.images?.length && ( @@ -141,22 +172,45 @@ try { instance. { - !isEmpty(plugin.versions) ? ( - + modernVersionTags.length > 0 || legacyVersionTags.length > 0 ? ( + <> + {modernVersionTags.length > 0 && ( + + )} + {legacyVersionTags.length > 0 && ( +
+ + Show older versions + + + +
+ )} + ) : (
No compatible version specified.
) diff --git a/src/pages/desktop/plugins/index.astro b/src/pages/desktop/plugins/index.astro index f3a776c..e8d6055 100644 --- a/src/pages/desktop/plugins/index.astro +++ b/src/pages/desktop/plugins/index.astro @@ -51,10 +51,7 @@ const pluginsData: Plugin[] | null = pluginsDataRequest.ok
{pluginsData.map((p) => ( -
`version-${v}`) : ""}`} - > + ))} diff --git a/src/styles/_plugins.scss b/src/styles/_plugins.scss index f56ae43..79621a2 100644 --- a/src/styles/_plugins.scss +++ b/src/styles/_plugins.scss @@ -77,16 +77,6 @@ @extend .fs-5; margin: 0; } - .plugin-versions { - display: flex; - flex-wrap: wrap; - gap: $spacer-1; - - & > span { - @extend .badge; - @extend .text-bg-primary; - } - } } } @@ -139,6 +129,27 @@ } .plugin-page { + .older-versions { + summary { + display: inline-flex; + align-items: center; + @extend .gap-1; + cursor: pointer; + list-style: none; + + &::-webkit-details-marker { + display: none; + } + } + + svg { + transition: all 0.2s ease-out; + } + &[open] svg { + transform: rotate(180deg); + } + } + .thumbnails { display: flex; flex-direction: row; From 123bfee316f4220070089bf0be68550ef76cd355 Mon Sep 17 00:00:00 2001 From: Mathieu Bastian Date: Sun, 13 Sep 2026 12:01:49 +0200 Subject: [PATCH 2/2] Fix bugs found in code review of the version-filtering changes - Show the legacy-hidden default plugin count in SSR/no-JS output instead of the raw total - Drop legacy versions stuck in filter state (bookmarked URL, or unchecking "Show older Gephi versions") instead of permanently zeroing results with no recovery - Scope the "Show older versions" caret rotation to the summary icon instead of every icon in the disclosure - Pick the "latest version" download CTA via a real numeric version comparison instead of relying on source JSON key order - Log fetch failures in getLatestGephiVersion instead of swallowing them silently - Break exact-timestamp ties when grouping versions by keeping the latest-iterated build - Fix normalizeGephiVersion corrupting single-part version strings - Simplify a redundant length check Co-Authored-By: Claude Sonnet 5 --- src/components/plugins/PluginsFilters.tsx | 25 ++++++++++++----- src/components/plugins/utils.ts | 34 +++++++++++++++++++++-- src/pages/desktop/plugins/[id].astro | 20 ++++++------- src/pages/desktop/plugins/index.astro | 8 ++++-- src/styles/_plugins.scss | 8 +++--- 5 files changed, 68 insertions(+), 27 deletions(-) diff --git a/src/components/plugins/PluginsFilters.tsx b/src/components/plugins/PluginsFilters.tsx index 1bbcbd9..3c6791f 100644 --- a/src/components/plugins/PluginsFilters.tsx +++ b/src/components/plugins/PluginsFilters.tsx @@ -13,19 +13,23 @@ interface FilterStateType { } function searchToState(urlSearchParam: URLSearchParams): FilterStateType { + const showLegacy = urlSearchParam.get("legacy") === "1"; + const versions = + urlSearchParam + .get("versions") + ?.split("|") + .map((v) => decodeURIComponent(v)) || []; return { query: urlSearchParam.get("query") || undefined, - versions: - urlSearchParam - .get("versions") - ?.split("|") - .map((v) => decodeURIComponent(v)) || [], + // Drop any legacy version stuck in a bookmarked/old URL when legacy versions aren't shown, + // otherwise it can never match a visible tag and silently zeroes the results. + versions: showLegacy ? versions : versions.filter((v) => !isLegacyGephiVersion(v)), categories: urlSearchParam .get("categories") ?.split("|") .map((v) => decodeURIComponent(v)) || [], - showLegacy: urlSearchParam.get("legacy") === "1", + showLegacy, }; } function stateToSearch(state: FilterStateType): string { @@ -163,7 +167,14 @@ export const PluginsFilters: FC<{ plugins: Plugin[] }> = ({ plugins }) => { id="show-legacy-checkbox" checked={state.showLegacy} onChange={(e) => { - setState((state) => ({ ...state, showLegacy: e.target.checked })); + const showLegacy = e.target.checked; + setState((state) => ({ + ...state, + showLegacy, + // Drop any checked legacy versions so they don't keep filtering to nothing + // once their checkboxes disappear from the facet list. + versions: showLegacy ? state.versions : state.versions.filter((v) => !isLegacyGephiVersion(v)), + })); }} className="form-check-input" /> diff --git a/src/components/plugins/utils.ts b/src/components/plugins/utils.ts index 7d197bf..3a85835 100644 --- a/src/components/plugins/utils.ts +++ b/src/components/plugins/utils.ts @@ -4,17 +4,22 @@ export function pluginElementId(id: string) { return `gephi-plugin-${id}`; } +function parseGephiVersion(v: string): [number, number] { + const parts = v.split(".").map((n) => parseInt(n, 10) || 0); + return [parts[0] ?? 0, parts[1] ?? 0]; +} + // Gephi 0.10+ no longer differentiates plugin builds at the patch level, so we // only keep major.minor for those. Pre-0.10 ("legacy") builds did differentiate // per patch, so we keep their full version string. export function isLegacyGephiVersion(v: string): boolean { - const [major, minor] = v.split(".").map((n) => parseInt(n, 10) || 0); + const [major, minor] = parseGephiVersion(v); return major === 0 && minor < 10; } export function normalizeGephiVersion(v: string): string { if (isLegacyGephiVersion(v)) return v; - const [major, minor] = v.split(".").map((n) => parseInt(n, 10) || 0); + const [major, minor] = parseGephiVersion(v); return `${major}.${minor}`; } @@ -22,12 +27,35 @@ export function pluginHasModernVersion(p: Plugin): boolean { return Object.keys(p.versions).some((v) => !isLegacyGephiVersion(v)); } +// Descending comparator so the most recent version sorts first, regardless of +// the order versions happen to appear in the source data. +export function compareGephiVersionsDesc(a: string, b: string): number { + const aParts = a.split(".").map((n) => parseInt(n, 10) || 0); + const bParts = b.split(".").map((n) => parseInt(n, 10) || 0); + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { + const diff = (bParts[i] ?? 0) - (aParts[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +// Groups a plugin's versions by normalized tag, keeping the most recently +// updated build per tag. +export function groupPluginVersionsByTag(versions: Plugin["versions"]): Record { + return Object.entries(versions).reduce>((acc, [v, info]) => { + const tag = normalizeGephiVersion(v); + if (!acc[tag] || new Date(info.last_update) >= new Date(acc[tag].last_update)) acc[tag] = info; + return acc; + }, {}); +} + export async function getLatestGephiVersion(): Promise { try { const resp = await fetch("https://api.github.com/repos/gephi/gephi/releases/latest"); const data = (await resp.json()) as { tag_name: string }; return normalizeGephiVersion(data.tag_name.replace(/^v/, "")); - } catch { + } catch (err) { + console.log("Failed to fetch the latest Gephi release version from GitHub.", err); return null; } } diff --git a/src/pages/desktop/plugins/[id].astro b/src/pages/desktop/plugins/[id].astro index c7a5ee6..da6a360 100644 --- a/src/pages/desktop/plugins/[id].astro +++ b/src/pages/desktop/plugins/[id].astro @@ -6,7 +6,12 @@ import { Image } from "astro:assets"; import Banner from "../../../components/Banner.astro"; import Layout from "../../../layouts/Layout.astro"; import type { Plugin } from "../../../type"; -import { getLatestGephiVersion, isLegacyGephiVersion, normalizeGephiVersion } from "../../../components/plugins/utils"; +import { + compareGephiVersionsDesc, + getLatestGephiVersion, + groupPluginVersionsByTag, + isLegacyGephiVersion, +} from "../../../components/plugins/utils"; interface Props { plugin: Plugin; @@ -37,15 +42,8 @@ try { // Since Gephi 0.10, plugin builds are no longer version-specific at the patch level, so // group download links by normalized version, keeping the most recently updated build per group. -const groupedVersions = Object.entries(plugin.versions).reduce>( - (acc, [v, info]) => { - const tag = normalizeGephiVersion(v); - if (!acc[tag] || new Date(info.last_update) > new Date(acc[tag].last_update)) acc[tag] = info; - return acc; - }, - {}, -); -const sortedVersionTags = Object.keys(groupedVersions).reverse(); +const groupedVersions = groupPluginVersionsByTag(plugin.versions); +const sortedVersionTags = Object.keys(groupedVersions).sort(compareGephiVersionsDesc); const modernVersionTags = sortedVersionTags.filter((v) => !isLegacyGephiVersion(v)); const legacyVersionTags = sortedVersionTags.filter(isLegacyGephiVersion); const topVersionTag = modernVersionTags[0]; @@ -172,7 +170,7 @@ const topVersionIsLatestGephi = !!topVersionTag && topVersionTag === latestGephi instance.
{ - modernVersionTags.length > 0 || legacyVersionTags.length > 0 ? ( + sortedVersionTags.length > 0 ? ( <> {modernVersionTags.length > 0 && (
    diff --git a/src/pages/desktop/plugins/index.astro b/src/pages/desktop/plugins/index.astro index e8d6055..1d5bc6d 100644 --- a/src/pages/desktop/plugins/index.astro +++ b/src/pages/desktop/plugins/index.astro @@ -7,7 +7,7 @@ import Default from "../../../images/plugins/default-screenshot.jpg"; import Layout from "../../../layouts/Layout.astro"; import { PluginsFilters } from "../../../components/plugins/PluginsFilters"; import type { Plugin } from "../../../type"; -import { pluginElementId } from "../../../components/plugins/utils"; +import { pluginElementId, pluginHasModernVersion } from "../../../components/plugins/utils"; const pluginsDataRequest = await fetch( "https://raw.githubusercontent.com/gephi/gephi-plugins/refs/heads/gh-pages/plugins/plugins.json", @@ -16,6 +16,10 @@ const pluginsDataRequest = await fetch( const pluginsData: Plugin[] | null = pluginsDataRequest.ok ? reverse(sortBy((await pluginsDataRequest.json()).plugins as Plugin[], (p) => new Date(p.last_update))) : null; + +// Matches PluginsFilters' default (legacy plugins hidden) so the count doesn't flash/mismatch +// before hydration, and stays correct for no-JS clients. +const defaultVisibleCount = pluginsData?.filter(pluginHasModernVersion).length ?? 0; --- @@ -47,7 +51,7 @@ const pluginsData: Plugin[] | null = pluginsDataRequest.ok

    - {pluginsData.length || "No"} {pluginsData.length > 1 ? "plugins" : "plugin"} + {defaultVisibleCount || "No"} {defaultVisibleCount > 1 ? "plugins" : "plugin"}

    {pluginsData.map((p) => ( diff --git a/src/styles/_plugins.scss b/src/styles/_plugins.scss index 79621a2..a7587ad 100644 --- a/src/styles/_plugins.scss +++ b/src/styles/_plugins.scss @@ -140,12 +140,12 @@ &::-webkit-details-marker { display: none; } - } - svg { - transition: all 0.2s ease-out; + svg { + transition: all 0.2s ease-out; + } } - &[open] svg { + &[open] summary svg { transform: rotate(180deg); } }