@mailwoman/resolver-wof-sqlite 7.2.0 → 7.3.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.
- package/address-point-interpolation.ts +207 -0
- package/address-point-schema.ts +107 -0
- package/address-point.ts +122 -0
- package/ancestry-backfill.ts +205 -0
- package/ancestry.ts +70 -0
- package/build-candidate.ts +351 -0
- package/build-slim.ts +394 -0
- package/candidate-fts.ts +43 -0
- package/candidate-lookup.ts +382 -0
- package/candidate-schema.ts +166 -0
- package/coincident-roles.ts +240 -0
- package/convention.ts +152 -0
- package/fst-autocomplete.ts +187 -0
- package/fst-builder.ts +291 -0
- package/fst-deserialize-web.ts +164 -0
- package/fst-matcher.ts +150 -0
- package/fst-serialize.ts +311 -0
- package/fst-types.ts +78 -0
- package/fts.ts +318 -0
- package/geo.ts +140 -0
- package/geonames-aliases.ts +317 -0
- package/geonames-postal.ts +150 -0
- package/index.ts +117 -0
- package/interpolation.ts +232 -0
- package/lookup.ts +1498 -0
- package/out/poi-lookup.d.ts +14 -2
- package/out/poi-lookup.d.ts.map +1 -1
- package/out/poi-lookup.js +55 -21
- package/out/poi-lookup.js.map +1 -1
- package/out/poi-schema.d.ts +9 -0
- package/out/poi-schema.d.ts.map +1 -1
- package/out/poi-schema.js +16 -0
- package/out/poi-schema.js.map +1 -1
- package/out/reverse.d.ts +8 -1
- package/out/reverse.d.ts.map +1 -1
- package/out/reverse.js +10 -1
- package/out/reverse.js.map +1 -1
- package/package.json +168 -82
- package/poi-lookup.ts +375 -0
- package/poi-schema.ts +164 -0
- package/postal-city-alias-lookup.ts +89 -0
- package/postal-city-alias-schema.ts +75 -0
- package/postal-city-candidate-schema.ts +81 -0
- package/postcode-point-lookup.ts +64 -0
- package/reverse.ts +439 -0
- package/schema.ts +176 -0
- package/sharding.ts +235 -0
- package/sqlite-convention-source.ts +61 -0
- package/sqlite-utils.ts +25 -0
- package/street-centroid-schema.ts +124 -0
- package/street-centroid.ts +124 -0
- package/street-morphology-fst-builder.ts +230 -0
- package/street-name-lookup.ts +101 -0
- package/street-normalize.ts +302 -0
- package/street-segment-schema.ts +104 -0
- package/types.ts +164 -0
- package/unified-schema.ts +171 -0
package/fst-serialize.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Binary serialization for the FST gazetteer. Format:
|
|
7
|
+
*
|
|
8
|
+
* HEADER (32 bytes) magic [u8; 4] "FST\0" version u16 1 flags u16 0 (reserved) stateCount u32
|
|
9
|
+
* edgeCount u32 total edges across all states placeCount u32 total place entries across all
|
|
10
|
+
* states stringCount u32 unique strings in string table stringBytes u32 total bytes of string
|
|
11
|
+
* data _reserved u32
|
|
12
|
+
*
|
|
13
|
+
* STRING TABLE offsets [u32; stringCount + 1] byte offset into data (last = sentinel) data [u8;
|
|
14
|
+
* stringBytes] concatenated UTF-8
|
|
15
|
+
*
|
|
16
|
+
* STATE TABLE [stateCount × 12 bytes] edgeStart u32 index into edge table placeStart u32 index into
|
|
17
|
+
* place table edgeCount u16 placeCount u16
|
|
18
|
+
*
|
|
19
|
+
* EDGE TABLE [edgeCount × 8 bytes] stringIdx u32 index into string table targetState u32
|
|
20
|
+
*
|
|
21
|
+
* PLACE TABLE [placeCount × 56 bytes] wofID u32 placetypeIdx u8 index into PLACETYPE_ORDER chainLen
|
|
22
|
+
* u8 0..8 _pad u16 nameIdx u32 index into string table importance f32 Wikipedia importance [0,1]
|
|
23
|
+
* (V2); was population u32 (V1) lat f32 lon f32 chain [u32; 8] parent chain (unused slots = 0)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { FSTNode } from "./fst-matcher.ts"
|
|
27
|
+
import { FSTMatcher } from "./fst-matcher.ts"
|
|
28
|
+
import type { FSTProvenance, PlaceEntry, PlacetypeID } from "./fst-types.ts"
|
|
29
|
+
|
|
30
|
+
const MAGIC = Buffer.from("FST\0", "ascii")
|
|
31
|
+
const VERSION = 4
|
|
32
|
+
const HEADER_SIZE = 32
|
|
33
|
+
const STATE_ENTRY_SIZE = 16
|
|
34
|
+
const EDGE_ENTRY_SIZE = 8
|
|
35
|
+
const PLACE_ENTRY_SIZE = 56
|
|
36
|
+
const MAX_CHAIN_LEN = 8
|
|
37
|
+
|
|
38
|
+
const PLACETYPE_ORDER: readonly PlacetypeID[] = [
|
|
39
|
+
"country",
|
|
40
|
+
"region",
|
|
41
|
+
"county",
|
|
42
|
+
"locality",
|
|
43
|
+
"localadmin",
|
|
44
|
+
"borough",
|
|
45
|
+
"neighbourhood",
|
|
46
|
+
"postalcode",
|
|
47
|
+
"campus",
|
|
48
|
+
"dependency",
|
|
49
|
+
"street_affix",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
const placetypeToIdx = new Map<string, number>()
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < PLACETYPE_ORDER.length; i++) {
|
|
55
|
+
placetypeToIdx.set(PLACETYPE_ORDER[i]!, i)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function serializeFST(matcher: FSTMatcher, provenance?: FSTProvenance): Buffer {
|
|
59
|
+
const nodes = matcher.toNodes() as FSTNode[]
|
|
60
|
+
|
|
61
|
+
// --- String interning ---
|
|
62
|
+
const stringMap = new Map<string, number>()
|
|
63
|
+
const strings: string[] = []
|
|
64
|
+
|
|
65
|
+
function intern(s: string): number {
|
|
66
|
+
let idx = stringMap.get(s)
|
|
67
|
+
|
|
68
|
+
if (idx === undefined) {
|
|
69
|
+
idx = strings.length
|
|
70
|
+
strings.push(s)
|
|
71
|
+
stringMap.set(s, idx)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return idx
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const node of nodes) {
|
|
78
|
+
for (const token of node.edges.keys()) {
|
|
79
|
+
intern(token)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const place of node.places) {
|
|
83
|
+
intern(place.name)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const encodedStrings = strings.map((s) => Buffer.from(s, "utf8"))
|
|
88
|
+
const stringBytes = encodedStrings.reduce((sum, b) => sum + b.length, 0)
|
|
89
|
+
|
|
90
|
+
// --- Counts ---
|
|
91
|
+
let totalEdges = 0
|
|
92
|
+
let totalPlaces = 0
|
|
93
|
+
|
|
94
|
+
for (const node of nodes) {
|
|
95
|
+
totalEdges += node.edges.size
|
|
96
|
+
totalPlaces += node.places.length
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- Allocate ---
|
|
100
|
+
const stringTableSize = (strings.length + 1) * 4 + stringBytes
|
|
101
|
+
const stateTableSize = nodes.length * STATE_ENTRY_SIZE
|
|
102
|
+
const edgeTableSize = totalEdges * EDGE_ENTRY_SIZE
|
|
103
|
+
const placeTableSize = totalPlaces * PLACE_ENTRY_SIZE
|
|
104
|
+
const provenanceJson = provenance ? Buffer.from(JSON.stringify(provenance), "utf8") : null
|
|
105
|
+
const provenanceSize = provenanceJson ? 4 + provenanceJson.length : 0
|
|
106
|
+
const binarySize = HEADER_SIZE + stringTableSize + stateTableSize + edgeTableSize + placeTableSize
|
|
107
|
+
const totalSize = binarySize + provenanceSize
|
|
108
|
+
const buf = Buffer.alloc(totalSize)
|
|
109
|
+
let pos = 0
|
|
110
|
+
|
|
111
|
+
// --- Header ---
|
|
112
|
+
MAGIC.copy(buf, pos)
|
|
113
|
+
pos += 4
|
|
114
|
+
buf.writeUInt16LE(VERSION, pos)
|
|
115
|
+
pos += 2
|
|
116
|
+
buf.writeUInt16LE(0, pos)
|
|
117
|
+
pos += 2
|
|
118
|
+
buf.writeUInt32LE(nodes.length, pos)
|
|
119
|
+
pos += 4
|
|
120
|
+
buf.writeUInt32LE(totalEdges, pos)
|
|
121
|
+
pos += 4
|
|
122
|
+
buf.writeUInt32LE(totalPlaces, pos)
|
|
123
|
+
pos += 4
|
|
124
|
+
buf.writeUInt32LE(strings.length, pos)
|
|
125
|
+
pos += 4
|
|
126
|
+
buf.writeUInt32LE(stringBytes, pos)
|
|
127
|
+
pos += 4
|
|
128
|
+
buf.writeUInt32LE(provenanceJson ? binarySize : 0, pos)
|
|
129
|
+
pos += 4
|
|
130
|
+
|
|
131
|
+
// --- String table ---
|
|
132
|
+
let strOffset = 0
|
|
133
|
+
|
|
134
|
+
for (let i = 0; i < encodedStrings.length; i++) {
|
|
135
|
+
buf.writeUInt32LE(strOffset, pos)
|
|
136
|
+
pos += 4
|
|
137
|
+
strOffset += encodedStrings[i]!.length
|
|
138
|
+
}
|
|
139
|
+
buf.writeUInt32LE(strOffset, pos)
|
|
140
|
+
pos += 4
|
|
141
|
+
|
|
142
|
+
// sentinel
|
|
143
|
+
|
|
144
|
+
for (const encoded of encodedStrings) {
|
|
145
|
+
encoded.copy(buf, pos)
|
|
146
|
+
pos += encoded.length
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- State, edge, and place tables ---
|
|
150
|
+
const stateTableStart = pos
|
|
151
|
+
const edgeTableStart = stateTableStart + stateTableSize
|
|
152
|
+
const placeTableStart = edgeTableStart + edgeTableSize
|
|
153
|
+
|
|
154
|
+
let edgeIdx = 0
|
|
155
|
+
let placeIdx = 0
|
|
156
|
+
|
|
157
|
+
for (let si = 0; si < nodes.length; si++) {
|
|
158
|
+
const node = nodes[si]!
|
|
159
|
+
const sp = stateTableStart + si * STATE_ENTRY_SIZE
|
|
160
|
+
|
|
161
|
+
buf.writeUInt32LE(edgeIdx, sp)
|
|
162
|
+
buf.writeUInt32LE(placeIdx, sp + 4)
|
|
163
|
+
buf.writeUInt32LE(node.edges.size, sp + 8)
|
|
164
|
+
buf.writeUInt32LE(node.places.length, sp + 12)
|
|
165
|
+
|
|
166
|
+
for (const [token, target] of node.edges) {
|
|
167
|
+
const ep = edgeTableStart + edgeIdx * EDGE_ENTRY_SIZE
|
|
168
|
+
buf.writeUInt32LE(intern(token), ep)
|
|
169
|
+
buf.writeUInt32LE(target, ep + 4)
|
|
170
|
+
edgeIdx++
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const place of node.places) {
|
|
174
|
+
const pp = placeTableStart + placeIdx * PLACE_ENTRY_SIZE
|
|
175
|
+
// Filter out WOF sentinel parent IDs (negative values like -1, -4).
|
|
176
|
+
const validChain = place.parentChain.filter((id) => id > 0)
|
|
177
|
+
const chainLen = Math.min(validChain.length, MAX_CHAIN_LEN)
|
|
178
|
+
buf.writeUInt32LE(place.wofID, pp)
|
|
179
|
+
buf.writeUInt8(placetypeToIdx.get(place.placetype) ?? 0, pp + 4)
|
|
180
|
+
buf.writeUInt8(chainLen, pp + 5)
|
|
181
|
+
buf.writeUInt16LE(0, pp + 6) // pad
|
|
182
|
+
buf.writeUInt32LE(intern(place.name), pp + 8)
|
|
183
|
+
buf.writeFloatLE(place.importance, pp + 12)
|
|
184
|
+
buf.writeFloatLE(place.lat, pp + 16)
|
|
185
|
+
buf.writeFloatLE(place.lon, pp + 20)
|
|
186
|
+
|
|
187
|
+
for (let ci = 0; ci < MAX_CHAIN_LEN; ci++) {
|
|
188
|
+
buf.writeUInt32LE(ci < chainLen ? validChain[ci]! : 0, pp + 24 + ci * 4)
|
|
189
|
+
}
|
|
190
|
+
placeIdx++
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (provenanceJson) {
|
|
195
|
+
const trailerStart = binarySize
|
|
196
|
+
buf.writeUInt32LE(provenanceJson.length, trailerStart)
|
|
197
|
+
provenanceJson.copy(buf, trailerStart + 4)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return buf
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function deserializeFST(buf: Buffer): FSTMatcher {
|
|
204
|
+
// --- Header ---
|
|
205
|
+
if (buf.length < HEADER_SIZE) throw new Error("FST buffer too small for header")
|
|
206
|
+
|
|
207
|
+
if (!buf.subarray(0, 4).equals(MAGIC)) throw new Error("FST magic mismatch")
|
|
208
|
+
const version = buf.readUInt16LE(4)
|
|
209
|
+
|
|
210
|
+
if (version < 1 || version > VERSION) throw new Error(`FST version ${version} unsupported (expected 1..${VERSION})`)
|
|
211
|
+
const isV2 = version >= 2
|
|
212
|
+
|
|
213
|
+
const stateCount = buf.readUInt32LE(8)
|
|
214
|
+
const edgeCount = buf.readUInt32LE(12)
|
|
215
|
+
const _placeCount = buf.readUInt32LE(16)
|
|
216
|
+
const stringCount = buf.readUInt32LE(20)
|
|
217
|
+
const stringBytes = buf.readUInt32LE(24)
|
|
218
|
+
|
|
219
|
+
let pos = HEADER_SIZE
|
|
220
|
+
|
|
221
|
+
// --- String table ---
|
|
222
|
+
const strOffsets = new Uint32Array(stringCount + 1)
|
|
223
|
+
|
|
224
|
+
for (let i = 0; i <= stringCount; i++) {
|
|
225
|
+
strOffsets[i] = buf.readUInt32LE(pos)
|
|
226
|
+
pos += 4
|
|
227
|
+
}
|
|
228
|
+
const strDataStart = pos
|
|
229
|
+
const strings: string[] = new Array(stringCount)
|
|
230
|
+
|
|
231
|
+
for (let i = 0; i < stringCount; i++) {
|
|
232
|
+
const start = strDataStart + strOffsets[i]!
|
|
233
|
+
const end = strDataStart + strOffsets[i + 1]!
|
|
234
|
+
strings[i] = buf.toString("utf8", start, end)
|
|
235
|
+
}
|
|
236
|
+
pos += stringBytes
|
|
237
|
+
|
|
238
|
+
// --- State table ---
|
|
239
|
+
const stateEntrySize = version >= 4 ? 16 : 12
|
|
240
|
+
const stateTableStart = pos
|
|
241
|
+
const edgeTableStart = stateTableStart + stateCount * stateEntrySize
|
|
242
|
+
const placeTableStart = edgeTableStart + edgeCount * EDGE_ENTRY_SIZE
|
|
243
|
+
|
|
244
|
+
const nodes: FSTNode[] = new Array(stateCount)
|
|
245
|
+
|
|
246
|
+
for (let si = 0; si < stateCount; si++) {
|
|
247
|
+
const sp = stateTableStart + si * stateEntrySize
|
|
248
|
+
const edgeStart = buf.readUInt32LE(sp)
|
|
249
|
+
const placeStart = buf.readUInt32LE(sp + 4)
|
|
250
|
+
const edgeCountForState = version >= 4 ? buf.readUInt32LE(sp + 8) : buf.readUInt16LE(sp + 8)
|
|
251
|
+
const placeCountForState = version >= 4 ? buf.readUInt32LE(sp + 12) : buf.readUInt16LE(sp + 10)
|
|
252
|
+
|
|
253
|
+
const edges = new Map<string, number>()
|
|
254
|
+
|
|
255
|
+
for (let ei = 0; ei < edgeCountForState; ei++) {
|
|
256
|
+
const ep = edgeTableStart + (edgeStart + ei) * EDGE_ENTRY_SIZE
|
|
257
|
+
const stringIdx = buf.readUInt32LE(ep)
|
|
258
|
+
const target = buf.readUInt32LE(ep + 4)
|
|
259
|
+
edges.set(strings[stringIdx]!, target)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const places: PlaceEntry[] = new Array(placeCountForState)
|
|
263
|
+
|
|
264
|
+
for (let pi = 0; pi < placeCountForState; pi++) {
|
|
265
|
+
const pp = placeTableStart + (placeStart + pi) * PLACE_ENTRY_SIZE
|
|
266
|
+
const chainLen = buf.readUInt8(pp + 5)
|
|
267
|
+
const parentChain: number[] = []
|
|
268
|
+
|
|
269
|
+
for (let ci = 0; ci < chainLen; ci++) {
|
|
270
|
+
parentChain.push(buf.readUInt32LE(pp + 24 + ci * 4))
|
|
271
|
+
}
|
|
272
|
+
const rawImportance = isV2
|
|
273
|
+
? buf.readFloatLE(pp + 12)
|
|
274
|
+
: Math.min(1.0, Math.log2(1 + buf.readUInt32LE(pp + 12) / 1000) / 14)
|
|
275
|
+
places[pi] = {
|
|
276
|
+
wofID: buf.readUInt32LE(pp),
|
|
277
|
+
placetype: PLACETYPE_ORDER[buf.readUInt8(pp + 4)] ?? "locality",
|
|
278
|
+
name: strings[buf.readUInt32LE(pp + 8)]!,
|
|
279
|
+
importance: rawImportance,
|
|
280
|
+
lat: buf.readFloatLE(pp + 16),
|
|
281
|
+
lon: buf.readFloatLE(pp + 20),
|
|
282
|
+
parentChain,
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
nodes[si] = { edges, places }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return FSTMatcher.fromNodes(nodes)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function readFSTProvenance(buf: Buffer): FSTProvenance | undefined {
|
|
293
|
+
if (buf.length < HEADER_SIZE) return undefined
|
|
294
|
+
|
|
295
|
+
if (!buf.subarray(0, 4).equals(MAGIC)) return undefined
|
|
296
|
+
const version = buf.readUInt16LE(4)
|
|
297
|
+
|
|
298
|
+
if (version < 3) return undefined
|
|
299
|
+
const provenanceOffset = buf.readUInt32LE(28)
|
|
300
|
+
|
|
301
|
+
if (provenanceOffset === 0 || provenanceOffset >= buf.length) return undefined
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const jsonLen = buf.readUInt32LE(provenanceOffset)
|
|
305
|
+
const jsonStr = buf.toString("utf8", provenanceOffset + 4, provenanceOffset + 4 + jsonLen)
|
|
306
|
+
|
|
307
|
+
return JSON.parse(jsonStr) as FSTProvenance
|
|
308
|
+
} catch {
|
|
309
|
+
return undefined
|
|
310
|
+
}
|
|
311
|
+
}
|
package/fst-types.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Types for the FST gazetteer language model. The FST maps token sequences (place names) to
|
|
7
|
+
* (placetype, wof_id, parent_chain) entries — pre-computing the valid interpretations for each
|
|
8
|
+
* prefix of every place name in the gazetteer.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface PlaceEntry {
|
|
12
|
+
wofID: number
|
|
13
|
+
placetype: PlacetypeID
|
|
14
|
+
name: string
|
|
15
|
+
parentChain: number[]
|
|
16
|
+
importance: number
|
|
17
|
+
lat: number
|
|
18
|
+
lon: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type PlacetypeID =
|
|
22
|
+
| "country"
|
|
23
|
+
| "region"
|
|
24
|
+
| "county"
|
|
25
|
+
| "locality"
|
|
26
|
+
| "localadmin"
|
|
27
|
+
| "borough"
|
|
28
|
+
| "neighbourhood"
|
|
29
|
+
| "postalcode"
|
|
30
|
+
| "campus"
|
|
31
|
+
| "dependency"
|
|
32
|
+
| "street_affix"
|
|
33
|
+
|
|
34
|
+
export interface FSTMatchResult {
|
|
35
|
+
stateID: number
|
|
36
|
+
accepted: boolean
|
|
37
|
+
depth: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface FSTContinuation {
|
|
41
|
+
token: string
|
|
42
|
+
targetState: number
|
|
43
|
+
acceptingCount: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface FSTQueryResult {
|
|
47
|
+
path: string[]
|
|
48
|
+
stateID: number
|
|
49
|
+
accepting: PlaceEntry[]
|
|
50
|
+
continuations: FSTContinuation[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface FSTProvenance {
|
|
54
|
+
builtAt: string
|
|
55
|
+
countries: string[]
|
|
56
|
+
stateCount: number
|
|
57
|
+
placeCount: number
|
|
58
|
+
edgeCount: number
|
|
59
|
+
nameInsertions: number
|
|
60
|
+
importanceMatches: number
|
|
61
|
+
sourceDB?: string
|
|
62
|
+
modelCardVersion?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface BuildFSTOpts {
|
|
66
|
+
dbPath: string
|
|
67
|
+
countries?: string[]
|
|
68
|
+
placetypes?: PlacetypeID[]
|
|
69
|
+
languages?: string[]
|
|
70
|
+
onProgress?: (phase: string, detail?: string) => void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface BuildFSTResult {
|
|
74
|
+
stateCount: number
|
|
75
|
+
placeCount: number
|
|
76
|
+
edgeCount: number
|
|
77
|
+
tokenCount: number
|
|
78
|
+
}
|