@mailwoman/corpus 10.6.0 → 10.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/data/PROVENANCE.md +24 -0
  2. package/data/address-source-register.json +18948 -8757
  3. package/data/corpus-source-inputs.json +4756 -0
  4. package/data/license-decisions.json +7260 -0
  5. package/data/licenses/whosonfirst-licenses.md +3 -2
  6. package/data/reviewed-ve-postcode-tuples.json +5 -72
  7. package/data/source-additions.json +2450 -0
  8. package/data/source-resolutions.json +87 -0
  9. package/data/training-manifests/v0.7.10-gb-county.effective.json +2335 -0
  10. package/data/training-manifests/v0.7.10-gb-county.json +1132 -0
  11. package/data/training-manifests/v0.7.2-address-systems.effective.json +3 -2
  12. package/data/training-manifests/v0.7.7-no-ppd.json +550 -0
  13. package/data/training-manifests/v0.7.8-rights-clean.json +538 -0
  14. package/data/training-manifests/v0.7.9-country-gap.json +1132 -0
  15. package/data/us-situs-sources.json +128 -0
  16. package/lib/adapters/cordis/adapter.ts +1 -1
  17. package/lib/adapters/libpostal-dictionary.ts +23 -3
  18. package/lib/adapters/osm/adapter.ts +42 -11
  19. package/lib/adapters.ts +14 -0
  20. package/lib/az/adapters/opendata/adapter.ts +389 -0
  21. package/lib/bs/adapters/gbpa/adapter.ts +248 -0
  22. package/lib/build/types.ts +16 -0
  23. package/lib/build.ts +21 -0
  24. package/lib/ee/adapters/ads/adapter.ts +18 -0
  25. package/lib/fo/adapters/adressur/adapter.ts +242 -0
  26. package/lib/gb/adapters/os-open/adapter.ts +12 -0
  27. package/lib/gb/county.ts +59 -0
  28. package/lib/gb/os-open/open/names.ts +41 -0
  29. package/lib/gu/adapters/bsp/adapter.ts +434 -0
  30. package/lib/im/adapters/land-registry/adapter.ts +340 -0
  31. package/lib/international/recipes/locale.ts +164 -19
  32. package/lib/li/adapters/llv/adapter.ts +246 -0
  33. package/lib/recipes/bare/postcode.ts +10 -34
  34. package/lib/recipes/country-balanced.ts +95 -78
  35. package/lib/recipes/intersection.ts +22 -30
  36. package/lib/recipes/po/box/cedex/recipe.ts +8 -5
  37. package/lib/recipes/po/box/cedex/sources.ts +2 -23
  38. package/lib/recipes/scaffold.ts +28 -3
  39. package/lib/recipes/sources.ts +112 -1
  40. package/lib/recipes/street/affix.ts +17 -29
  41. package/lib/recipes/sub/venue/context.ts +33 -9
  42. package/lib/recipes/sub/venue/sources.ts +54 -13
  43. package/lib/recipes/sub/venue.ts +68 -23
  44. package/lib/recipes/unit.ts +20 -7
  45. package/lib/registers.ts +33 -0
  46. package/lib/source-register/corpus-inputs.ts +528 -0
  47. package/lib/source-register/effective-manifest.ts +37 -5
  48. package/lib/source-register/types.ts +20 -0
  49. package/lib/south-asia/recipes/register.ts +1 -1
  50. package/lib/tools/overlay/manifest.ts +109 -1
  51. package/lib/tools/source-register/build.ts +47 -1
  52. package/lib/tools/variety-sample.ts +633 -0
  53. package/lib/us/adapters/usgov/nad/adapter.ts +39 -10
  54. package/lib/us/recipes/skeleton-sources.ts +156 -0
  55. package/lib/us/tools/nad-skeletons.ts +395 -0
  56. package/lib/uy/adapters/ide/adapter.ts +275 -0
  57. package/lib/ve/recipes/reviewed-postcode-tail.ts +29 -6
  58. package/out/adapters/cordis/adapter.d.ts +1 -1
  59. package/out/adapters/cordis/adapter.js +1 -1
  60. package/out/adapters/libpostal-dictionary.d.ts +9 -0
  61. package/out/adapters/libpostal-dictionary.d.ts.map +1 -1
  62. package/out/adapters/libpostal-dictionary.js +19 -3
  63. package/out/adapters/libpostal-dictionary.js.map +1 -1
  64. package/out/adapters/osm/adapter.d.ts +12 -1
  65. package/out/adapters/osm/adapter.d.ts.map +1 -1
  66. package/out/adapters/osm/adapter.js +30 -13
  67. package/out/adapters/osm/adapter.js.map +1 -1
  68. package/out/adapters.d.ts.map +1 -1
  69. package/out/adapters.js +14 -0
  70. package/out/adapters.js.map +1 -1
  71. package/out/az/adapters/opendata/adapter.d.ts +88 -0
  72. package/out/az/adapters/opendata/adapter.d.ts.map +1 -0
  73. package/out/az/adapters/opendata/adapter.js +282 -0
  74. package/out/az/adapters/opendata/adapter.js.map +1 -0
  75. package/out/bs/adapters/gbpa/adapter.d.ts +64 -0
  76. package/out/bs/adapters/gbpa/adapter.d.ts.map +1 -0
  77. package/out/bs/adapters/gbpa/adapter.js +187 -0
  78. package/out/bs/adapters/gbpa/adapter.js.map +1 -0
  79. package/out/build/types.d.ts +15 -0
  80. package/out/build/types.d.ts.map +1 -1
  81. package/out/build/types.js.map +1 -1
  82. package/out/build.d.ts.map +1 -1
  83. package/out/build.js +17 -0
  84. package/out/build.js.map +1 -1
  85. package/out/ee/adapters/ads/adapter.d.ts +6 -0
  86. package/out/ee/adapters/ads/adapter.d.ts.map +1 -1
  87. package/out/ee/adapters/ads/adapter.js +16 -0
  88. package/out/ee/adapters/ads/adapter.js.map +1 -1
  89. package/out/fo/adapters/adressur/adapter.d.ts +63 -0
  90. package/out/fo/adapters/adressur/adapter.d.ts.map +1 -0
  91. package/out/fo/adapters/adressur/adapter.js +177 -0
  92. package/out/fo/adapters/adressur/adapter.js.map +1 -0
  93. package/out/gb/adapters/os-open/adapter.d.ts +8 -0
  94. package/out/gb/adapters/os-open/adapter.d.ts.map +1 -1
  95. package/out/gb/adapters/os-open/adapter.js +11 -0
  96. package/out/gb/adapters/os-open/adapter.js.map +1 -1
  97. package/out/gb/county.d.ts +30 -0
  98. package/out/gb/county.d.ts.map +1 -0
  99. package/out/gb/county.js +51 -0
  100. package/out/gb/county.js.map +1 -0
  101. package/out/gb/os-open/open/names.d.ts +17 -0
  102. package/out/gb/os-open/open/names.d.ts.map +1 -1
  103. package/out/gb/os-open/open/names.js +19 -0
  104. package/out/gb/os-open/open/names.js.map +1 -1
  105. package/out/gu/adapters/bsp/adapter.d.ts +83 -0
  106. package/out/gu/adapters/bsp/adapter.d.ts.map +1 -0
  107. package/out/gu/adapters/bsp/adapter.js +346 -0
  108. package/out/gu/adapters/bsp/adapter.js.map +1 -0
  109. package/out/im/adapters/land-registry/adapter.d.ts +68 -0
  110. package/out/im/adapters/land-registry/adapter.d.ts.map +1 -0
  111. package/out/im/adapters/land-registry/adapter.js +261 -0
  112. package/out/im/adapters/land-registry/adapter.js.map +1 -0
  113. package/out/international/recipes/locale.d.ts +24 -4
  114. package/out/international/recipes/locale.d.ts.map +1 -1
  115. package/out/international/recipes/locale.js +107 -15
  116. package/out/international/recipes/locale.js.map +1 -1
  117. package/out/li/adapters/llv/adapter.d.ts +69 -0
  118. package/out/li/adapters/llv/adapter.d.ts.map +1 -0
  119. package/out/li/adapters/llv/adapter.js +181 -0
  120. package/out/li/adapters/llv/adapter.js.map +1 -0
  121. package/out/recipes/bare/postcode.d.ts.map +1 -1
  122. package/out/recipes/bare/postcode.js +9 -30
  123. package/out/recipes/bare/postcode.js.map +1 -1
  124. package/out/recipes/country-balanced.d.ts.map +1 -1
  125. package/out/recipes/country-balanced.js +82 -73
  126. package/out/recipes/country-balanced.js.map +1 -1
  127. package/out/recipes/intersection.d.ts +3 -2
  128. package/out/recipes/intersection.d.ts.map +1 -1
  129. package/out/recipes/intersection.js +20 -26
  130. package/out/recipes/intersection.js.map +1 -1
  131. package/out/recipes/po/box/cedex/recipe.d.ts.map +1 -1
  132. package/out/recipes/po/box/cedex/recipe.js +5 -4
  133. package/out/recipes/po/box/cedex/recipe.js.map +1 -1
  134. package/out/recipes/po/box/cedex/sources.d.ts +2 -45
  135. package/out/recipes/po/box/cedex/sources.d.ts.map +1 -1
  136. package/out/recipes/po/box/cedex/sources.js +0 -20
  137. package/out/recipes/po/box/cedex/sources.js.map +1 -1
  138. package/out/recipes/scaffold.d.ts +15 -2
  139. package/out/recipes/scaffold.d.ts.map +1 -1
  140. package/out/recipes/scaffold.js +21 -1
  141. package/out/recipes/scaffold.js.map +1 -1
  142. package/out/recipes/sources.d.ts.map +1 -1
  143. package/out/recipes/sources.js +112 -1
  144. package/out/recipes/sources.js.map +1 -1
  145. package/out/recipes/street/affix.d.ts.map +1 -1
  146. package/out/recipes/street/affix.js +8 -20
  147. package/out/recipes/street/affix.js.map +1 -1
  148. package/out/recipes/sub/venue/context.d.ts +14 -3
  149. package/out/recipes/sub/venue/context.d.ts.map +1 -1
  150. package/out/recipes/sub/venue/context.js +29 -9
  151. package/out/recipes/sub/venue/context.js.map +1 -1
  152. package/out/recipes/sub/venue/sources.d.ts +41 -8
  153. package/out/recipes/sub/venue/sources.d.ts.map +1 -1
  154. package/out/recipes/sub/venue/sources.js +26 -7
  155. package/out/recipes/sub/venue/sources.js.map +1 -1
  156. package/out/recipes/sub/venue.d.ts.map +1 -1
  157. package/out/recipes/sub/venue.js +35 -14
  158. package/out/recipes/sub/venue.js.map +1 -1
  159. package/out/recipes/unit.d.ts +4 -0
  160. package/out/recipes/unit.d.ts.map +1 -1
  161. package/out/recipes/unit.js +7 -6
  162. package/out/recipes/unit.js.map +1 -1
  163. package/out/registers.d.ts +33 -0
  164. package/out/registers.d.ts.map +1 -1
  165. package/out/registers.js +33 -0
  166. package/out/registers.js.map +1 -1
  167. package/out/source-register/corpus-inputs.d.ts +240 -0
  168. package/out/source-register/corpus-inputs.d.ts.map +1 -0
  169. package/out/source-register/corpus-inputs.js +275 -0
  170. package/out/source-register/corpus-inputs.js.map +1 -0
  171. package/out/source-register/effective-manifest.d.ts +2 -0
  172. package/out/source-register/effective-manifest.d.ts.map +1 -1
  173. package/out/source-register/effective-manifest.js +33 -4
  174. package/out/source-register/effective-manifest.js.map +1 -1
  175. package/out/source-register/types.d.ts +21 -1
  176. package/out/source-register/types.d.ts.map +1 -1
  177. package/out/source-register/types.js +20 -0
  178. package/out/source-register/types.js.map +1 -1
  179. package/out/south-asia/recipes/register.js +1 -1
  180. package/out/south-asia/recipes/register.js.map +1 -1
  181. package/out/tools/overlay/manifest.d.ts +7 -0
  182. package/out/tools/overlay/manifest.d.ts.map +1 -1
  183. package/out/tools/overlay/manifest.js +72 -1
  184. package/out/tools/overlay/manifest.js.map +1 -1
  185. package/out/tools/source-register/build.d.ts +9 -0
  186. package/out/tools/source-register/build.d.ts.map +1 -1
  187. package/out/tools/source-register/build.js +25 -3
  188. package/out/tools/source-register/build.js.map +1 -1
  189. package/out/tools/variety-sample.d.ts +196 -0
  190. package/out/tools/variety-sample.d.ts.map +1 -0
  191. package/out/tools/variety-sample.js +417 -0
  192. package/out/tools/variety-sample.js.map +1 -0
  193. package/out/us/adapters/usgov/nad/adapter.d.ts +80 -0
  194. package/out/us/adapters/usgov/nad/adapter.d.ts.map +1 -1
  195. package/out/us/adapters/usgov/nad/adapter.js +27 -8
  196. package/out/us/adapters/usgov/nad/adapter.js.map +1 -1
  197. package/out/us/recipes/skeleton-sources.d.ts +92 -0
  198. package/out/us/recipes/skeleton-sources.d.ts.map +1 -0
  199. package/out/us/recipes/skeleton-sources.js +121 -0
  200. package/out/us/recipes/skeleton-sources.js.map +1 -0
  201. package/out/us/tools/nad-skeletons.d.ts +120 -0
  202. package/out/us/tools/nad-skeletons.d.ts.map +1 -0
  203. package/out/us/tools/nad-skeletons.js +262 -0
  204. package/out/us/tools/nad-skeletons.js.map +1 -0
  205. package/out/uy/adapters/ide/adapter.d.ts +64 -0
  206. package/out/uy/adapters/ide/adapter.d.ts.map +1 -0
  207. package/out/uy/adapters/ide/adapter.js +206 -0
  208. package/out/uy/adapters/ide/adapter.js.map +1 -0
  209. package/out/ve/recipes/reviewed-postcode-tail.d.ts +6 -2
  210. package/out/ve/recipes/reviewed-postcode-tail.d.ts.map +1 -1
  211. package/out/ve/recipes/reviewed-postcode-tail.js +16 -5
  212. package/out/ve/recipes/reviewed-postcode-tail.js.map +1 -1
  213. package/package.json +14 -14
  214. package/lib/recipes/unit/sources.ts +0 -44
  215. package/lib/tools/fetch/ppd.ts +0 -121
  216. package/out/recipes/unit/sources.d.ts +0 -28
  217. package/out/recipes/unit/sources.d.ts.map +0 -1
  218. package/out/recipes/unit/sources.js +0 -30
  219. package/out/recipes/unit/sources.js.map +0 -1
  220. package/out/tools/fetch/ppd.d.ts +0 -15
  221. package/out/tools/fetch/ppd.d.ts.map +0 -1
  222. package/out/tools/fetch/ppd.js +0 -86
  223. package/out/tools/fetch/ppd.js.map +0 -1
