355 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
}
|