/**
* @copyright Sister Software
* @license AGPL-3.0
* @author Teffen Ellis, et al.
*
* Reads the parts of an INSPIRE Addresses feature that every publisher spells the same way.
*
* INSPIRE harmonizes the schema and leaves the rest to the publisher. Seven publishers this
* repository reads all write `ad:Address` with the same element paths, and they disagree on four
* things that each decide a value:
*
* 1. The wrapper. Czechia writes `base:SpatialDataSet` with `base:member`. The Netherlands,
* Wallonia and Brussels write `gml:FeatureCollection` with `gml:featureMember`.
* 2. The reference form. Czechia's `ad:component` href is an absolute WFS stored-query URL
* whose `Id=` parameter is the key. The Netherlands, Wallonia and Brussels write a local `#`
* fragment. Flanders writes an absolute data URI that a caller rewrites into a `resourceId`.
* 3. Where the postcode lives. Czechia and Wallonia reference an `ad:PostalDescriptor`; the
* Netherlands puts it inline as a `LocatorDesignatorTypeValue/postalDeliveryIdentifier` locator and publishes no
* `ad:PostalDescriptor` at all.
* 4. The locator vocabulary. Czechia types its number `buildingIdentifier`, the Netherlands
* `addressNumber`, Wallonia `LocatorDesignatorTypeValue/addressIdentifierGeneral`, Brussels
* `buildingIdentifier`.
* 5. The prefix's case. Seven publishers write `ad:` and `gn:`. The Dirección General del Catastro
* and the Gobierno de Navarra bind the same namespaces to `AD:` and `GN:`. The Catastro also
* states a designator type and an administrative level as element text, `1` and `4`, where
* everyone else writes a codelist URI. The readers below therefore match an element name
* without regard to case, and accept either form of a coded value.
*
* So this module exposes the primitives and leaves composition to each adapter. A configuration
* object describing those four axes would have to be right about publishers whose files nobody has
* read yet. A handful of small functions carries no such claim.
*
* The reference form is the exception, because its shapes are enumerable and measured:
* {@linkcode componentJoinKey} reads all four.
*/
import type { MarkupElement } from "@mailwoman/core/html/elements"
/**
* Whether an element's name is `wanted`, ignoring the case of both.
*
* The prefix a document binds a namespace to is the document's own choice.
* Seven publishers write `ad:Address` and `gn:text`.
*
* The Dirección General del Catastro and the Gobierno de Navarra bind the same
* two namespaces to `AD:` and `GN:`.
* A reader comparing the name exactly finds neither a locator nor a street
* nor a postcode in either Spanish file.
*
* That reads as an address carrying none rather than as a prefix this repository had not met.
*
* The prefix is still compared, because it carries the namespace and the namespace carries the meaning.
* The Service public de Wallonie writes three `gml:name` elements inside each
* `ad:ThoroughfareName`, ahead of the `ad:name` that holds the street.
*
* A reader that dropped the prefix and matched on `name` alone read the first of those three
* and found no street on any of Wallonia's 58,591 thoroughfare features.
*/
export function inspireNameIs(name: string, wanted: string): boolean {
return name.toLowerCase() === wanted.toLowerCase()
}
/**
* The first child of `element` named `name`, ignoring case.
*
* `name` is written with the prefix the INSPIRE guideline uses, which is the
* prefix eight of the nine publishers write.
* The case-insensitive sibling of `childElement` from `@mailwoman/core/html/elements`,
* which compares the name exactly as a general markup reader must.
*/
export function inspireChild(element: MarkupElement, name: string): MarkupElement | undefined {
return element.children.find((child) => inspireNameIs(child.name, name))
}
/**
* Every child of `element` named `name`, in document order, ignoring case.
*
* The case-insensitive sibling of `childElements` from `@mailwoman/core/html/elements`,
* for the same reason {@linkcode inspireChild} is.
*/
export function inspireChildren(element: MarkupElement, name: string): readonly MarkupElement[] {
return element.children.filter((child) => inspireNameIs(child.name, name))
}
/**
* The element reached by walking `path` from `element`, taking the first match at each step.
*
* The case-insensitive sibling of `elementAtPath` from `@mailwoman/core/html/elements`.
* An INSPIRE value sits five or six elements down, so the path form reads better than
* a chain of {@linkcode inspireChild} calls and reports the same absence.
*/
export function inspireElementAt(element: MarkupElement, ...path: readonly string[]): MarkupElement | undefined {
let current: MarkupElement | undefined = element
for (const step of path) {
if (!current) return undefined
current = inspireChild(current, step)
}
return current
}
/**
* The final segment of an INSPIRE codelist URI, which is the value's name.
*
* `http://inspire.ec.europa.eu/codelist/LocatorDesignatorTypeValue/buildingIdentifier`
* reads `buildingIdentifier`.
* A `#` fragment is dropped first, because Flanders appends one to a `vocab.belgif.be`
* reference and Czechia folds it into `base:localId`.
*/
export function codelistValue(uri: string | undefined): string | undefined {
if (!uri) return undefined
const withoutFragment = uri.split("#")[0]!
const segment = withoutFragment.split("/").pop()
return segment || undefined
}
/**
* Whether a publisher marked this element void.
*
* INSPIRE writes `xsi:nil="true"` and usually a `nilReason`.
* Brussels writes the nil marker and no reason on all 232,003 of its void `ad:validFrom`
* elements, and Czechia writes four `gn:pronunciation` elements nil with no reason,
* so a reader keyed on `nilReason` alone mistakes a void value for a populated one.
*
* An absent element answers false, so a caller distinguishes a void value from a missing one.
*/
export function isVoid(element: MarkupElement | undefined): boolean {
return element?.attributes["xsi:nil"] === "true"
}
/**
* The reason a publisher gave for a void element, as a codelist value such as `Unpopulated`.
*
* Returns undefined when the element is void and states no reason, which the
* schema permits and which Brussels does throughout.
* A caller that needs to separate a void carrying a reason from a void carrying
* none reads this together with {@linkcode isVoid}.
*/
export function voidReason(element: MarkupElement | undefined): string | undefined {
if (!isVoid(element)) return undefined
return codelistValue(element?.attributes.nilReason)
}
/**
* Every locator designator on an address, keyed by its INSPIRE type name.
*
* One `ad:AddressLocator` carries a designator per part of the number, each in its
* own `ad:LocatorDesignator` beside an `ad:type` naming what it is.
* Czechia writes two, `č.ev.` typed `buildingIdentifierPrefix` and `502` typed `buildingIdentifier`.
*
* The Netherlands writes four, including an empty `LocatorDesignatorTypeValue/addressNumberExtension`
* and the postcode as `LocatorDesignatorTypeValue/postalDeliveryIdentifier`.
*
* A type may repeat, so each key holds a list in document order.
* A designator the publisher wrote empty contributes an empty string, which is a value the publisher
* stated rather than one it omitted: the Netherlands writes `` 198,118 times
* over 108,708 features to mean "no extension", and that is a different condition from void.
*
* A designator whose `ad:type` is absent or void is skipped, because its value cannot be placed.
*/
export function designatorsByType(address: MarkupElement): ReadonlyMap {
const byType = new Map()
const locator = inspireElementAt(address, "ad:locator", "ad:AddressLocator")
if (!locator) return byType
for (const wrapper of inspireChildren(locator, "ad:designator")) {
const locatorDesignator = inspireChild(wrapper, "ad:LocatorDesignator")
if (!locatorDesignator) continue
const type = inspireChild(locatorDesignator, "ad:type")
if (!type || isVoid(type)) continue
const name = codelistValue(type.attributes["xlink:href"]) ?? type.text
if (!name) continue
const value = inspireChild(locatorDesignator, "ad:designator")
// A void designator is reported by `voidDesignatorTypes` rather than here, because a
// value the publisher marked unknown is not a value and must not reach a component.
if (!value || isVoid(value)) continue
const existing = byType.get(name)
if (existing) {
existing.push(value.text)
} else {
byType.set(name, [value.text])
}
}
return byType
}
/**
* The first non-empty designator among `types`, in the order given.
*
* A caller states its publisher's vocabulary and its own precedence:
* Czechia asks for `buildingIdentifier`, the Netherlands for `addressNumber`,
* Wallonia for `LocatorDesignatorTypeValue/addressIdentifierGeneral`.
*/
/**
* The locator-designator types this address marks void, by their INSPIRE type name.
*
* {@linkcode designatorsByType} leaves a void designator out, so on its own it cannot
* separate a value the publisher marked unknown from one the publisher never wrote.
* The repository's partial-read rule needs that separation: an unreadable requested
* value has to be reported rather than turned into an absence.
*
* A caller reads this when the difference changes what it does.
* An adapter that refuses a row carrying no house number should report a void `buildingIdentifier` as
* a value it could not read, and an absent one as an address the publisher states has no number.
*/
export function voidDesignatorTypes(address: MarkupElement): ReadonlySet {
const voided = new Set()
const locator = inspireElementAt(address, "ad:locator", "ad:AddressLocator")
if (!locator) return voided
for (const wrapper of inspireChildren(locator, "ad:designator")) {
const locatorDesignator = inspireChild(wrapper, "ad:LocatorDesignator")
if (!locatorDesignator) continue
const value = inspireChild(locatorDesignator, "ad:designator")
if (!value || !isVoid(value)) continue
const type = inspireChild(locatorDesignator, "ad:type")
const name = type && !isVoid(type) ? (codelistValue(type.attributes["xlink:href"]) ?? type.text) : undefined
// A void value whose own type is void or absent cannot be placed, so it is reported under
// the empty name rather than dropped, which keeps the address's unreadable parts countable.
voided.add(name || "")
}
return voided
}
export function designator(
byType: ReadonlyMap,
...types: readonly string[]
): string | undefined {
for (const type of types) {
for (const value of byType.get(type) ?? []) {
const trimmed = value.trim()
if (trimmed) return trimmed
}
}
return undefined
}
/**
* Every `ad:component` reference on an address, as written.
*
* The href is returned verbatim, and {@linkcode componentJoinKey} turns it into a join key.
* A component whose href is absent or empty is skipped.
*
* Brussels writes `` on 9 addresses, and those 9 therefore
* carry no postal zone, which a caller must report rather than read as a resolved component.
*/
export function componentHrefs(address: MarkupElement): readonly string[] {
return componentLinks(address).map((link) => link.href)
}
/**
* One `ad:component` reference, with the title the publisher wrote beside it.
*/
export interface ComponentLink {
/**
* The `xlink:href`, trimmed and otherwise as written.
*/
readonly href: string
/**
* The `xlink:title`, or undefined where the reference carries none.
*/
readonly title?: string
}
/**
* Every `ad:component` reference on an address, with its title.
*
* Most publishers repeat the referenced feature's own value in `xlink:title`, and their adapters
* read the resolved feature instead, because a title is a copy and the feature is the record.
*
* The Gobierno de Navarra is the case that needs the title.
* Its references leave the file: a thoroughfare reference is the bare string `ThoroughfareName`
* and an administrative-unit reference addresses a CartoCiudad stored query.
*
* The title is the only statement of the street name, the postcode and the place names
* that its own file carries, so its adapter reads titles and resolves no reference.
*
* A component whose href is absent or empty is skipped, as in {@linkcode componentHrefs}.
*/
export function componentLinks(address: MarkupElement): readonly ComponentLink[] {
const links: ComponentLink[] = []
for (const component of inspireChildren(address, "ad:component")) {
const href = component.attributes["xlink:href"]?.trim()
if (!href) continue
const title = component.attributes["xlink:title"]?.trim()
links.push(title ? { href, title } : { href })
}
return links
}
/**
* The query parameters a WFS stored-query reference carries its feature id in.
*
* Each publisher picked a spelling, and the match below ignores case, so Czechia's `Id`
* and Slovakia's `id` both resolve without naming each casing separately.
* A reader that knew only `id` returned Czechia's whole URL as the key, and no `gml:id`
* equals a URL, so all 5,460 of one municipality's references read as unjoinable.
*/
const REFERENCE_ID_PARAMETERS = new Set(["id", "featureid", "resourceid"])
/**
* The key a component reference joins on.
*
* Four shapes appear across the publishers measured, and they differ in
* where the key sits rather than in whether one exists:
*
* | publisher | href | key |
* | --- | --- | --- |
* | Netherlands, Wallonia, Brussels | `#nl-imbag-ad-thoroughfarename.0003300000117203` | the fragment |
* | Czechia | `…inspire-ad-wfs.asp?…&Id=TF.48674` | `TF.48674` |
* | Slovakia | `…ad/ows?…&id=AdminUnitName.15345` | `AdminUnitName.15345` |
* | Flanders | `https://data.vlaanderen.be/id/straatnaam/6301` | the URI itself |
*
* A URI with no id parameter is its own key, which is what Flanders publishes on both sides of the join.
* Its fragment is dropped, because a reference may carry one where the identifier does not:
* `http://vocab.belgif.be/auth/refnis1995/1000#id` addresses the same term as that URI without it.
*
* Whether a key joins is not a property of its spelling, so this reads a key
* and the caller decides joinability against the features the publisher actually served.
* Deciding it here from the URL's shape once reported 15 of Flanders' 20 references as
* belonging to another register when every one of them addressed a feature of the same service.
*
* @returns The key, or undefined when the href is neither a fragment nor a URL.
*/
export function componentJoinKey(href: string): string | undefined {
const trimmed = href.trim()
if (!trimmed) return undefined
if (trimmed.startsWith("#")) return trimmed.slice(1) || undefined
let url: URL
try {
url = new URL(trimmed)
} catch {
return undefined
}
for (const [parameter, value] of url.searchParams) {
if (!REFERENCE_ID_PARAMETERS.has(parameter.toLowerCase())) continue
const id = value.trim()
if (id) return id
}
url.hash = ""
return url.toString()
}
/**
* The spelling a `gn:GeographicalName` carries, reached from a feature that holds one.
*
* Every name-bearing INSPIRE feature nests the text at
* `…/gn:GeographicalName/gn:spelling/gn:SpellingOfName/gn:text`, under a per-feature
* prefix: an `ad:ThoroughfareName` adds `ad:name/ad:ThoroughfareNameValue/ad:name`,
* while an `ad:AdminUnitName` and an `ad:AddressAreaName` add only `ad:name`.
* The caller gives the prefix.
*
* @returns The text, or undefined when any step is absent or the name is void.
*/
export function geographicalNameText(feature: MarkupElement, ...prefix: readonly string[]): string | undefined {
const name = inspireElementAt(
feature,
...prefix,
"gn:GeographicalName",
"gn:spelling",
"gn:SpellingOfName",
"gn:text"
)
if (!name || isVoid(name)) return undefined
const text = name.text.trim()
return text || undefined
}
/**
* The street name an `ad:ThoroughfareName` feature states.
*/
export function thoroughfareName(feature: MarkupElement): string | undefined {
return geographicalNameText(feature, "ad:name", "ad:ThoroughfareNameValue", "ad:name")
}
/**
* The place name an `ad:AdminUnitName` or `ad:AddressAreaName` feature states.
*/
export function placeName(feature: MarkupElement): string | undefined {
return geographicalNameText(feature, "ad:name")
}
/**
* The postcode an `ad:PostalDescriptor` feature states.
*
* The Spanish cadastre writes this element as an integer, so `02250` arrives as `2250`.
* Left-padding belongs to the caller that knows the jurisdiction's width,
* because this reader returns what the publisher wrote.
*/
export function postalDescriptorCode(feature: MarkupElement): string | undefined {
const code = inspireChild(feature, "ad:postCode")
if (!code || isVoid(code)) return undefined
const text = code.text.trim()
return text || undefined
}
/**
* The administrative level an `ad:AdminUnitName` states, as a codelist value such as `4thOrder`.
*
* A publisher references several admin units per address: Czechia references the
* country at `1stOrder` and the municipality at `4thOrder`, and the Netherlands
* publishes exactly one `ad:AdminUnitName` feature, the country.
* A caller picks the finest level it wants by this value rather than by the order the references appear.
*/
export function adminUnitLevel(feature: MarkupElement): string | undefined {
const level = inspireChild(feature, "ad:level")
if (!level || isVoid(level)) return undefined
return codelistValue(level.attributes["xlink:href"]) ?? (level.text.trim() || undefined)
}