@crombolo/data 0.10.0 → 0.11.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/package.json +2 -2
- package/src/audit.ts +291 -0
- package/src/database.ts +39 -9
- package/src/dialect.ts +24 -0
- package/src/index.ts +6 -0
- package/src/repositories/content-types.ts +41 -4
- package/src/repositories/documents.ts +12 -2
- package/src/repositories/members.ts +15 -1
- package/src/repositories/nodes.ts +32 -7
- package/src/schema/audit-log.ts +45 -0
- package/src/schema/plan.ts +6 -0
- package/src/schema/property-searchable.ts +29 -0
- package/src/schema/search-index.ts +92 -0
- package/src/search-text.ts +133 -0
- package/src/search.ts +397 -0
package/src/search.ts
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The backoffice search index: what a write queues, how the indexer turns a
|
|
3
|
+
* queued item into rows, and the two indexes the Examine dashboard shows.
|
|
4
|
+
* docs/search.md.
|
|
5
|
+
*/
|
|
6
|
+
import { ObjectTypes } from '@crombolo/core'
|
|
7
|
+
import type { Db } from './database.ts'
|
|
8
|
+
import { DbDate, fromDbBool } from './dialect.ts'
|
|
9
|
+
import { searchTerms, searchWords, type UnsearchableProperty, valueText } from './search-text.ts'
|
|
10
|
+
|
|
11
|
+
/** Umbraco's names for the two backoffice indexes, which the dashboard shows. */
|
|
12
|
+
export type SearchIndexName = 'InternalIndex' | 'MembersIndex'
|
|
13
|
+
|
|
14
|
+
export interface SearchIndexDefinition {
|
|
15
|
+
name: SearchIndexName
|
|
16
|
+
searcherName: string
|
|
17
|
+
objectTypes: readonly string[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const SEARCH_INDEXES: readonly SearchIndexDefinition[] = [
|
|
21
|
+
{
|
|
22
|
+
name: 'InternalIndex',
|
|
23
|
+
searcherName: 'InternalSearcher',
|
|
24
|
+
objectTypes: [ObjectTypes.Document, ObjectTypes.Media],
|
|
25
|
+
},
|
|
26
|
+
{ name: 'MembersIndex', searcherName: 'MembersSearcher', objectTypes: [ObjectTypes.Member] },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
const INDEXED_TYPES: readonly string[] = SEARCH_INDEXES.flatMap((index) => index.objectTypes)
|
|
30
|
+
|
|
31
|
+
const INDEX_TYPE: Record<string, string> = {
|
|
32
|
+
[ObjectTypes.Document]: 'content',
|
|
33
|
+
[ObjectTypes.Media]: 'media',
|
|
34
|
+
[ObjectTypes.Member]: 'member',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const marks = (n: number) => Array.from({ length: n }, () => '?').join(', ')
|
|
38
|
+
|
|
39
|
+
const ENQUEUE = `INSERT INTO search_index_queue (node_id, generation, queued_at)`
|
|
40
|
+
const ON_CONFLICT = `ON CONFLICT (node_id) DO UPDATE
|
|
41
|
+
SET generation = search_index_queue.generation + 1, queued_at = excluded.queued_at`
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Queues items for the indexer. Called inside the write's own transaction, so
|
|
45
|
+
* the queue row commits with the change or not at all. Ids of anything that is
|
|
46
|
+
* not a page, media item or member are ignored.
|
|
47
|
+
*/
|
|
48
|
+
export async function queueForSearch(db: Db, nodeIds: Iterable<number>): Promise<void> {
|
|
49
|
+
const ids = [...new Set(nodeIds)]
|
|
50
|
+
if (ids.length === 0) return
|
|
51
|
+
for (let i = 0; i < ids.length; i += 500) {
|
|
52
|
+
const chunk = ids.slice(i, i + 500)
|
|
53
|
+
await db.exec(
|
|
54
|
+
`${ENQUEUE} SELECT id, 1, ${db.dialect.now()} FROM node
|
|
55
|
+
WHERE id IN (${marks(chunk.length)}) AND node_object_type IN (${marks(INDEXED_TYPES.length)})
|
|
56
|
+
${ON_CONFLICT}`,
|
|
57
|
+
[...chunk, ...INDEXED_TYPES],
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Queues every item of these content types, and of the types composing them. */
|
|
63
|
+
export async function queueContentTypesForSearch(
|
|
64
|
+
db: Db,
|
|
65
|
+
contentTypeNodeIds: Iterable<number>,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
const types = new Set(contentTypeNodeIds)
|
|
68
|
+
let frontier = [...types]
|
|
69
|
+
while (frontier.length > 0) {
|
|
70
|
+
const children = await db.query<{ id: number }>(
|
|
71
|
+
`SELECT child_content_type_id AS id FROM content_type_composition
|
|
72
|
+
WHERE parent_content_type_id IN (${marks(frontier.length)})`,
|
|
73
|
+
frontier,
|
|
74
|
+
)
|
|
75
|
+
frontier = children.map((row) => Number(row.id)).filter((id) => !types.has(id))
|
|
76
|
+
for (const id of frontier) types.add(id)
|
|
77
|
+
}
|
|
78
|
+
if (types.size === 0) return
|
|
79
|
+
const ids = [...types]
|
|
80
|
+
await db.exec(
|
|
81
|
+
`${ENQUEUE} SELECT node_id, 1, ${db.dialect.now()} FROM content
|
|
82
|
+
WHERE content_type_id IN (${marks(ids.length)})
|
|
83
|
+
${ON_CONFLICT}`,
|
|
84
|
+
ids,
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Queues every page, media item and member. An element type's properties are
|
|
90
|
+
* read inside blocks wherever they are used, which no query can narrow.
|
|
91
|
+
*/
|
|
92
|
+
export async function queueEverythingForSearch(db: Db): Promise<void> {
|
|
93
|
+
await db.exec(
|
|
94
|
+
`${ENQUEUE} SELECT id, 1, ${db.dialect.now()} FROM node
|
|
95
|
+
WHERE node_object_type IN (${marks(INDEXED_TYPES.length)})
|
|
96
|
+
${ON_CONFLICT}`,
|
|
97
|
+
[...INDEXED_TYPES],
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One row of `search_document`: an item in one culture, '' when invariant. */
|
|
102
|
+
export interface SearchEntry {
|
|
103
|
+
culture: string
|
|
104
|
+
name: string
|
|
105
|
+
body: string
|
|
106
|
+
fields: Record<string, string[]>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface SearchIndexStats {
|
|
110
|
+
documentCount: number
|
|
111
|
+
fieldCount: number
|
|
112
|
+
/** Items queued and not yet indexed. */
|
|
113
|
+
pending: number
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface SearchHit {
|
|
117
|
+
key: string
|
|
118
|
+
score: number
|
|
119
|
+
fields: Array<{ name: string; values: string[] }>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class SearchIndex {
|
|
123
|
+
#db: Db
|
|
124
|
+
|
|
125
|
+
constructor(db: Db) {
|
|
126
|
+
this.#db = db
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
static definition(name: string): SearchIndexDefinition | undefined {
|
|
130
|
+
return SEARCH_INDEXES.find((index) => index.name.toLowerCase() === name.toLowerCase())
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
static bySearcher(name: string): SearchIndexDefinition | undefined {
|
|
134
|
+
return SEARCH_INDEXES.find((index) => index.searcherName.toLowerCase() === name.toLowerCase())
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Indexes up to `limit` queued items, oldest first, and returns how many it
|
|
139
|
+
* indexed. Each item is its own transaction, claimed by locking its queue row:
|
|
140
|
+
* a second indexer passes over it, and a write queueing it again bumps the
|
|
141
|
+
* generation, so the row outlives this run and the item is indexed once more.
|
|
142
|
+
*/
|
|
143
|
+
async indexQueued(limit = 200): Promise<number> {
|
|
144
|
+
const queued = await this.#db.query<{ node_id: number; generation: number }>(
|
|
145
|
+
'SELECT node_id, generation FROM search_index_queue ORDER BY queued_at, node_id LIMIT ?',
|
|
146
|
+
[limit],
|
|
147
|
+
)
|
|
148
|
+
let indexed = 0
|
|
149
|
+
for (const row of queued) {
|
|
150
|
+
const nodeId = Number(row.node_id)
|
|
151
|
+
const generation = Number(row.generation)
|
|
152
|
+
const done = await this.#db.transaction(async (tx) => {
|
|
153
|
+
const claimed = await tx.query(
|
|
154
|
+
`SELECT node_id FROM search_index_queue WHERE node_id = ? AND generation = ? ${tx.dialect.forUpdateSkipLocked}`,
|
|
155
|
+
[nodeId, generation],
|
|
156
|
+
)
|
|
157
|
+
if (claimed.length === 0) return false
|
|
158
|
+
await writeEntries(tx, nodeId, await entriesFor(tx, nodeId))
|
|
159
|
+
await tx.exec('DELETE FROM search_index_queue WHERE node_id = ? AND generation = ?', [
|
|
160
|
+
nodeId,
|
|
161
|
+
generation,
|
|
162
|
+
])
|
|
163
|
+
return true
|
|
164
|
+
})
|
|
165
|
+
if (done) indexed++
|
|
166
|
+
}
|
|
167
|
+
return indexed
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Indexes until nothing is queued. For tests and the command line. */
|
|
171
|
+
async indexAll(): Promise<number> {
|
|
172
|
+
let total = 0
|
|
173
|
+
for (;;) {
|
|
174
|
+
const indexed = await this.indexQueued()
|
|
175
|
+
total += indexed
|
|
176
|
+
if (indexed === 0) return total
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Queues every item the index covers; the index keeps answering meanwhile. */
|
|
181
|
+
async rebuild(index: SearchIndexDefinition): Promise<void> {
|
|
182
|
+
await this.#db.exec(
|
|
183
|
+
`${ENQUEUE} SELECT id, 1, ${this.#db.dialect.now()} FROM node
|
|
184
|
+
WHERE node_object_type IN (${marks(index.objectTypes.length)})
|
|
185
|
+
${ON_CONFLICT}`,
|
|
186
|
+
[...index.objectTypes],
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async stats(index: SearchIndexDefinition): Promise<SearchIndexStats> {
|
|
191
|
+
const types = index.objectTypes
|
|
192
|
+
const [documents] = await this.#db.query<{ n: number; fields: number | null }>(
|
|
193
|
+
`SELECT COUNT(DISTINCT sd.node_id) AS n, MAX(sd.field_count) AS fields
|
|
194
|
+
FROM search_document sd JOIN node n ON n.id = sd.node_id
|
|
195
|
+
WHERE n.node_object_type IN (${marks(types.length)})`,
|
|
196
|
+
[...types],
|
|
197
|
+
)
|
|
198
|
+
const [pending] = await this.#db.query<{ n: number }>(
|
|
199
|
+
`SELECT COUNT(*) AS n FROM search_index_queue q JOIN node n ON n.id = q.node_id
|
|
200
|
+
WHERE n.node_object_type IN (${marks(types.length)})`,
|
|
201
|
+
[...types],
|
|
202
|
+
)
|
|
203
|
+
return {
|
|
204
|
+
documentCount: Number(documents?.n ?? 0),
|
|
205
|
+
fieldCount: Number(documents?.fields ?? 0),
|
|
206
|
+
pending: Number(pending?.n ?? 0),
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The Examine dashboard's search: every stored field of each matching item, best first. */
|
|
211
|
+
async query(
|
|
212
|
+
index: SearchIndexDefinition,
|
|
213
|
+
term: string,
|
|
214
|
+
paging: { skip: number; take: number },
|
|
215
|
+
): Promise<{ total: number; items: SearchHit[] }> {
|
|
216
|
+
const terms = searchTerms(term)
|
|
217
|
+
if (terms.length === 0) return { total: 0, items: [] }
|
|
218
|
+
const types = index.objectTypes
|
|
219
|
+
const from = `FROM (${this.#db.dialect.fullTextHits}) h JOIN node n ON n.id = h.node_id
|
|
220
|
+
WHERE n.node_object_type IN (${marks(types.length)})`
|
|
221
|
+
const params = [this.#db.dialect.fullTextQuery(terms), ...types]
|
|
222
|
+
const [count] = await this.#db.query<{ n: number }>(`SELECT COUNT(*) AS n ${from}`, params)
|
|
223
|
+
const rows = await this.#db.query<{ id: number; key: string; score: number }>(
|
|
224
|
+
`SELECT n.id, n.unique_id AS key, h.score ${from} ORDER BY h.score DESC, n.id LIMIT ? OFFSET ?`,
|
|
225
|
+
[...params, paging.take, paging.skip],
|
|
226
|
+
)
|
|
227
|
+
const items: SearchHit[] = []
|
|
228
|
+
for (const row of rows) {
|
|
229
|
+
const stored = await this.#db.query<{ fields: string }>(
|
|
230
|
+
'SELECT fields FROM search_document WHERE node_id = ? ORDER BY culture',
|
|
231
|
+
[Number(row.id)],
|
|
232
|
+
)
|
|
233
|
+
const merged = new Map<string, string[]>()
|
|
234
|
+
for (const entry of stored)
|
|
235
|
+
for (const [name, values] of Object.entries(
|
|
236
|
+
JSON.parse(entry.fields) as Record<string, string[]>,
|
|
237
|
+
))
|
|
238
|
+
merged.set(name, [...new Set([...(merged.get(name) ?? []), ...values])])
|
|
239
|
+
items.push({
|
|
240
|
+
key: String(row.key),
|
|
241
|
+
score: Number(row.score),
|
|
242
|
+
fields: [...merged].map(([name, values]) => ({ name, values })),
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
return { total: Number(count?.n ?? 0), items }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function writeEntries(tx: Db, nodeId: number, entries: readonly SearchEntry[]) {
|
|
250
|
+
await tx.exec('DELETE FROM search_document WHERE node_id = ?', [nodeId])
|
|
251
|
+
const now = DbDate.toDb(new Date())
|
|
252
|
+
for (const entry of entries)
|
|
253
|
+
await tx.exec(
|
|
254
|
+
`INSERT INTO search_document (node_id, culture, name, body, fields, field_count, indexed_at)
|
|
255
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
256
|
+
[
|
|
257
|
+
nodeId,
|
|
258
|
+
entry.culture,
|
|
259
|
+
entry.name,
|
|
260
|
+
entry.body,
|
|
261
|
+
JSON.stringify(entry.fields),
|
|
262
|
+
Object.keys(entry.fields).length,
|
|
263
|
+
now,
|
|
264
|
+
],
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const folded = (texts: readonly string[]) => texts.flatMap(searchWords).join(' ')
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The element-type properties marked `searchable = false`, by type key and
|
|
272
|
+
* alias, for the blocks inside a value. A property a composition contributes
|
|
273
|
+
* counts on every type composing it, which is the key a block carries.
|
|
274
|
+
*/
|
|
275
|
+
async function unsearchableElementProperties(db: Db): Promise<UnsearchableProperty> {
|
|
276
|
+
const rows = await db.query<{ type_id: number; alias: string }>(
|
|
277
|
+
`SELECT p.content_type_id AS type_id, p.alias FROM property_type p
|
|
278
|
+
WHERE p.searchable = ? AND p.retired_at IS NULL`,
|
|
279
|
+
[db.dialect.boolValue(false)],
|
|
280
|
+
)
|
|
281
|
+
if (rows.length === 0) return () => false
|
|
282
|
+
const composedBy = new Map<number, number[]>()
|
|
283
|
+
for (const row of await db.query<{ parent: number; child: number }>(
|
|
284
|
+
'SELECT parent_content_type_id AS parent, child_content_type_id AS child FROM content_type_composition',
|
|
285
|
+
))
|
|
286
|
+
composedBy.set(Number(row.parent), [
|
|
287
|
+
...(composedBy.get(Number(row.parent)) ?? []),
|
|
288
|
+
Number(row.child),
|
|
289
|
+
])
|
|
290
|
+
const keys = new Map<number, string>()
|
|
291
|
+
for (const row of await db.query<{ id: number; unique_id: string }>(
|
|
292
|
+
`SELECT n.id, n.unique_id FROM node n JOIN content_type ct ON ct.node_id = n.id`,
|
|
293
|
+
))
|
|
294
|
+
keys.set(Number(row.id), String(row.unique_id).toLowerCase())
|
|
295
|
+
const hidden = new Set<string>()
|
|
296
|
+
for (const row of rows) {
|
|
297
|
+
const seen = new Set<number>()
|
|
298
|
+
const pending = [Number(row.type_id)]
|
|
299
|
+
while (pending.length > 0) {
|
|
300
|
+
const id = pending.pop() as number
|
|
301
|
+
if (seen.has(id)) continue
|
|
302
|
+
seen.add(id)
|
|
303
|
+
hidden.add(`${keys.get(id)}|${row.alias}`)
|
|
304
|
+
pending.push(...(composedBy.get(id) ?? []))
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return (typeKey, alias) => hidden.has(`${typeKey.toLowerCase()}|${alias}`)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* What the index holds for an item, from its current (draft) values: a row per
|
|
312
|
+
* culture it has a name in, each carrying the invariant values too, or one
|
|
313
|
+
* invariant row. Retired properties are left out, and so is sensitive member
|
|
314
|
+
* data, which a search must not reveal to somebody who cannot see it. Trashed
|
|
315
|
+
* items are kept and filtered when searched, so the recycle bin is searchable.
|
|
316
|
+
*/
|
|
317
|
+
export async function entriesFor(db: Db, nodeId: number): Promise<SearchEntry[]> {
|
|
318
|
+
const [node] = await db.query<Record<string, unknown>>(
|
|
319
|
+
`SELECT n.id, n.unique_id, n.text, n.path, n.node_object_type, n.trashed, ct.alias AS type_alias
|
|
320
|
+
FROM node n JOIN content c ON c.node_id = n.id JOIN content_type ct ON ct.node_id = c.content_type_id
|
|
321
|
+
WHERE n.id = ?`,
|
|
322
|
+
[nodeId],
|
|
323
|
+
)
|
|
324
|
+
if (!node || !INDEXED_TYPES.includes(String(node.node_object_type))) return []
|
|
325
|
+
|
|
326
|
+
const values = await db.query<Record<string, unknown>>(
|
|
327
|
+
`SELECT p.alias, l.iso_code, pv.varchar_value, pv.text_value
|
|
328
|
+
FROM property_value pv
|
|
329
|
+
JOIN property_type p ON p.id = pv.property_type_id
|
|
330
|
+
LEFT JOIN language l ON l.id = pv.language_id
|
|
331
|
+
LEFT JOIN member_property_type mpt ON mpt.property_type_id = p.id
|
|
332
|
+
WHERE pv.node_id = ? AND pv.is_current = ? AND p.retired_at IS NULL AND p.searchable = ?
|
|
333
|
+
AND (mpt.is_sensitive IS NULL OR mpt.is_sensitive = ?)
|
|
334
|
+
ORDER BY p.sort_order, p.id`,
|
|
335
|
+
[nodeId, db.dialect.boolValue(true), db.dialect.boolValue(true), db.dialect.boolValue(false)],
|
|
336
|
+
)
|
|
337
|
+
const unsearchable = await unsearchableElementProperties(db)
|
|
338
|
+
const names = await db.query<{ iso_code: string; name: string }>(
|
|
339
|
+
`SELECT l.iso_code, cvcv.name FROM content_version cv
|
|
340
|
+
JOIN content_version_culture_variation cvcv ON cvcv.version_id = cv.id
|
|
341
|
+
JOIN language l ON l.id = cvcv.language_id
|
|
342
|
+
WHERE cv.node_id = ? AND cv.current = ?
|
|
343
|
+
ORDER BY l.iso_code`,
|
|
344
|
+
[nodeId, db.dialect.boolValue(true)],
|
|
345
|
+
)
|
|
346
|
+
const extraNames: string[] = []
|
|
347
|
+
const extraFields: Record<string, string[]> = {}
|
|
348
|
+
if (String(node.node_object_type) === ObjectTypes.Member) {
|
|
349
|
+
const [member] = await db.query<{ email: string; login_name: string }>(
|
|
350
|
+
'SELECT email, login_name FROM member WHERE node_id = ?',
|
|
351
|
+
[nodeId],
|
|
352
|
+
)
|
|
353
|
+
if (member) {
|
|
354
|
+
extraNames.push(String(member.email), String(member.login_name))
|
|
355
|
+
extraFields.email = [String(member.email)]
|
|
356
|
+
extraFields.loginName = [String(member.login_name)]
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const textOf = (culture: string | null) => {
|
|
361
|
+
const fields: Record<string, string[]> = {}
|
|
362
|
+
for (const row of values) {
|
|
363
|
+
const iso = row.iso_code === null || row.iso_code === undefined ? null : String(row.iso_code)
|
|
364
|
+
if (iso !== null && iso.toLowerCase() !== culture?.toLowerCase()) continue
|
|
365
|
+
const texts = valueText(row.text_value ?? row.varchar_value, unsearchable)
|
|
366
|
+
if (texts.length === 0) continue
|
|
367
|
+
const name = iso ? `${String(row.alias)}_${iso.toLowerCase()}` : String(row.alias)
|
|
368
|
+
fields[name] = [...(fields[name] ?? []), ...texts]
|
|
369
|
+
}
|
|
370
|
+
return fields
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const base = (culture: string, name: string): SearchEntry => {
|
|
374
|
+
const valueFields = textOf(culture || null)
|
|
375
|
+
const fields: Record<string, string[]> = {
|
|
376
|
+
__NodeId: [String(node.id)],
|
|
377
|
+
__Key: [String(node.unique_id)],
|
|
378
|
+
__IndexType: [INDEX_TYPE[String(node.node_object_type)] ?? 'content'],
|
|
379
|
+
__NodeTypeAlias: [String(node.type_alias)],
|
|
380
|
+
__Path: [String(node.path)],
|
|
381
|
+
...(fromDbBool(node.trashed) ? { __Trashed: ['true'] } : {}),
|
|
382
|
+
nodeName: [name],
|
|
383
|
+
...(culture ? { __Culture: [culture] } : {}),
|
|
384
|
+
...extraFields,
|
|
385
|
+
...valueFields,
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
culture,
|
|
389
|
+
name: folded([name, ...extraNames]),
|
|
390
|
+
body: folded(Object.values(valueFields).flat()),
|
|
391
|
+
fields,
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (names.length === 0) return [base('', String(node.text ?? ''))]
|
|
396
|
+
return names.map((row) => base(String(row.iso_code).toLowerCase(), String(row.name)))
|
|
397
|
+
}
|