diff --git a/src/components/plugins/PluginsFilters.tsx b/src/components/plugins/PluginsFilters.tsx index 8513bc4..3c6791f 100644 --- a/src/components/plugins/PluginsFilters.tsx +++ b/src/components/plugins/PluginsFilters.tsx @@ -1,29 +1,35 @@ 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 { + 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, }; } function stateToSearch(state: FilterStateType): string { @@ -33,31 +39,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 +76,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 +114,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 +161,27 @@ export const PluginsFilters: FC<{ plugins: Plugin[] }> = ({ plugins }) => { })); }} /> +
+ { + 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 ee9935c..3a85835 100644 --- a/src/components/plugins/utils.ts +++ b/src/components/plugins/utils.ts @@ -1,3 +1,61 @@ +import type { Plugin } from "../../type"; + 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] = parseGephiVersion(v); + return major === 0 && minor < 10; +} + +export function normalizeGephiVersion(v: string): string { + if (isLegacyGephiVersion(v)) return v; + const [major, minor] = parseGephiVersion(v); + return `${major}.${minor}`; +} + +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 (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 ee535a4..da6a360 100644 --- a/src/pages/desktop/plugins/[id].astro +++ b/src/pages/desktop/plugins/[id].astro @@ -1,30 +1,37 @@ --- 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 { + compareGephiVersionsDesc, + getLatestGephiVersion, + groupPluginVersionsByTag, + isLegacyGephiVersion, +} 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 +39,15 @@ 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 = 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]; +const topVersionIsLatestGephi = !!topVersionTag && topVersionTag === latestGephiVersion; --- @@ -47,6 +63,19 @@ try {

{plugin.short_description}

last updated on {plugin.last_update}

+ { + topVersionIsLatestGephi && ( + + ) + }
{ !!plugin.images?.length && ( @@ -141,22 +170,45 @@ try { instance. { - !isEmpty(plugin.versions) ? ( - + sortedVersionTags.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..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,14 +51,11 @@ const pluginsData: Plugin[] | null = pluginsDataRequest.ok

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

{pluginsData.map((p) => ( -
`version-${v}`) : ""}`} - > + ))} diff --git a/src/styles/_plugins.scss b/src/styles/_plugins.scss index f56ae43..a7587ad 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] summary svg { + transform: rotate(180deg); + } + } + .thumbnails { display: flex; flex-direction: row;