@@ -0,0 +1,389 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `opendata-az`: `Azərbaycanda obyektlər və məkanlar` (objects and places in Azerbaijan), a GeoJSON of
7
+ * 11,807 points of interest in Baku, Sumgayit and Absheron that the Ministry of Ecology and Natural
8
+ * Resources publishes on opendata.az.
9
+ *
10
+ * Each point holds a place's name (`NAME`) and its address in English transliteration: `STRT_NAME` and
11
+ * `STRT_ADDR` for the street and number, `STATE` for the city district or settlement and `CITY` for the
12
+ * city. The adapter reads `NAME` as the venue, `STRT_NAME` as the street, `STRT_ADDR` as the house
13
+ * number, the last comma part of `STATE` (`Sabunchu,Zabrat` names the settlement Zabrat) as the dependent
14
+ * locality and `CITY` as the locality. Absheron is a district rather than a city, so an Absheron row
15
+ * takes its settlement (`Khirdalan`, `Masazir`) as the locality.
16
+ *
17
+ * `STRT_ADDR` writes `0` where the point has no number. Some street values carry the number after a
18
+ * comma (`Zarifa Aliyeva,38`) or a building's name in parentheses (`Azadlig Avenue(Hilton Baku)`): the
19
+ * number is read from the street when `STRT_ADDR` has none, and the parenthetical is removed. A block
20
+ * code (`558-ci0mehelle030`) is not a house number. A row with neither a number nor a venue is refused.
21
+ *
22
+ * `NAME` names a point of interest of the type `TYPE` states (`Cafe Gorush`, `School 93`, `Library by
23
+ * A. P. Chekhov`). No value in the 2025-08-05 file is a person's given name and surname alone: of the
24
+ * three that read `<Word> <surname-ending>`, each is a category word and a name (`Cafe Beyzade`,
25
+ * `Monument Rahimov`). A value with no run of three letters is a code and is dropped.
26
+ *
27
+ * Ninety-two points also carry their street in Azerbaijani spelling (`STRT_N_AZ`); each of those
28
+ * emits a second row in that spelling, with `NAME_AZ`, `STATE_AZ` and `CITY_AZ` where present
29
+ * (`Bakı` for `Baku`), when it renders differently from the first.
30
+ *
31
+ * The file holds no postcode. The CKAN record states `license_id: cc-by`, `Creative Commons Attribution`,
32
+ * with no version, so the rows carry a LicenseRef rather than an SPDX id that would assert a version.
33
+ */
34
+
35
+ import { formatAddressRow } from "@mailwoman/codex/address/format"
36
+ import { readLocalTextFile } from "@mailwoman/core/fs/readers"
37
+ import { parseJSONStrict } from "@mailwoman/core/json"
38
+ import { resolvePath } from "path-ts"
39
+
40
+ import { UnsupportedCountryError } from "#adapters/errors"
41
+ import { stableSourceIDFromParts } from "#adapters/source-id"
42
+ import { SourceRegister } from "#registers"
43
+ import {
44
+ AddressRole,
45
+ type AdapterOptions,
46
+ type CanonicalRow,
47
+ type CorpusAdapter,
48
+ countDropped,
49
+ SurfaceOrigin,
50
+ } from "#types"
51
+
52
+ /**
53
+ * Registry id for this adapter, stamped into every row it emits.
54
+ */
55
+ export const OPENDATA_AZ_ADAPTER_ID = "opendata-az"
56
+
57
+ /**
58
+ * The publisher's license, Creative Commons Attribution, whose version the record leaves unstated.
59
+ */
60
+ export const OPENDATA_AZ_LICENSE = "LicenseRef-CC-BY-unstated-version"
61
+
62
+ /**
63
+ * Every jurisdiction this adapter emits, checked against a caller's `--country`.
64
+ */
65
+ export const OPENDATA_AZ_COUNTRIES: readonly string[] = ["AZ"]
66
+
67
+ /**
68
+ * The feature properties the adapter reads.
69
+ */
70
+ export interface AzerbaijanPlaceRecord {
71
+ OBJECTID?: number | null
72
+ CITY?: string | null
73
+ STATE?: string | null
74
+ STRT_NAME?: string | null
75
+ STRT_ADDR?: string | null
76
+ NAME?: string | null
77
+ NAME_AZ?: string | null
78
+ STATE_AZ?: string | null
79
+ STRT_N_AZ?: string | null
80
+ CITY_AZ?: string | null
81
+ }
82
+
83
+ interface AzerbaijanFeatureCollection {
84
+ features?: Array<{
85
+ properties?: AzerbaijanPlaceRecord | null
86
+ geometry?: { coordinates?: readonly number[] } | null
87
+ }>
88
+ }
89
+
90
+ /**
91
+ * The spelling a row is rendered in.
92
+ */
93
+ export const AzerbaijanSpelling = {
94
+ /**
95
+ * `STRT_NAME`, `NAME`, `STATE` and `CITY`: English transliteration.
96
+ */
97
+ Transliterated: "transliterated",
98
+ /**
99
+ * `STRT_N_AZ`, `NAME_AZ`, `STATE_AZ` and `CITY_AZ`: Azerbaijani.
100
+ */
101
+ Azerbaijani: "azerbaijani",
102
+ } as const
103
+
104
+ export type AzerbaijanSpelling = (typeof AzerbaijanSpelling)[keyof typeof AzerbaijanSpelling]
105
+
106
+ /**
107
+ * A value with its whitespace collapsed, or null when the file leaves it blank (a single space).
108
+ */
109
+ function present(value: string | number | null | undefined): string | null {
110
+ const collapsed = value == null ? "" : String(value).replaceAll(/\s+/gu, " ").trim()
111
+
112
+ return collapsed || null
113
+ }
114
+
115
+ /**
116
+ * A house number: digits with an optional letter, and an optional second number
117
+ * after a slash, backslash or hyphen (`4/66`, `23-33`, `20\116`, `8/5A`).
118
+ */
119
+ const HOUSE_NUMBER = /^\d+\p{L}?(?:[/\\-]\d+\p{L}?)?$/u
120
+
121
+ /**
122
+ * A street value that carries the house number after a comma, as `Zarifa Aliyeva,38`.
123
+ */
124
+ const STREET_WITH_NUMBER = /^(.+?),\s*(\d+\p{L}?(?:\/\d+\p{L}?)?)$/u
125
+
126
+ /**
127
+ * A block (`məhəllə`) code in the number column, as `558-ci0mehelle030`.
128
+ */
129
+ const BLOCK_CODE = /mehelle/iu
130
+
131
+ /**
132
+ * The house number `STRT_ADDR` states, with a backslash read as the slash it stands for,
133
+ * or null when the value is absent, `0`, or not a house number.
134
+ */
135
+ function houseNumberFrom(value: string | null, tally: Pick<AdapterOptions, "dropped">): string | null {
136
+ if (!value || value === "0") return null
137
+
138
+ if (BLOCK_CODE.test(value)) {
139
+ countDropped(tally, "component:house_number:block-code")
140
+
141
+ return null
142
+ }
143
+
144
+ if (!HOUSE_NUMBER.test(value)) {
145
+ countDropped(tally, "component:house_number:not-a-number")
146
+
147
+ return null
148
+ }
149
+
150
+ return value.replaceAll("\\", "/")
151
+ }
152
+
153
+ /**
154
+ * The fields of one spelling: the street, venue, district and city columns it reads.
155
+ */
156
+ interface SpellingFields {
157
+ street: string | null
158
+ venue: string | null
159
+ state: string | null
160
+ city: string | null
161
+ }
162
+
163
+ function fieldsFor(record: AzerbaijanPlaceRecord, spelling: AzerbaijanSpelling): SpellingFields {
164
+ if (spelling === AzerbaijanSpelling.Azerbaijani) {
165
+ return {
166
+ street: present(record.STRT_N_AZ),
167
+ venue: present(record.NAME_AZ),
168
+ state: present(record.STATE_AZ) ?? present(record.STATE),
169
+ city: present(record.CITY_AZ) ?? present(record.CITY),
170
+ }
171
+ }
172
+
173
+ return {
174
+ street: present(record.STRT_NAME),
175
+ venue: present(record.NAME),
176
+ state: present(record.STATE),
177
+ city: present(record.CITY),
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Whether a city value is the Absheron district, in either spelling.
183
+ */
184
+ function isAbsheron(city: string): boolean {
185
+ return /^Ab(?:sh|ş)eron$/iu.test(city)
186
+ }
187
+
188
+ /**
189
+ * Maps one record onto the components the corpus asserts in one spelling,
190
+ * or returns null when the row is refused.
191
+ *
192
+ * Each refusal and each dropped component increments its reason in `dropped`.
193
+ */
194
+ export function componentsForAzerbaijanRecord(
195
+ record: AzerbaijanPlaceRecord,
196
+ dropped?: Pick<AdapterOptions, "dropped">,
197
+ spelling: AzerbaijanSpelling = AzerbaijanSpelling.Transliterated
198
+ ): CanonicalRow["components"] | null {
199
+ const tally = dropped ?? {}
200
+ const fields = fieldsFor(record, spelling)
201
+ let street = fields.street
202
+
203
+ if (!street) {
204
+ countDropped(tally, "row:no-street")
205
+
206
+ return null
207
+ }
208
+
209
+ if (/[{@]/u.test(street)) {
210
+ countDropped(tally, "row:street-markup")
211
+
212
+ return null
213
+ }
214
+
215
+ let houseNumber = houseNumberFrom(present(record.STRT_ADDR), tally)
216
+ const carried = STREET_WITH_NUMBER.exec(street)
217
+
218
+ if (carried) {
219
+ street = carried[1]!.trim()
220
+ houseNumber ??= carried[2]!
221
+ }
222
+
223
+ if (/\(/u.test(street)) {
224
+ street = street.replaceAll(/\s*\([^)]*\)\s*/gu, " ").trim()
225
+ countDropped(tally, "component:street:parenthetical-removed")
226
+ }
227
+
228
+ if (!street || street.includes(",")) {
229
+ countDropped(tally, "row:street-holds-several-parts")
230
+
231
+ return null
232
+ }
233
+
234
+ const components: CanonicalRow["components"] = { street }
235
+
236
+ if (houseNumber) {
237
+ components.house_number = houseNumber
238
+ }
239
+
240
+ const venue = fields.venue
241
+
242
+ // A value with no run of three letters (`555`, `SM`, `T T`) is a code rather than a name.
243
+ if (venue && !/\p{L}{3}/u.test(venue)) {
244
+ countDropped(tally, "component:venue:code")
245
+ } else if (venue) {
246
+ components.venue = venue
247
+ }
248
+
249
+ if (!components.house_number && !components.venue) {
250
+ countDropped(tally, "row:no-house-number-or-venue")
251
+
252
+ return null
253
+ }
254
+
255
+ const city = fields.city
256
+ const district = fields.state?.split(",").pop()?.trim() || null
257
+
258
+ if (!city) {
259
+ countDropped(tally, "row:no-city")
260
+
261
+ return null
262
+ }
263
+
264
+ if (isAbsheron(city)) {
265
+ components.locality = district ?? city
266
+ } else {
267
+ components.locality = city
268
+
269
+ if (district && district.toLowerCase() !== city.toLowerCase() && !/^Sum[gq]ayit$/iu.test(district)) {
270
+ components.dependent_locality = district
271
+ }
272
+ }
273
+
274
+ return components
275
+ }
276
+
277
+ export function createAzerbaijanPlacesAdapter(): CorpusAdapter {
278
+ return {
279
+ id: OPENDATA_AZ_ADAPTER_ID,
280
+ defaultLicense: OPENDATA_AZ_LICENSE,
281
+ addressRole: AddressRole.Premise,
282
+ register: SourceRegister.AzerbaijanObjectsAndPlaces,
283
+ surface: SurfaceOrigin.Rendered,
284
+ description:
285
+ "Azərbaycanda obyektlər və məkanlar (Ministry of Ecology and Natural Resources, opendata.az): addressed places in Baku, Sumgayit and Absheron.",
286
+
287
+ async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
288
+ if (opts.country && !OPENDATA_AZ_COUNTRIES.includes(opts.country)) {
289
+ throw new UnsupportedCountryError(OPENDATA_AZ_ADAPTER_ID, OPENDATA_AZ_COUNTRIES, opts.country)
290
+ }
291
+
292
+ // The publication is one 5 MB file, so it is parsed whole.
293
+ const path = resolvePath(opts.inputPath)
294
+ const collection = parseJSONStrict<AzerbaijanFeatureCollection>(await readLocalTextFile(path))
295
+
296
+ if (!Array.isArray(collection.features)) {
297
+ throw new TypeError(`${OPENDATA_AZ_ADAPTER_ID} adapter: ${path} holds no GeoJSON features array`)
298
+ }
299
+
300
+ let emitted = 0
301
+
302
+ for (const feature of collection.features) {
303
+ const record = feature.properties
304
+
305
+ if (!record) {
306
+ countDropped(opts, "row:no-properties")
307
+
308
+ continue
309
+ }
310
+
311
+ const [lon, lat] = feature.geometry?.coordinates ?? []
312
+ let transliterated: string | null = null
313
+
314
+ const spellings = present(record.STRT_N_AZ)
315
+ ? [AzerbaijanSpelling.Transliterated, AzerbaijanSpelling.Azerbaijani]
316
+ : [AzerbaijanSpelling.Transliterated]
317
+
318
+ for (const spelling of spellings) {
319
+ if (opts.signal?.aborted) return
320
+
321
+ if (opts.limit !== undefined && emitted >= opts.limit) return
322
+
323
+ // The Azerbaijani row is counted under its own prefix, so its refusals
324
+ // do not read as refusals of records.
325
+ const tally = spelling === AzerbaijanSpelling.Azerbaijani ? { dropped: new Map<string, number>() } : opts
326
+ const components = componentsForAzerbaijanRecord(record, tally, spelling)
327
+
328
+ if (spelling === AzerbaijanSpelling.Azerbaijani) {
329
+ for (const [reason, count] of tally.dropped ?? []) {
330
+ const key = `azerbaijani-spelling:${reason}`
331
+
332
+ opts.dropped?.set(key, (opts.dropped.get(key) ?? 0) + count)
333
+ }
334
+ }
335
+
336
+ if (!components) continue
337
+
338
+ const rendered = formatAddressRow(components, "AZ", { singleLine: true })
339
+
340
+ if (!rendered) {
341
+ countDropped(opts, "row:render-failed")
342
+
343
+ continue
344
+ }
345
+
346
+ for (const tag of rendered.unplaced) {
347
+ countDropped(opts, `component:${tag}:not-rendered`)
348
+ }
349
+
350
+ const { raw, components: aligned } = rendered
351
+
352
+ if (spelling === AzerbaijanSpelling.Transliterated) {
353
+ transliterated = raw
354
+ } else if (raw === transliterated) {
355
+ countDropped(opts, "azerbaijani-spelling:row:renders-as-transliterated")
356
+
357
+ continue
358
+ }
359
+
360
+ yield {
361
+ raw,
362
+ components: aligned,
363
+ country: "AZ",
364
+ locale: spelling === AzerbaijanSpelling.Azerbaijani ? "az-AZ" : "en-AZ",
365
+ source: OPENDATA_AZ_ADAPTER_ID,
366
+ // `OBJECTID` is unique per feature.
367
+ // The spelling separates a feature's two rows.
368
+ source_id: stableSourceIDFromParts(OPENDATA_AZ_ADAPTER_ID, {
369
+ ...aligned,
370
+ objectid: present(record.OBJECTID) ?? "",
371
+ spelling,
372
+ lat: typeof lat === "number" ? String(lat) : "",
373
+ lon: typeof lon === "number" ? String(lon) : "",
374
+ }),
375
+ corpus_version: "",
376
+ license: OPENDATA_AZ_LICENSE,
377
+ }
378
+
379
+ emitted++
380
+ }
381
+ }
382
+ },
383
+ }
384
+ }
385
+
386
+ /**
387
+ * The configured adapter instance registered with the corpus builder.
388
+ */
389
+ export const azerbaijanPlacesAdapter = createAzerbaijanPlacesAdapter()
@@ -0,0 +1,248 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `gbpa-bs`: the address points of the Grand Bahama Port Authority (GBPA), covering the City of Freeport
7
+ * and Lucaya on Grand Bahama, which the GBPA publishes in its `GBPA/gis-datafiles` repository on GitHub.
8
+ * The adapter reads the CSV export, `CSV/addresses.csv`.
9
+ *
10
+ * A Freeport address number is a block and a parcel joined by a hyphen (`8-29 Seagull Drive`), and either
11
+ * part may be lettered (`UU-3`, `13-43C`, `W-A-10`). The adapter reads `AddressNum` as the house number,
12
+ * `FullStName` as the street, `City` as the locality, `Island` as the region and `AltName` as a venue
13
+ * (`Lucayan International School`, `Shoreline`). The export truncates some parcel ids to five characters and
14
+ * a star (`Minis*`), and those numbers are dropped.
15
+ *
16
+ * The export repeats the street's name in a few records (`Rippindale Rippindale`, and across an embedded
17
+ * line break `Santa Maria \r\nSanta Maria Avenue`). The adapter collapses the repeat and counts it.
18
+ *
19
+ * The GBPA licenses the data under CC BY 4.0 and asks for the statement "Data Provided by The Grand Bahama
20
+ * Port Authority". Every row records that license.
21
+ */
22
+
23
+ import { formatAddressRow } from "@mailwoman/codex/address/format"
24
+ import { readLocalBuffer } from "@mailwoman/core/fs/readers"
25
+ import { resolvePath } from "path-ts"
26
+ import { CSVSpliterator } from "spliterator"
27
+
28
+ import { UnsupportedCountryError } from "#adapters/errors"
29
+ import { stableSourceIDFromParts } from "#adapters/source-id"
30
+ import { SourceRegister } from "#registers"
31
+ import {
32
+ AddressRole,
33
+ type AdapterOptions,
34
+ type CanonicalRow,
35
+ type CorpusAdapter,
36
+ countDropped,
37
+ SurfaceOrigin,
38
+ } from "#types"
39
+
40
+ /**
41
+ * Registry id for this adapter, stamped into every row it emits.
42
+ */
43
+ export const GBPA_BS_ADAPTER_ID = "gbpa-bs"
44
+
45
+ /**
46
+ * The license the GBPA publishes the repository's data under.
47
+ */
48
+ export const GBPA_BS_LICENSE = "CC-BY-4.0"
49
+
50
+ /**
51
+ * The attribution statement the GBPA asks for.
52
+ */
53
+ export const GBPA_BS_ATTRIBUTION = "Data Provided by The Grand Bahama Port Authority"
54
+
55
+ /**
56
+ * Every jurisdiction this adapter emits, checked against a caller's `--country`.
57
+ */
58
+ export const GBPA_BS_COUNTRIES: readonly string[] = ["BS"]
59
+
60
+ /**
61
+ * The columns the adapter reads from `CSV/addresses.csv`.
62
+ */
63
+ export interface GBPAAddressRecord {
64
+ Y: string
65
+ X: string
66
+ AddID: string
67
+ AltName: string
68
+ Suffix: string
69
+ FullStName: string
70
+ AddressNum: string
71
+ City: string
72
+ Island: string
73
+ }
74
+
75
+ /**
76
+ * A value with its whitespace, including the export's embedded line breaks, collapsed, or null when empty.
77
+ */
78
+ function present(value: string | undefined): string | null {
79
+ const trimmed = value?.replaceAll(/\s+/gu, " ").trim() ?? ""
80
+
81
+ return trimmed || null
82
+ }
83
+
84
+ /**
85
+ * A block and parcel number: alphanumeric parts joined by hyphens (`8-29`, `UU-3`, `W-A-10`, `G-3B10`, `L`).
86
+ */
87
+ const ADDRESS_NUMBER = /^[A-Z\d]+(?:-[A-Z\d]+)*$/iu
88
+
89
+ /**
90
+ * A street's name repeated before its type (`Santa Maria Santa Maria Avenue`)
91
+ * or after it (`Wragby Wragby`).
92
+ */
93
+ const REPEATED_NAME = /^(.+?) \1(?= |$)/u
94
+
95
+ /**
96
+ * A street value that is only a type word, with no name before it.
97
+ */
98
+ const TYPE_WORD_ONLY = /^(?:road|lane|drive|avenue|street|court|close|way|place|circle|terrace|crescent)$/iu
99
+
100
+ /**
101
+ * Initials and a surname alone (`J.S. Johnson`) name a person rather than a place or a business.
102
+ */
103
+ const INITIALS_AND_SURNAME = /^(?:\p{Lu}\.\s*)+\p{Lu}\p{Ll}+$/u
104
+
105
+ /**
106
+ * Maps one record onto the components the corpus asserts, or returns null when the row is refused.
107
+ *
108
+ * Each refusal and each dropped or repaired component increments its reason in `dropped`.
109
+ */
110
+ export function componentsForGBPARecord(
111
+ record: GBPAAddressRecord,
112
+ dropped?: Pick<AdapterOptions, "dropped">
113
+ ): CanonicalRow["components"] | null {
114
+ const tally = dropped ?? {}
115
+ let street = present(record.FullStName)
116
+
117
+ if (!street) {
118
+ countDropped(tally, "row:no-street")
119
+
120
+ return null
121
+ }
122
+
123
+ if (REPEATED_NAME.test(street)) {
124
+ street = street.replace(REPEATED_NAME, "$1")
125
+ countDropped(tally, "component:street:repeated-name-collapsed")
126
+ }
127
+
128
+ if (TYPE_WORD_ONLY.test(street)) {
129
+ countDropped(tally, "row:street-is-a-type-word")
130
+
131
+ return null
132
+ }
133
+
134
+ const components: CanonicalRow["components"] = { street }
135
+ const number = present(record.AddressNum)
136
+
137
+ if (number && ADDRESS_NUMBER.test(number)) {
138
+ components.house_number = number
139
+ } else if (number?.includes("*")) {
140
+ countDropped(tally, "component:house_number:truncated")
141
+ } else if (number) {
142
+ countDropped(tally, "component:house_number:not-a-block-parcel-number")
143
+ }
144
+
145
+ const venue = present(record.AltName)
146
+
147
+ if (venue && !/\p{L}{3}/u.test(venue)) {
148
+ countDropped(tally, "component:venue:code")
149
+ } else if (venue && /(\p{L})\1{3,}$/u.test(venue)) {
150
+ // `Bellevue Gifts & Suppliesccccc` carries a run of repeated keystrokes.
151
+ countDropped(tally, "component:venue:malformed")
152
+ } else if (venue && INITIALS_AND_SURNAME.test(venue)) {
153
+ countDropped(tally, "component:venue:names-a-person")
154
+ } else if (venue) {
155
+ components.venue = venue
156
+ }
157
+
158
+ if (!components.house_number && !components.venue) {
159
+ countDropped(tally, "row:no-house-number-or-name")
160
+
161
+ return null
162
+ }
163
+
164
+ const locality = present(record.City)
165
+ const region = present(record.Island)
166
+
167
+ if (locality) {
168
+ components.locality = locality
169
+ }
170
+
171
+ if (region) {
172
+ components.region = region
173
+ }
174
+
175
+ if (!components.locality) {
176
+ countDropped(tally, "row:no-locality")
177
+
178
+ return null
179
+ }
180
+
181
+ return components
182
+ }
183
+
184
+ export function createGBPAAdapter(): CorpusAdapter {
185
+ return {
186
+ id: GBPA_BS_ADAPTER_ID,
187
+ defaultLicense: GBPA_BS_LICENSE,
188
+ addressRole: AddressRole.Premise,
189
+ register: SourceRegister.GrandBahamaPortAuthorityAddresses,
190
+ surface: SurfaceOrigin.Rendered,
191
+ description: "Grand Bahama Port Authority address points (Freeport and Lucaya), GBPA/gis-datafiles CSV export.",
192
+
193
+ async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
194
+ if (opts.country && !GBPA_BS_COUNTRIES.includes(opts.country)) {
195
+ throw new UnsupportedCountryError(GBPA_BS_ADAPTER_ID, GBPA_BS_COUNTRIES, opts.country)
196
+ }
197
+
198
+ // The file is 8 MB, so it is decoded whole.
199
+ // The BOM would otherwise prefix the first header.
200
+ const text = (await readLocalBuffer(resolvePath(opts.inputPath))).toString("utf8").replace(/^/u, "")
201
+ const records = CSVSpliterator.from<GBPAAddressRecord>(Buffer.from(text, "utf8"), { normalizeKeys: false })
202
+ let emitted = 0
203
+
204
+ for (const record of records) {
205
+ if (opts.signal?.aborted) return
206
+
207
+ if (opts.limit !== undefined && emitted >= opts.limit) return
208
+
209
+ const components = componentsForGBPARecord(record, opts)
210
+
211
+ if (!components) continue
212
+
213
+ const rendered = formatAddressRow(components, "BS", { singleLine: true })
214
+
215
+ if (!rendered) {
216
+ countDropped(opts, "row:render-failed")
217
+
218
+ continue
219
+ }
220
+
221
+ for (const tag of rendered.unplaced) {
222
+ countDropped(opts, `component:${tag}:not-rendered`)
223
+ }
224
+
225
+ const { raw, components: aligned } = rendered
226
+
227
+ yield {
228
+ raw,
229
+ components: aligned,
230
+ country: "BS",
231
+ locale: "en-BS",
232
+ source: GBPA_BS_ADAPTER_ID,
233
+ // The GBPA's own address id separates two points the rendered components cannot.
234
+ source_id: stableSourceIDFromParts(GBPA_BS_ADAPTER_ID, { ...aligned, add_id: record.AddID ?? "" }),
235
+ corpus_version: "",
236
+ license: GBPA_BS_LICENSE,
237
+ }
238
+
239
+ emitted++
240
+ }
241
+ },
242
+ }
243
+ }
244
+
245
+ /**
246
+ * The configured adapter instance registered with the corpus builder.
247
+ */
248
+ export const gbpaAdapter = createGBPAAdapter()
@@ -13,6 +13,7 @@ import type { PathBuilderLike } from "path-ts"
13
13
 
