Fix wiki/wikidata URL builders (Bugs 1-3 from 2026-05-24 handoff)

The backend (navi-places wiki_rewrite) already turns each OSM wiki tag into a
complete URL in extratags.{wikipedia,wikidata,wikivoyage} -- a local Kiwix URL
(https://wiki.echo6.co/content/...) when the article is mirrored, otherwise the
public URL -- and records which in sources.wiki_rewrites[tag] = local|public.
PlaceCard and PlaceDetail ignored those rewritten values and rebuilt/linked from
the wrong fields, causing all three bugs. Verified against the live
/api/place/R/121355 (Twin Falls) response before and after.

- Bug 1 (Wikipedia "(local)" href was public): the link used the public
  wiki-index field wiki_url with a hardcoded "(local)" badge, while the actual
  local URL sat unused in extratags.wikipedia. Now links to extratags.wikipedia
  and shows "(local)" only when sources.wiki_rewrites.wikipedia === "local".
- Bug 2 (Wikivoyage "(local)" href was public): same hardcoded badge on the
  public wikivoyage_url. Now prefers the rewritten extratags.wikivoyage (local
  when mirrored) and badges from sources.wiki_rewrites.wikivoyage; falls back to
  the public wikivoyage_url with no "(local)" badge (Twin Falls has no mirror).
- Bug 3 (Wikidata URL doubled): the link prepended https://www.wikidata.org/wiki/
  onto extratags.wikidata, which is already that full URL -> .../wiki/https://...
  Now uses the value directly (only builds the URL for a bare Q-id).

New frontend/src/utils/wiki.js centralizes the builders (wikipediaLink,
wikivoyageLink, wikidataHref); both components import them, and the duplicated
public-URL builder (wikiUrl) is removed from each. No backend changes. Frontend
build clean; navi-places tests still 13 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt 2026-05-26 06:02:05 +00:00
commit dd5a231cc2
3 changed files with 100 additions and 50 deletions

View file

@ -9,6 +9,7 @@ import { useStore } from "../store"
import { fetchElevation, fetchPlaceDetails, fetchPlaceByWikidata, fetchDriveTime, fetchNearbyContacts, fetchLandclass, fetchReverse } from "../api"
import { hasFeature } from "../config"
import { buildAddress } from "../utils/place"
import { wikipediaLink, wikivoyageLink, wikidataHref } from "../utils/wiki"
// Wiki service icons (simplified monochrome versions)
@ -110,13 +111,6 @@ function wheelchairLabel(val) {
return map[val.toLowerCase()] || null
}
function wikiUrl(wp) {
if (!wp) return null
const [lang, ...rest] = wp.split(":")
const title = rest.join(":").replace(/ /g, "_")
return "https://" + lang + ".wikipedia.org/wiki/" + encodeURIComponent(title)
}
function wikiLabel(wp) {
if (!wp) return null
const [, ...rest] = wp.split(":")
@ -218,13 +212,16 @@ function EnrichmentSkeleton() {
function EnrichmentSections({ details }) {
if (!details) return null
const { category, extratags, wiki_url, wikivoyage_url } = details
const { category, extratags } = details
const et = extratags || {}
const wp = wikipediaLink(details)
const wv = wikivoyageLink(details)
const wd = wikidataHref(et)
const hasAbout = category
const hasHours = et.opening_hours
const hasContact = et.phone || et.website || et.email
const hasDetails = et.cuisine || et.operator || et.fee || et.wheelchair || et.takeaway
const hasLinks = et.wikipedia || et.wikidata || wiki_url || wikivoyage_url
const hasLinks = wp || wv || wd
if (!hasAbout && !hasHours && !hasContact && !hasDetails && !hasLinks) return null
let idx = 0
return (
@ -258,27 +255,22 @@ function EnrichmentSections({ details }) {
{hasLinks && (
<DetailSection label="Links" icon={BookOpen} first={idx++ === 0}>
<div className="flex flex-col gap-1.5">
{wiki_url ? (
<a href={wiki_url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs" style={{ color: "var(--accent)" }}>
<WikipediaIcon size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />
<span>Wikipedia</span>
<span style={{ color: "var(--text-tertiary)", fontSize: "9px" }}>(local)</span>
</a>
) : et.wikipedia && wikiUrl(et.wikipedia) && (
<a href={wikiUrl(et.wikipedia)} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs" style={{ color: "var(--accent)" }}>
{wp && (
<a href={wp.href} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs" style={{ color: "var(--accent)" }}>
<WikipediaIcon size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />
<span>Wikipedia</span>
{wp.local && <span style={{ color: "var(--text-tertiary)", fontSize: "9px" }}>(local)</span>}
</a>
)}
{wikivoyage_url && (
<a href={wikivoyage_url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs" style={{ color: "var(--accent)" }}>
{wv && (
<a href={wv.href} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs" style={{ color: "var(--accent)" }}>
<WikivoyageIcon size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />
<span>Wikivoyage</span>
<span style={{ color: "var(--text-tertiary)", fontSize: "9px" }}>(local)</span>
{wv.local && <span style={{ color: "var(--text-tertiary)", fontSize: "9px" }}>(local)</span>}
</a>
)}
{et.wikidata && (
<a href={"https://www.wikidata.org/wiki/" + et.wikidata} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs" style={{ color: "var(--accent)" }}>
{wd && (
<a href={wd} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs" style={{ color: "var(--accent)" }}>
<WikidataIcon size={13} style={{ color: "var(--text-tertiary)", flexShrink: 0 }} />
<span>Wikidata</span>
</a>

View file

@ -9,6 +9,7 @@ import { useStore } from '../store'
import { fetchElevation, fetchPlaceDetails, fetchPlaceByWikidata, fetchDriveTime, fetchNearbyContacts, fetchLandclass, fetchReverse } from '../api'
import { hasFeature } from '../config'
import { buildAddress } from '../utils/place'
import { wikipediaLink, wikivoyageLink, wikidataHref } from '../utils/wiki'
// Wiki service icons (simplified monochrome versions)
@ -120,13 +121,6 @@ function wheelchairLabel(val) {
return map[val.toLowerCase()] || null
}
function wikiUrl(wp) {
if (!wp) return null
const match = wp.match(/^([a-z-]+):(.+)$/)
if (!match) return null
return `https://${match[1]}.wikipedia.org/wiki/${encodeURIComponent(match[2].replace(/ /g, '_'))}`
}
function wikiLabel(wp) {
if (!wp) return null
const match = wp.match(/^[a-z-]+:(.+)$/)
@ -277,14 +271,17 @@ function CopyPopover({ address, selectedPlace, onClose }) {
function EnrichmentSections({ details }) {
if (!details) return null
const { category, extratags, wiki_url, wikivoyage_url } = details
const { category, extratags } = details
const et = extratags || {}
const wp = wikipediaLink(details)
const wv = wikivoyageLink(details)
const wd = wikidataHref(et)
const hasAbout = category
const hasHours = et.opening_hours
const hasContact = et.phone || et.website || et.email
const hasDetails = et.cuisine || et.operator || et.fee || et.wheelchair || et.takeaway
const hasLinks = et.wikipedia || et.wikidata || wiki_url || wikivoyage_url
const hasLinks = wp || wv || wd
if (!hasAbout && !hasHours && !hasContact && !hasDetails && !hasLinks) return null
@ -346,21 +343,9 @@ function EnrichmentSections({ details }) {
{hasLinks && (
<DetailSection label="Links" icon={BookOpen} first={idx++ === 0}>
<div className="flex flex-col gap-1.5">
{wiki_url ? (
{wp && (
<a
href={wiki_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs"
style={{ color: 'var(--accent)' }}
>
<WikipediaIcon size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
<span>Wikipedia</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: '9px' }}>(local)</span>
</a>
) : et.wikipedia && wikiUrl(et.wikipedia) && (
<a
href={wikiUrl(et.wikipedia)}
href={wp.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs"
@ -368,11 +353,12 @@ function EnrichmentSections({ details }) {
>
<WikipediaIcon size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
<span>Wikipedia</span>
{wp.local && <span style={{ color: 'var(--text-tertiary)', fontSize: '9px' }}>(local)</span>}
</a>
)}
{wikivoyage_url && (
{wv && (
<a
href={wikivoyage_url}
href={wv.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs"
@ -380,12 +366,12 @@ function EnrichmentSections({ details }) {
>
<WikivoyageIcon size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
<span>Wikivoyage</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: '9px' }}>(local)</span>
{wv.local && <span style={{ color: 'var(--text-tertiary)', fontSize: '9px' }}>(local)</span>}
</a>
)}
{et.wikidata && (
{wd && (
<a
href={`https://www.wikidata.org/wiki/${et.wikidata}`}
href={wd}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs"

View file

@ -0,0 +1,72 @@
// Wiki / Wikidata link builders.
//
// The backend (navi-places wiki_rewrite) already turns each OSM wiki tag into a
// complete URL in extratags.{wikipedia,wikidata,wikivoyage} — a local Kiwix URL
// (https://wiki.echo6.co/content/...) when the article is mirrored, otherwise the
// public URL — and records which in sources.wiki_rewrites[tag] = 'local' | 'public'.
//
// These helpers use those rewritten values verbatim instead of rebuilding URLs
// from raw tag values. Rebuilding was the source of three bugs: the Wikipedia /
// Wikivoyage links showed a "(local)" badge while pointing at the public site
// (they used the public wiki-index fields and ignored the rewritten extratags),
// and Wikidata doubled its prefix onto an already-complete URL.
// Public Wikipedia URL from a raw OSM tag value ("en:Title" or "Title").
function publicWikipediaUrl(wp) {
if (!wp) return null
const [lang, ...rest] = wp.split(":")
const title = rest.join(":").replace(/ /g, "_")
if (!title) return null
return `https://${lang}.wikipedia.org/wiki/${encodeURIComponent(title)}`
}
// Public Wikivoyage URL from a raw OSM tag value ("en:Title" or "Title").
function publicWikivoyageUrl(wv) {
if (!wv) return null
const parts = wv.split(":")
const title = (parts.length > 1 ? parts.slice(1).join(":") : parts[0]).replace(/ /g, "_")
if (!title) return null
return `https://en.wikivoyage.org/wiki/${encodeURIComponent(title)}`
}
// Resolve the Wikipedia link for a place: { href, local } or null.
// Prefers the backend-rewritten extratags.wikipedia (already a full URL); only
// falls back to the public wiki-index wiki_url when there is no OSM wikipedia tag.
export function wikipediaLink(details) {
const et = (details && details.extratags) || {}
const status = details && details.sources && details.sources.wiki_rewrites
? details.sources.wiki_rewrites.wikipedia
: undefined
if (et.wikipedia) {
const href = et.wikipedia.startsWith("http")
? et.wikipedia
: publicWikipediaUrl(et.wikipedia)
return href ? { href, local: status === "local" } : null
}
if (details && details.wiki_url) return { href: details.wiki_url, local: false }
return null
}
// Resolve the Wikivoyage link for a place: { href, local } or null.
export function wikivoyageLink(details) {
const et = (details && details.extratags) || {}
const status = details && details.sources && details.sources.wiki_rewrites
? details.sources.wiki_rewrites.wikivoyage
: undefined
if (et.wikivoyage) {
const href = et.wikivoyage.startsWith("http")
? et.wikivoyage
: publicWikivoyageUrl(et.wikivoyage)
return href ? { href, local: status === "local" } : null
}
if (details && details.wikivoyage_url) return { href: details.wikivoyage_url, local: false }
return null
}
// Resolve the Wikidata href. extratags.wikidata is already a full URL after the
// backend rewrite; only build the URL when given a bare Q-id (rewriting disabled).
export function wikidataHref(et) {
const wd = et && et.wikidata
if (!wd) return null
return wd.startsWith("http") ? wd : `https://www.wikidata.org/wiki/${wd}`
}