Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 60 additions & 28 deletions src/components/plugins/PluginsFilters.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -33,39 +39,44 @@ 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<FilterStateType>) {
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) => {
switch (field) {
case "categories":
return p.category;
case "versions":
return keys(p.versions);
return getVisibleVersionTags(p, showLegacy);
}
}),
),
Expand Down Expand Up @@ -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],
);

Expand Down Expand Up @@ -150,6 +161,27 @@ export const PluginsFilters: FC<{ plugins: Plugin[] }> = ({ plugins }) => {
}));
}}
/>
<div className="checkbox d-flex align-items-center gap-2 mt-3">
<input
type="checkbox"
id="show-legacy-checkbox"
checked={state.showLegacy}
onChange={(e) => {
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"
/>
<label className="form-check-label" htmlFor="show-legacy-checkbox">
Show older Gephi versions
</label>
</div>
</div>

<div>
Expand Down
58 changes: 58 additions & 0 deletions src/components/plugins/utils.ts
Original file line number Diff line number Diff line change
@@ -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<string, Plugin["versions"][string]> {
return Object.entries(versions).reduce<Record<string, Plugin["versions"][string]>>((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<string | null> {
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;
}
}
96 changes: 74 additions & 22 deletions src/pages/desktop/plugins/[id].astro
Original file line number Diff line number Diff line change
@@ -1,37 +1,53 @@
---
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 {
readme = await marked(plugin.readme);
} 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;
---

<Layout class="plugin-page">
Expand All @@ -47,6 +63,19 @@ try {

<p class="fs-4">{plugin.short_description}</p>
<p class="fs-5 opacity-75">last updated on {plugin.last_update}</p>
{
topVersionIsLatestGephi && (
<div>
<a
href={`https://raw.githubusercontent.com/gephi/gephi-plugins/gh-pages/plugins/${groupedVersions[topVersionTag].url}`}
class="btn btn-white fs-4 fw-bold"
>
<Icon name="ph:download" />
Download plugin for Gephi v{topVersionTag}
</a>
</div>
)
}
</div>
{
!!plugin.images?.length && (
Expand Down Expand Up @@ -141,22 +170,45 @@ try {
instance.
</div>
{
!isEmpty(plugin.versions) ? (
<ul class="list-unstyled">
{Object.keys(plugin.versions)
.reverse()
.map((v) => (
<li>
<a
href={`https://raw.githubusercontent.com/gephi/gephi-plugins/gh-pages/plugins/${plugin.versions[v].url}`}
class="text-decoration-none"
>
<Icon name="ph:download" />
Download plugin for Gephi v{v}
</a>
</li>
))}
</ul>
sortedVersionTags.length > 0 ? (
<>
{modernVersionTags.length > 0 && (
<ul class="list-unstyled">
{modernVersionTags.map((v) => (
<li>
<a
href={`https://raw.githubusercontent.com/gephi/gephi-plugins/gh-pages/plugins/${groupedVersions[v].url}`}
class="text-decoration-none"
>
<Icon name="ph:download" />
Download plugin for Gephi v{v}
</a>
</li>
))}
</ul>
)}
{legacyVersionTags.length > 0 && (
<details class="older-versions mt-2">
<summary>
Show older versions
<Icon name="ph:caret-down" />
</summary>
<ul class="list-unstyled mt-2">
{legacyVersionTags.map((v) => (
<li>
<a
href={`https://raw.githubusercontent.com/gephi/gephi-plugins/gh-pages/plugins/${groupedVersions[v].url}`}
class="text-decoration-none"
>
<Icon name="ph:download" />
Download plugin for Gephi v{v}
</a>
</li>
))}
</ul>
</details>
)}
</>
) : (
<div class="text-muted">No compatible version specified.</div>
)
Expand Down
Loading
Loading