Files
vicinae-extension-ietf-rfc/src/search-rfc.tsx
T

355 lines
9.2 KiB
TypeScript

import {
Action,
ActionPanel,
Cache,
Color,
Icon,
List,
showToast,
Toast,
} from "@vicinae/api";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
parseRfcIndex,
RFC_INDEX_URL,
type Rfc,
rfcUrl,
type StatusFilter,
searchRfcs,
} from "./rfc-index";
const cache = new Cache({ namespace: "rfc-index", capacity: 30 * 1024 * 1024 });
const CACHE_KEY = "index-v1";
const MAX_CACHE_AGE = 24 * 60 * 60 * 1000;
type CachedIndex = {
updatedAt: number;
rfcs: Rfc[];
};
const STATUS_OPTIONS = [
["all", "All statuses"],
["INTERNET STANDARD", "Internet Standard"],
["PROPOSED STANDARD", "Proposed Standard"],
["DRAFT STANDARD", "Draft Standard"],
["BEST CURRENT PRACTICE", "Best Current Practice"],
["INFORMATIONAL", "Informational"],
["EXPERIMENTAL", "Experimental"],
["HISTORIC", "Historic"],
["UNKNOWN", "Unknown"],
] as const;
function readCachedIndex(): CachedIndex | undefined {
const stored = cache.get(CACHE_KEY);
if (!stored) return undefined;
try {
const parsed = JSON.parse(stored) as CachedIndex;
if (!Array.isArray(parsed.rfcs) || typeof parsed.updatedAt !== "number") {
throw new Error("Invalid cached index");
}
return parsed;
} catch {
cache.remove(CACHE_KEY);
return undefined;
}
}
function labelForStatus(status: string): string {
return STATUS_OPTIONS.find(([value]) => value === status)?.[1] ?? status;
}
function escapeMarkdown(value: string): string {
return value.replace(/([\\`*_{}[\]()#+.!|>-])/g, "\\$1");
}
function relationText(ids: string[]): string {
return ids.join(", ");
}
function RfcDetail({ rfc }: { rfc: Rfc }) {
const obsoleteWarning = rfc.obsoletedBy.length
? `> **Obsoleted by ${relationText(rfc.obsoletedBy)}.** Prefer the newer specification unless you specifically need this RFC.\n\n`
: "";
const markdown = `# ${rfc.docId}\n\n## ${escapeMarkdown(rfc.title)}\n\n${obsoleteWarning}${escapeMarkdown(rfc.abstract || "No abstract is available for this RFC.")}`;
return (
<List.Item.Detail
markdown={markdown}
metadata={
<List.Item.Detail.Metadata>
<List.Item.Detail.Metadata.Label
title="Status"
text={
rfc.obsoletedBy.length
? { value: "Obsoleted", color: Color.Red }
: labelForStatus(rfc.status)
}
/>
<List.Item.Detail.Metadata.Label
title="Published"
text={[rfc.month, rfc.year].filter(Boolean).join(" ") || "Unknown"}
/>
<List.Item.Detail.Metadata.Label
title="Authors"
text={rfc.authors.join(", ") || "Unknown"}
/>
<List.Item.Detail.Metadata.Label
title="Stream"
text={rfc.stream ?? "Unknown"}
/>
{rfc.pageCount ? (
<List.Item.Detail.Metadata.Label
title="Pages"
text={String(rfc.pageCount)}
/>
) : null}
{rfc.workingGroup ? (
<List.Item.Detail.Metadata.Label
title="Working group"
text={rfc.workingGroup}
/>
) : null}
{rfc.isAlso.length ? (
<List.Item.Detail.Metadata.Label
title="Also"
text={relationText(rfc.isAlso)}
/>
) : null}
{rfc.obsoletes.length ? (
<List.Item.Detail.Metadata.Label
title="Obsoletes"
text={relationText(rfc.obsoletes)}
/>
) : null}
{rfc.obsoletedBy.length ? (
<List.Item.Detail.Metadata.Label
title="Obsoleted by"
text={{ value: relationText(rfc.obsoletedBy), color: Color.Red }}
/>
) : null}
{rfc.updates.length ? (
<List.Item.Detail.Metadata.Label
title="Updates"
text={relationText(rfc.updates)}
/>
) : null}
{rfc.updatedBy.length ? (
<List.Item.Detail.Metadata.Label
title="Updated by"
text={relationText(rfc.updatedBy)}
/>
) : null}
{rfc.keywords.length ? (
<List.Item.Detail.Metadata.TagList title="Keywords">
{rfc.keywords.map((keyword) => (
<List.Item.Detail.Metadata.TagList.Item
key={keyword}
text={keyword}
/>
))}
</List.Item.Detail.Metadata.TagList>
) : null}
</List.Item.Detail.Metadata>
}
/>
);
}
function RfcActions({ rfc, refresh }: { rfc: Rfc; refresh: () => void }) {
const htmlUrl = rfcUrl(rfc, "html");
return (
<ActionPanel>
<ActionPanel.Section>
<Action.OpenInBrowser
title="Open RFC"
url={htmlUrl}
icon={Icon.Book}
shortcut="open"
/>
<Action.CopyToClipboard
title="Copy RFC URL"
content={htmlUrl}
shortcut="copy"
/>
<Action.CopyToClipboard
title="Copy Citation"
content={`${rfc.docId}: ${rfc.title}`}
/>
</ActionPanel.Section>
<ActionPanel.Section title="More Formats">
{rfc.formats
.filter((format) => format !== "HTML")
.map((format) => (
<Action.OpenInBrowser
key={format}
title={`Open ${format}`}
url={rfcUrl(rfc, format)}
icon={Icon.BlankDocument}
/>
))}
<Action.OpenInBrowser
title="Open RFC Editor Info"
url={`https://www.rfc-editor.org/info/rfc${rfc.number}`}
icon={Icon.Globe01}
/>
<Action.OpenInBrowser
title="Open IETF Datatracker"
url={`https://datatracker.ietf.org/doc/rfc${rfc.number}/`}
icon={Icon.Globe01}
/>
{rfc.errataUrl ? (
<Action.OpenInBrowser
title="View Errata"
url={rfc.errataUrl}
icon={Icon.Bug}
/>
) : null}
{rfc.doi ? (
<Action.OpenInBrowser
title="Open DOI"
url={`https://doi.org/${rfc.doi}`}
icon={Icon.Link}
/>
) : null}
</ActionPanel.Section>
<ActionPanel.Section>
<Action
title="Refresh RFC Index"
icon={Icon.ArrowClockwise}
shortcut="refresh"
onAction={refresh}
/>
</ActionPanel.Section>
</ActionPanel>
);
}
export default function SearchRfc() {
const cached = useMemo(readCachedIndex, []);
const [rfcs, setRfcs] = useState<Rfc[]>(cached?.rfcs ?? []);
const [query, setQuery] = useState("");
const [status, setStatus] = useState<StatusFilter>("all");
const [isLoading, setIsLoading] = useState(!cached);
const [error, setError] = useState<string>();
const loadIndex = useCallback(async (force = false, signal?: AbortSignal) => {
const existing = readCachedIndex();
if (!force && existing && Date.now() - existing.updatedAt < MAX_CACHE_AGE) {
setRfcs(existing.rfcs);
setIsLoading(false);
return;
}
setIsLoading(true);
setError(undefined);
try {
const response = await fetch(RFC_INDEX_URL, { signal });
if (!response.ok)
throw new Error(`RFC Editor returned HTTP ${response.status}`);
const nextRfcs = parseRfcIndex(await response.text());
const nextCache = { updatedAt: Date.now(), rfcs: nextRfcs };
cache.set(CACHE_KEY, JSON.stringify(nextCache));
setRfcs(nextRfcs);
} catch (cause) {
if (cause instanceof DOMException && cause.name === "AbortError") return;
const message = cause instanceof Error ? cause.message : "Unknown error";
if (existing) {
setRfcs(existing.rfcs);
await showToast(
Toast.Style.Failure,
"Couldn't refresh the RFC index",
"Showing cached data",
);
} else {
setError(message);
}
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
const controller = new AbortController();
void loadIndex(false, controller.signal);
return () => controller.abort();
}, [loadIndex]);
const results = useMemo(
() => searchRfcs(rfcs, query, status),
[rfcs, query, status],
);
const refresh = () => void loadIndex(true);
return (
<List
isLoading={isLoading}
isShowingDetail={results.length > 0}
filtering={false}
throttle
searchText={query}
onSearchTextChange={setQuery}
searchBarPlaceholder="Search by RFC number, title, author, keyword, or abstract…"
searchBarAccessory={
<List.Dropdown
tooltip="Filter by status"
value={status}
onChange={(value) => setStatus(value as StatusFilter)}
>
{STATUS_OPTIONS.map(([value, label]) => (
<List.Dropdown.Item key={value} title={label} value={value} />
))}
</List.Dropdown>
}
>
{error ? (
<List.EmptyView
icon={Icon.CloudLightning}
title="Couldn't load the RFC index"
description={`${error}. Check your connection and try again.`}
actions={
<ActionPanel>
<Action
title="Retry"
icon={Icon.ArrowClockwise}
onAction={refresh}
/>
</ActionPanel>
}
/>
) : !isLoading && results.length === 0 ? (
<List.EmptyView
icon={Icon.MagnifyingGlass}
title={
query ? `No RFCs found for “${query}”` : "No RFCs match this status"
}
description="Try a broader term, a different status, or an RFC number such as 9110."
/>
) : (
<List.Section
title={query ? `${results.length} results` : "Latest RFCs"}
>
{results.map((rfc) => (
<List.Item
key={rfc.docId}
id={rfc.docId}
icon={rfc.obsoletedBy.length ? Icon.Warning : Icon.Book}
title={rfc.docId}
subtitle={rfc.title}
accessories={[
{
tag: rfc.obsoletedBy.length
? { value: "Obsoleted", color: Color.Red }
: labelForStatus(rfc.status),
},
{ text: rfc.year ? String(rfc.year) : undefined },
]}
detail={<RfcDetail rfc={rfc} />}
actions={<RfcActions rfc={rfc} refresh={refresh} />}
/>
))}
</List.Section>
)}
</List>
);
}