Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions apps/web/src/components/map/MapFooter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
20 changes: 16 additions & 4 deletions apps/web/src/components/ui/AttributionDetails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand All @@ -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" },
},
],
},
},
Expand All @@ -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();
Expand Down
11 changes: 5 additions & 6 deletions apps/web/src/components/ui/AttributionEntry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { safeHref } from "@openmapx/core";
import {
type AttributionDetail,
buildRuntimeAttributionHtml,
creditLicensing,
isUnstatedLicence,
parseAttributionHtml,
sanitizeAttributionHtml,
} from "@openmapx/core/attribution";
Expand All @@ -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 <AttributionText html={readable(entry.html)} />;
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) => ({
Expand Down Expand Up @@ -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) => (
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/integration-api/map/popupCard.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { escapeHtml } from "@openmapx/core";
import { creditLicensing, escapeHtml } from "@openmapx/core";
import {
DISPLAY_LICENSE_SCOPES,
formatLicensing,
Expand Down Expand Up @@ -98,7 +98,8 @@ function attributionText(raw: unknown): string {
if (raw != null && typeof raw === "object") {
const o = raw as Record<string, unknown>;
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;
}
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/lib/attributionCredits.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
}

Expand Down
5 changes: 4 additions & 1 deletion integrations/transit-dynamic-registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/utils/attribution-presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) =>
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(
'<a href="https://kvb.koeln/" target="_blank" rel="noopener noreferrer">Kölner Verkehrs-Betriebe AG</a>',
);
});

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");
});
});
31 changes: 30 additions & 1 deletion packages/core/src/utils/attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ export {
buildAttributionPresentation,
buildLicensingHtml,
buildRuntimeAttributionHtml,
creditLicensing,
extractSourcePrefix,
isUnstatedLicence,
parseAttributionHtml,
pickIntegrationForSources,
sanitizeAttributionHtml,
Expand Down
1 change: 0 additions & 1 deletion packages/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,6 @@
},
"footer": {
"licenceVariesByItem": "Abhängig vom Eintrag",
"licenceNotSpecified": "Lizenz nicht angegeben",
"termsOfUse": "Nutzungsbedingungen",
"sourceLink": "Quelle",
"additionalSource": "Originalquelle",
Expand Down
1 change: 0 additions & 1 deletion packages/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,6 @@
},
"footer": {
"licenceVariesByItem": "Varies by item",
"licenceNotSpecified": "Licence not specified",
"termsOfUse": "Terms of use",
"sourceLink": "Source",
"additionalSource": "Original source",
Expand Down
Loading