diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx index 9ce381e4..099c6fd0 100644 --- a/src/components/ExternalContentHeader.tsx +++ b/src/components/ExternalContentHeader.tsx @@ -11,7 +11,7 @@ import { LinkEmbed } from '@/components/LinkEmbed'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { WikipediaIcon } from '@/components/icons/WikipediaIcon'; import { BlueskyIcon } from '@/components/icons/BlueskyIcon'; -import { extractYouTubeId, extractWikipediaTitle, extractBlueskyPost } from '@/lib/linkEmbed'; +import { extractYouTubeId, extractWikipediaTitle, extractWikidataId, extractBlueskyPost } from '@/lib/linkEmbed'; import { parseExternalUri, formatIsbn } from '@/lib/externalContent'; import { shareOrCopy } from '@/lib/share'; import { useLinkPreview } from '@/hooks/useLinkPreview'; @@ -26,6 +26,7 @@ import { useShareOrigin } from '@/hooks/useShareOrigin'; import { genUserName } from '@/lib/genUserName'; import { getCountryInfo, getWikipediaTitle } from '@/lib/countries'; import { useWikipediaSummary } from '@/hooks/useWikipediaSummary'; +import { useWikidataEntity } from '@/hooks/useWikidataEntity'; import { EXTRA_KINDS } from '@/lib/extraKinds'; import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems'; import { cn } from '@/lib/utils'; @@ -36,12 +37,17 @@ import { cn } from '@/lib/utils'; export function UrlContentHeader({ url }: { url: string }) { const wikiTitle = useMemo(() => extractWikipediaTitle(url), [url]); + const wikidataId = useMemo(() => extractWikidataId(url), [url]); const blueskyPost = useMemo(() => extractBlueskyPost(url), [url]); if (wikiTitle) { return ; } + if (wikidataId) { + return ; + } + if (blueskyPost) { return ; } @@ -49,6 +55,40 @@ export function UrlContentHeader({ url }: { url: string }) { return ; } +// --------------------------------------------------------------------------- +// Wikidata entity header — resolves the entity to its Wikipedia article and +// delegates to WikipediaArticleHeader. Falls back to LinkEmbed when there is +// no English Wikipedia sitelink (or while resolving fails). +// --------------------------------------------------------------------------- + +function WikidataEntityHeader({ id, url }: { id: string; url: string }) { + const { data: entity, isLoading } = useWikidataEntity(id); + + if (isLoading) { + return ( +
+ +
+ + + +
+ + + +
+
+
+ ); + } + + if (entity?.wikipediaTitle && entity.wikipediaUrl) { + return ; + } + + return ; +} + // --------------------------------------------------------------------------- // Bluesky post header (full feed-style, like a thread top post) // --------------------------------------------------------------------------- diff --git a/src/hooks/useWikidataEntity.ts b/src/hooks/useWikidataEntity.ts new file mode 100644 index 00000000..31c31dd5 --- /dev/null +++ b/src/hooks/useWikidataEntity.ts @@ -0,0 +1,67 @@ +import { useQuery } from '@tanstack/react-query'; + +export interface WikidataEntity { + /** The Wikidata entity ID (e.g. "Q42"). */ + id: string; + /** + * English Wikipedia article title for this entity, if one exists. + * Derived from the `enwiki` sitelink. + */ + wikipediaTitle: string | null; + /** + * Full URL to the English Wikipedia article for this entity, if one exists. + * Derived from the `enwiki` sitelink URL. + */ + wikipediaUrl: string | null; +} + +async function fetchWikidataEntity( + id: string, + signal?: AbortSignal, +): Promise { + try { + // Use the Action API with CORS-friendly origin=* and minimal props. + // We only need the English Wikipedia sitelink to resolve to a Wikipedia article. + const url = new URL('https://www.wikidata.org/w/api.php'); + url.searchParams.set('action', 'wbgetentities'); + url.searchParams.set('ids', id); + url.searchParams.set('props', 'sitelinks/urls'); + url.searchParams.set('sitefilter', 'enwiki'); + url.searchParams.set('format', 'json'); + url.searchParams.set('origin', '*'); + + const response = await fetch(url.toString(), { + signal, + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) return null; + + const data = await response.json(); + const entity = data?.entities?.[id]; + if (!entity || entity.missing !== undefined) return null; + + const enwiki = entity.sitelinks?.enwiki; + const wikipediaTitle = typeof enwiki?.title === 'string' ? enwiki.title : null; + const wikipediaUrl = typeof enwiki?.url === 'string' ? enwiki.url : null; + + return { id, wikipediaTitle, wikipediaUrl }; + } catch { + return null; + } +} + +/** + * Resolve a Wikidata entity ID (e.g. "Q42") to its English Wikipedia article, if any. + * Uses the Wikidata Action API `wbgetentities` endpoint with `sitefilter=enwiki`. + */ +export function useWikidataEntity(id: string | null) { + return useQuery({ + queryKey: ['wikidata-entity', id], + queryFn: ({ signal }) => fetchWikidataEntity(id!, signal), + enabled: !!id, + staleTime: 1000 * 60 * 60 * 24, // 24 hours + gcTime: 1000 * 60 * 60 * 24 * 7, // 7 days + retry: 1, + }); +} diff --git a/src/lib/linkEmbed.ts b/src/lib/linkEmbed.ts index e83313dc..d1d74842 100644 --- a/src/lib/linkEmbed.ts +++ b/src/lib/linkEmbed.ts @@ -125,6 +125,29 @@ export function extractArchiveOrgId(url: string): string | null { } } +/** + * Extract a Wikidata entity ID (Q…, P…, or L…) from a Wikidata URL, or null if not one. + * + * Supports the "globally unique" concept URI form described at + * https://www.wikidata.org/wiki/Wikidata:Identifiers: + * - https://www.wikidata.org/entity/Q42 + * - http://www.wikidata.org/entity/Q42 + * …as well as the browser-facing page URL: + * - https://www.wikidata.org/wiki/Q42 + */ +export function extractWikidataId(url: string): string | null { + try { + const u = new URL(url); + const host = u.hostname.replace(/^www\./, ''); + if (host !== 'wikidata.org') return null; + // Match /entity/{id} or /wiki/{id} where id is Q|P|L followed by digits. + const match = u.pathname.match(/^\/(?:entity|wiki)\/([QPL]\d+)$/); + return match ? match[1] : null; + } catch { + return null; + } +} + /** * Extract a Wikipedia article title from a Wikipedia URL, or null if not a Wikipedia article link. * Supports en.wikipedia.org/wiki/{title} and other language subdomains. diff --git a/src/pages/ExternalContentPage.tsx b/src/pages/ExternalContentPage.tsx index a6b5108e..b0426352 100644 --- a/src/pages/ExternalContentPage.tsx +++ b/src/pages/ExternalContentPage.tsx @@ -38,10 +38,11 @@ import { useLayoutOptions } from '@/contexts/LayoutContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useFeedSettings } from '@/hooks/useFeedSettings'; import { useWikipediaSummary } from '@/hooks/useWikipediaSummary'; +import { useWikidataEntity } from '@/hooks/useWikidataEntity'; import { useToast } from '@/hooks/useToast'; import { getDisplayName } from '@/lib/getDisplayName'; import { timeAgo } from '@/lib/timeAgo'; -import { extractWikipediaTitle } from '@/lib/linkEmbed'; +import { extractWikipediaTitle, extractWikidataId } from '@/lib/linkEmbed'; import { cn } from '@/lib/utils'; import type { NostrEvent } from '@nostrify/nostrify'; import type { BookReview } from '@/lib/bookstr'; @@ -170,7 +171,12 @@ export function ExternalContentPage() { const { data: linkPreview } = useLinkPreview(linkPreviewUrl); // For Wikipedia URLs, use the Wikipedia API for accurate titles. - const wikiTitle = useMemo(() => linkPreviewUrl ? extractWikipediaTitle(linkPreviewUrl) : null, [linkPreviewUrl]); + // For Wikidata URLs, resolve the entity's English Wikipedia sitelink and fall through + // to the Wikipedia branch so the page title and back-link behave identically. + const directWikiTitle = useMemo(() => linkPreviewUrl ? extractWikipediaTitle(linkPreviewUrl) : null, [linkPreviewUrl]); + const wikidataId = useMemo(() => linkPreviewUrl ? extractWikidataId(linkPreviewUrl) : null, [linkPreviewUrl]); + const { data: wikidataEntity } = useWikidataEntity(directWikiTitle ? null : wikidataId); + const wikiTitle = directWikiTitle ?? wikidataEntity?.wikipediaTitle ?? null; const { data: wikiSummary } = useWikipediaSummary(wikiTitle); const resolvedTitle = wikiSummary?.title ?? linkPreview?.title;