feat: add IETF RFC search extension
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user