14
14
  import type { ParquetManifest } from "#parquet/writers"
15
15
  import type { AdapterRunManifest } from "#runner"
16
+ import type { SourceRightsRecord } from "#source-register/corpus-inputs"
16
17
  import type { AdapterOptions, CorpusAdapter } from "#types"
17
18
  import type { LicensePolicy, LicenseRefusalKind } from "#utils/license"
18
19
  import type { SplitManifest } from "#utils/split"
@@ -142,6 +143,16 @@ export interface BuildCorpusOptions {
142
143
  * @defaultValue {@linkcode BuildProfile.Exploratory}
143
144
  */
144
145
  profile?: BuildProfile
146
+
147
+ /**
148
+ * Why a local experiment admits sources whose register rows lack an elected
149
+ * decision permitting `transform` and `train`.
150
+ *
151
+ * Without it the build refuses such a source before any adapter runs.
152
+ * With it the build logs every gap and records the corpus as not shippable
153
+ * in {@linkcode BuildCorpusManifest.source_rights}.
154
+ */
155
+ unshippableReason?: string
145
156
  }
146
157
 
147
158
  /**
@@ -231,4 +242,9 @@ export interface BuildCorpusManifest {
231
242
  * That file is the frozen record of which sources contributed and under which terms.
232
243
  */
233
244
  training_manifest_digest: string
245
+ /**
246
+ * Whether every source this build emitted reads only register sources whose decisions permit
247
+ * `transform` and `train`, and the gaps an `unshippableReason` admitted when they do not.
248
+ */
249
+ source_rights: SourceRightsRecord
234
250
  }