diff --git a/apps/web/src/components/map/MapFooter.test.tsx b/apps/web/src/components/map/MapFooter.test.tsx index d1cad1f3c..0585e83ec 100644 --- a/apps/web/src/components/map/MapFooter.test.tsx +++ b/apps/web/src/components/map/MapFooter.test.tsx @@ -62,9 +62,8 @@ it("shows one captured MapTiler credit across owners while earlier basemap credi expect( notices.getByRole("link", { name: "OpenStreetMap contributors" }).getAttribute("href"), ).toBe("https://www.openstreetmap.org/copyright"); - expect(notices.getByRole("link", { name: "Licence not specified" }).getAttribute("href")).toBe( - "https://www.maptiler.com/terms/cloud/", - ); + // MapTiler states no licence for its material, so its credit names none. + expect(notices.queryByText(/Licence not specified/)).toBeNull(); act(() => useMapAttributionStore.setState({ byLayer: { base: [osm] } })); expect(notices.queryByRole("link", { name: "MapTiler" })).toBeNull(); }); diff --git a/apps/web/src/components/ui/AttributionDetails.test.tsx b/apps/web/src/components/ui/AttributionDetails.test.tsx index 5f4bcdd97..0a526a34c 100644 --- a/apps/web/src/components/ui/AttributionDetails.test.tsx +++ b/apps/web/src/components/ui/AttributionDetails.test.tsx @@ -163,11 +163,11 @@ it("uses the supplied linked source credit as the heading without repeating its }); it.each([ - ["en", "Varies by item", "Licence not specified"], - ["de", "Abhängig vom Eintrag", "Lizenz nicht angegeben"], + ["en", "Varies by item", "Licence not specified", "Terms"], + ["de", "Abhängig vom Eintrag", "Lizenz nicht angegeben", "Nutzungsbedingungen"], ] as const)( "uses concise generic licence labels in %s while preserving required notices and links", - (locale, perItem, unknown) => { + (locale, perItem, unknown, terms) => { const generic = "Determined by each contributing item or operator"; const dialog = show( { @@ -188,6 +188,11 @@ it.each([ conditions: ["Check each image's permission"], }, { scope: "data", subject: "Unknown dataset", license: { kind: "unknown" } }, + { + scope: "data", + subject: "Catalogue entry", + license: { kind: "unknown", raw: "todo", url: "https://operator.example/terms" }, + }, ], }, }, @@ -197,7 +202,14 @@ it.each([ "href", "https://operator.example/licensing", ); - expect(dialog.getByText(unknown)).toBeVisible(); + // A licence that names nothing is left out, or shown as its terms link. + expect(dialog.queryByText(unknown)).toBeNull(); + expect(dialog.queryByText("Unknown dataset")).toBeNull(); + expect(dialog.queryByText("todo")).toBeNull(); + expect(dialog.getByRole("link", { name: terms })).toHaveAttribute( + "href", + "https://operator.example/terms", + ); expect(dialog.queryByText(generic)).toBeNull(); expect(dialog.getByText("Credit the individual photographer")).toBeVisible(); expect(dialog.getByText("Check each image's permission")).toBeVisible(); diff --git a/apps/web/src/components/ui/AttributionEntry.tsx b/apps/web/src/components/ui/AttributionEntry.tsx index 994166fce..00fa7bbb8 100644 --- a/apps/web/src/components/ui/AttributionEntry.tsx +++ b/apps/web/src/components/ui/AttributionEntry.tsx @@ -7,6 +7,8 @@ import { safeHref } from "@openmapx/core"; import { type AttributionDetail, buildRuntimeAttributionHtml, + creditLicensing, + isUnstatedLicence, parseAttributionHtml, sanitizeAttributionHtml, } from "@openmapx/core/attribution"; @@ -20,7 +22,8 @@ export function AttributionEntry({ entry }: { entry: AttributionDetail }) { const attr = entry.attribution; const readable = (html: string) => sanitizeAttributionHtml(html, { urlLabel: t("sourceLink") }); if (!attr) return ; - const licensing = attr.licensing; + // A grant naming no licence shows only as a link to its source's terms. + const licensing = attr.licensing && creditLicensing(attr.licensing, { keepLinked: true }); // Repeated subjects can have different descriptors; preserve their exact positions. const bindings = licensing ? licensingLinks(licensing).map((binding, index) => ({ @@ -84,11 +87,7 @@ export function AttributionEntry({ entry }: { entry: AttributionDetail }) { descriptor.raw === "Determined by each contributing item or operator") ) return t("licenceVariesByItem"); - if ( - descriptor?.kind === "unknown" && - (!descriptor.raw || descriptor.raw === "Licence not specified") - ) - return t("licenceNotSpecified"); + if (descriptor && isUnstatedLicence(descriptor)) return t("terms"); return binding.label; }; const grant = (binding: (typeof bindings)[number], showSubject: boolean) => ( diff --git a/apps/web/src/integration-api/map/popupCard.ts b/apps/web/src/integration-api/map/popupCard.ts index 2317acfde..c2fa5170c 100644 --- a/apps/web/src/integration-api/map/popupCard.ts +++ b/apps/web/src/integration-api/map/popupCard.ts @@ -1,4 +1,4 @@ -import { escapeHtml } from "@openmapx/core"; +import { creditLicensing, escapeHtml } from "@openmapx/core"; import { DISPLAY_LICENSE_SCOPES, formatLicensing, @@ -98,7 +98,8 @@ function attributionText(raw: unknown): string { if (raw != null && typeof raw === "object") { const o = raw as Record; const provider = typeof o.provider === "string" ? o.provider : ""; - const licensing = parseLicensing(o.licensing); + const parsed = parseLicensing(o.licensing); + const licensing = parsed && creditLicensing(parsed); const license = licensing ? formatLicensing(licensing, { scopes: DISPLAY_LICENSE_SCOPES }) : ""; return provider && license ? `${provider} · ${license}` : provider || license; } diff --git a/apps/web/src/lib/attributionCredits.ts b/apps/web/src/lib/attributionCredits.ts index 3ba87e184..04f069353 100644 --- a/apps/web/src/lib/attributionCredits.ts +++ b/apps/web/src/lib/attributionCredits.ts @@ -1,4 +1,4 @@ -import { safeHref } from "@openmapx/core"; +import { creditLicensing, safeHref } from "@openmapx/core"; import type { Attribution } from "@openmapx/mobility-core/attribution"; import { contributorKey } from "@openmapx/mobility-core/contributors"; import { @@ -50,8 +50,9 @@ const MAX_CHIP_LICENSE_LENGTH = 24; /** A receipt's licence as a short label, or undefined without licensing. */ export function creditLicense(attr: Attribution): string | undefined { - return attr.licensing - ? formatLicensing(attr.licensing, { scopes: DISPLAY_LICENSE_SCOPES }) || undefined + const licensing = attr.licensing && creditLicensing(attr.licensing); + return licensing + ? formatLicensing(licensing, { scopes: DISPLAY_LICENSE_SCOPES }) || undefined : undefined; } diff --git a/integrations/transit-dynamic-registry/index.ts b/integrations/transit-dynamic-registry/index.ts index 1e0718e3f..b15d555da 100644 --- a/integrations/transit-dynamic-registry/index.ts +++ b/integrations/transit-dynamic-registry/index.ts @@ -28,7 +28,10 @@ function buildAttributionFor( name: entryAttribution.name, url: entryAttribution.homepage, licensing: licensingFromDeclaration( - entryAttribution.license, + // The catalogue writes "todo" where an entry's licence is yet to be found. + /^todo$/i.test(entryAttribution.license?.trim() ?? "") + ? undefined + : entryAttribution.license, undefined, entryAttribution.name, ), diff --git a/packages/core/src/utils/attribution-presentation.test.ts b/packages/core/src/utils/attribution-presentation.test.ts index 597cc178a..2a0c22935 100644 --- a/packages/core/src/utils/attribution-presentation.test.ts +++ b/packages/core/src/utils/attribution-presentation.test.ts @@ -266,3 +266,31 @@ it("retains ambiguous repeated-label notices when their link targets differ", () expect(result.visible).toHaveLength(2); expect(result.fullHtml).toEqual([repeatedLabelNotice, other]); }); + +describe("credits without an unstated licence", () => { + const credit = (license: Record) => + attributionHtml.buildRuntimeAttributionHtml({ + sourceId: "kvb", + name: "Kölner Verkehrs-Betriebe AG", + url: "https://kvb.koeln/", + licensing: { + version: 1, + bindings: [{ scope: "data", subject: "KVB", license }], + } as never, + }); + + it("names the source alone when its licence is unknown", () => { + expect(credit({ kind: "unknown" })).toBe( + 'Kölner Verkehrs-Betriebe AG', + ); + }); + + it("treats a catalogue's placeholder as no licence", () => { + expect(credit({ kind: "unknown", raw: "todo" })).not.toContain("todo"); + }); + + it("keeps a licence the source names", () => { + expect(credit({ kind: "spdx", expression: "CC-BY-4.0" })).toContain("CC-BY-4.0"); + expect(credit({ kind: "unknown", raw: "Proprietary" })).toContain("Proprietary"); + }); +}); diff --git a/packages/core/src/utils/attribution.ts b/packages/core/src/utils/attribution.ts index 8eca6288b..8ca781252 100644 --- a/packages/core/src/utils/attribution.ts +++ b/packages/core/src/utils/attribution.ts @@ -175,6 +175,34 @@ export function buildAttributionHtml(ds: { }); } +/** Placeholders a catalogue leaves where its licence is yet to be filled in. */ +const LICENCE_PLACEHOLDER = + /^(?:todo|tbd|tba|n\/?a|-|\?+|licence not specified|license not specified)$/i; + +/** Whether a grant names no licence: unknown, and without a licence name of its own. */ +export function isUnstatedLicence(license: Licensing["bindings"][number]["license"]): boolean { + return ( + license.kind === "unknown" && + (!license.raw?.trim() || LICENCE_PLACEHOLDER.test(license.raw.trim())) + ); +} + +/** + * The licensing a credit shows: without grants that name no licence. "Licence + * not specified" beside a source tells a reader nothing, so the credit names + * the source alone. With `keepLinked`, an unstated grant that links its source's + * terms stays, for views that can label that link. + */ +export function creditLicensing( + licensing: Licensing, + options: { keepLinked?: boolean } = {}, +): Licensing | undefined { + const bindings = licensing.bindings.filter( + (binding) => !isUnstatedLicence(binding.license) || (options.keepLinked && binding.license.url), + ); + return bindings.length > 0 ? { ...licensing, bindings } : undefined; +} + /** Render all applicable subjects and grants using the shared licence registry. */ export function buildLicensingHtml( licensing: Licensing, @@ -214,7 +242,8 @@ export function buildRuntimeAttributionHtml( const credit = attribution.attributionText ? sanitizeAttributionHtml(attribution.attributionText) : nameLink; - const licensing = attribution.licensing ? buildLicensingHtml(attribution.licensing, options) : ""; + const shown = attribution.licensing && creditLicensing(attribution.licensing); + const licensing = shown ? buildLicensingHtml(shown, options) : ""; return licensing ? `${credit} (${licensing})` : credit; } diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index b2595a321..7d0aaa46f 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -13,7 +13,9 @@ export { buildAttributionPresentation, buildLicensingHtml, buildRuntimeAttributionHtml, + creditLicensing, extractSourcePrefix, + isUnstatedLicence, parseAttributionHtml, pickIntegrationForSources, sanitizeAttributionHtml, diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index 2dd401271..67ae659c9 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -388,7 +388,6 @@ }, "footer": { "licenceVariesByItem": "Abhängig vom Eintrag", - "licenceNotSpecified": "Lizenz nicht angegeben", "termsOfUse": "Nutzungsbedingungen", "sourceLink": "Quelle", "additionalSource": "Originalquelle", diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index b3a2fb0b0..900c5e289 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -388,7 +388,6 @@ }, "footer": { "licenceVariesByItem": "Varies by item", - "licenceNotSpecified": "Licence not specified", "termsOfUse": "Terms of use", "sourceLink": "Source", "additionalSource": "Original source",