@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
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { normaliseUuid, ObjectTypes, type Page, SystemNodes } from '@crombolo/core'
|
|
8
8
|
import type { Db } from '../database.ts'
|
|
9
9
|
import { DbDate, fromDbBool } from '../dialect.ts'
|
|
10
|
+
import { searchTerms } from '../search-text.ts'
|
|
10
11
|
|
|
11
12
|
export interface NodeRow {
|
|
12
13
|
id: number
|
|
@@ -231,30 +232,54 @@ export class NodeRepository {
|
|
|
231
232
|
return { items: rows.slice(start, end), totalBefore: start, totalAfter: rows.length - end }
|
|
232
233
|
}
|
|
233
234
|
|
|
234
|
-
/**
|
|
235
|
+
/**
|
|
236
|
+
* Name search, case-insensitive, optionally under a parent. With `fullText`,
|
|
237
|
+
* an item also matches when the search index holds every word of the query by
|
|
238
|
+
* prefix; items whose name matches come first, then the best index matches.
|
|
239
|
+
*/
|
|
235
240
|
async search(
|
|
236
241
|
objectType: string,
|
|
237
242
|
query: string,
|
|
238
|
-
options: {
|
|
243
|
+
options: {
|
|
244
|
+
trashed?: boolean
|
|
245
|
+
parentId?: number
|
|
246
|
+
fullText?: boolean
|
|
247
|
+
skip: number
|
|
248
|
+
take: number
|
|
249
|
+
},
|
|
239
250
|
): Promise<Page<NodeRow>> {
|
|
240
|
-
const
|
|
251
|
+
const like = `%${query.toLowerCase().replaceAll('%', '').replaceAll('_', '')}%`
|
|
252
|
+
const terms = options.fullText ? searchTerms(query) : []
|
|
253
|
+
const indexed = terms.length > 0
|
|
254
|
+
const conditions = [
|
|
255
|
+
'node_object_type = ?',
|
|
256
|
+
'trashed = ?',
|
|
257
|
+
indexed ? '(LOWER(text) LIKE ? OR h.node_id IS NOT NULL)' : 'LOWER(text) LIKE ?',
|
|
258
|
+
]
|
|
241
259
|
const params: unknown[] = [
|
|
260
|
+
...(indexed ? [this.#db.dialect.fullTextQuery(terms)] : []),
|
|
242
261
|
objectType,
|
|
243
262
|
this.#db.dialect.boolValue(options.trashed ?? false),
|
|
244
|
-
|
|
263
|
+
like,
|
|
245
264
|
]
|
|
246
265
|
if (options.parentId !== undefined) {
|
|
247
266
|
conditions.push('parent_id = ?')
|
|
248
267
|
params.push(options.parentId)
|
|
249
268
|
}
|
|
269
|
+
const from = indexed
|
|
270
|
+
? `node LEFT JOIN (${this.#db.dialect.fullTextHits}) h ON h.node_id = node.id`
|
|
271
|
+
: 'node'
|
|
250
272
|
const where = conditions.join(' AND ')
|
|
251
273
|
const totals = await this.#db.query<{ n: number }>(
|
|
252
|
-
`SELECT COUNT(*) AS n FROM
|
|
274
|
+
`SELECT COUNT(*) AS n FROM ${from} WHERE ${where}`,
|
|
253
275
|
params,
|
|
254
276
|
)
|
|
277
|
+
const order = indexed
|
|
278
|
+
? 'CASE WHEN LOWER(text) LIKE ? THEN 0 ELSE 1 END, COALESCE(h.score, 0) DESC, text, id'
|
|
279
|
+
: 'text, id'
|
|
255
280
|
const rows = await this.#db.query(
|
|
256
|
-
`SELECT ${NODE_COLUMNS} FROM
|
|
257
|
-
[...params, options.take, options.skip],
|
|
281
|
+
`SELECT ${NODE_COLUMNS} FROM ${from} WHERE ${where} ORDER BY ${order} LIMIT ? OFFSET ?`,
|
|
282
|
+
[...params, ...(indexed ? [like] : []), options.take, options.skip],
|
|
258
283
|
)
|
|
259
284
|
return { total: Number(totals[0]?.n ?? 0), items: rows.map(mapNode) }
|
|
260
285
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration 033 — the audit log. docs/audit-log.md.
|
|
3
|
+
*
|
|
4
|
+
* `audit_entry` records who did what, when and from where: every backoffice
|
|
5
|
+
* change, every sign-in and failed sign-in, and what the command line and the
|
|
6
|
+
* background jobs changed. Names are copied in when an entry is written, so an
|
|
7
|
+
* entry still reads after its user or item is gone. It starts empty: history from
|
|
8
|
+
* before this migration stays in the content versions it came from.
|
|
9
|
+
*/
|
|
10
|
+
import type { Db } from '../database.ts'
|
|
11
|
+
import type { Migration } from '../migrations.ts'
|
|
12
|
+
import { STATE_PROPERTY_SEARCHABLE } from './property-searchable.ts'
|
|
13
|
+
|
|
14
|
+
export const STATE_AUDIT_LOG = '{6f1d4a8e-0033-4c1a-9e7d-3a5b8c2d4e33}'
|
|
15
|
+
|
|
16
|
+
export const auditLogMigration: Migration = {
|
|
17
|
+
from: STATE_PROPERTY_SEARCHABLE,
|
|
18
|
+
to: STATE_AUDIT_LOG,
|
|
19
|
+
name: 'AddAuditLog',
|
|
20
|
+
kind: 'expand',
|
|
21
|
+
release: '0.10.0',
|
|
22
|
+
async up(db: Db) {
|
|
23
|
+
const t = db.dialect.types
|
|
24
|
+
await db.exec(
|
|
25
|
+
`CREATE TABLE audit_entry (
|
|
26
|
+
id ${t.identity},
|
|
27
|
+
occurred_at ${t.timestamp} NOT NULL,
|
|
28
|
+
category ${t.varchar(32)} NOT NULL,
|
|
29
|
+
action ${t.varchar(64)} NOT NULL,
|
|
30
|
+
user_key ${t.uuid},
|
|
31
|
+
user_name ${t.varchar(255)},
|
|
32
|
+
entity_type ${t.varchar(64)},
|
|
33
|
+
entity_key ${t.varchar(255)},
|
|
34
|
+
entity_name ${t.varchar(255)},
|
|
35
|
+
source ${t.varchar(16)} NOT NULL,
|
|
36
|
+
ip_address ${t.varchar(64)},
|
|
37
|
+
forwarded_for ${t.varchar(255)},
|
|
38
|
+
detail ${t.text}
|
|
39
|
+
)`,
|
|
40
|
+
)
|
|
41
|
+
await db.exec('CREATE INDEX ix_audit_entry_occurred ON audit_entry (occurred_at)')
|
|
42
|
+
await db.exec('CREATE INDEX ix_audit_entry_entity ON audit_entry (entity_key, occurred_at)')
|
|
43
|
+
await db.exec('CREATE INDEX ix_audit_entry_user ON audit_entry (user_key, occurred_at)')
|
|
44
|
+
},
|
|
45
|
+
}
|
package/src/schema/plan.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { MigrationPlan } from '../migrations.ts'
|
|
15
15
|
import { assistantMigration } from './assistant.ts'
|
|
16
|
+
import { auditLogMigration } from './audit-log.ts'
|
|
16
17
|
import { bundleComponentsMigration } from './bundle-components.ts'
|
|
17
18
|
import { bundlePackageMigration } from './bundle-package.ts'
|
|
18
19
|
import { bundlesRenameMigration } from './bundles-rename.ts'
|
|
@@ -31,10 +32,12 @@ import { identityMigration } from './identity.ts'
|
|
|
31
32
|
import { logViewerMigration } from './log-viewer.ts'
|
|
32
33
|
import { memberPropertyTypeMigration } from './member-property-type.ts'
|
|
33
34
|
import { membersMigration } from './members.ts'
|
|
35
|
+
import { propertySearchableMigration } from './property-searchable.ts'
|
|
34
36
|
import { publicAccessMigration } from './public-access.ts'
|
|
35
37
|
import { recycleBinMigration } from './recycle-bin.ts'
|
|
36
38
|
import { redirectsMigration } from './redirects.ts'
|
|
37
39
|
import { richTextUiMigration } from './rich-text-ui.ts'
|
|
40
|
+
import { searchIndexMigration } from './search-index.ts'
|
|
38
41
|
import { serverRoleMigration } from './server-role.ts'
|
|
39
42
|
import { sinceVersionMigration } from './since-version.ts'
|
|
40
43
|
import { transferRunPackageMigration } from './transfer-run-package.ts'
|
|
@@ -74,4 +77,7 @@ export const cromboloPlan = new MigrationPlan([
|
|
|
74
77
|
bundleComponentsMigration,
|
|
75
78
|
bundlePackageMigration,
|
|
76
79
|
transferRunPackageMigration,
|
|
80
|
+
searchIndexMigration,
|
|
81
|
+
propertySearchableMigration,
|
|
82
|
+
auditLogMigration,
|
|
77
83
|
])
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration 032 — a property can be kept out of backoffice search.
|
|
3
|
+
*
|
|
4
|
+
* `searchable = false` on a property in the schema files. Stored on the
|
|
5
|
+
* property type, because the backoffice writes those files back from the
|
|
6
|
+
* database and its save carries no such flag, so the column is what a save
|
|
7
|
+
* preserves. Every existing property stays searchable. docs/search.md.
|
|
8
|
+
*/
|
|
9
|
+
import type { Db } from '../database.ts'
|
|
10
|
+
import type { Migration } from '../migrations.ts'
|
|
11
|
+
import { schemaOps } from '../schema-ops.ts'
|
|
12
|
+
import { STATE_SEARCH_INDEX } from './search-index.ts'
|
|
13
|
+
|
|
14
|
+
export const STATE_PROPERTY_SEARCHABLE = '{6f1d4a8e-0032-4b7e-8a2c-5d9f1e3b6c32}'
|
|
15
|
+
|
|
16
|
+
export const propertySearchableMigration: Migration = {
|
|
17
|
+
from: STATE_SEARCH_INDEX,
|
|
18
|
+
to: STATE_PROPERTY_SEARCHABLE,
|
|
19
|
+
name: 'AddPropertySearchable',
|
|
20
|
+
kind: 'expand',
|
|
21
|
+
release: '0.10.0',
|
|
22
|
+
async up(db: Db) {
|
|
23
|
+
await schemaOps(db).addColumn(
|
|
24
|
+
'property_type',
|
|
25
|
+
'searchable',
|
|
26
|
+
`${db.dialect.types.boolean} NOT NULL DEFAULT ${db.dialect.name === 'postgres' ? 'TRUE' : '1'}`,
|
|
27
|
+
)
|
|
28
|
+
},
|
|
29
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration 031 — the backoffice search index. docs/search.md.
|
|
3
|
+
*
|
|
4
|
+
* `search_document` holds one row per item and culture: its name and the text of
|
|
5
|
+
* its current values, folded to lowercase without diacritics. SQLite matches it
|
|
6
|
+
* through an FTS5 table kept in step by triggers; Postgres through a generated
|
|
7
|
+
* `tsvector` column. Both use language-neutral word splitting, so the two
|
|
8
|
+
* dialects find the same things.
|
|
9
|
+
*
|
|
10
|
+
* `search_index_queue` is what a write leaves for the indexer: a node id and a
|
|
11
|
+
* generation, bumped each time the node is queued again, so a row re-queued
|
|
12
|
+
* while it was being indexed survives the indexer's delete. Every existing
|
|
13
|
+
* page, media item and member is queued here, which is the backfill.
|
|
14
|
+
*/
|
|
15
|
+
import { ObjectTypes } from '@crombolo/core'
|
|
16
|
+
import type { Db } from '../database.ts'
|
|
17
|
+
import type { Migration } from '../migrations.ts'
|
|
18
|
+
import { STATE_TRANSFER_RUN_PACKAGE } from './transfer-run-package.ts'
|
|
19
|
+
|
|
20
|
+
export const STATE_SEARCH_INDEX = '{6f1d4a8e-0031-4f3a-9c6d-2b8e5a1f7d31}'
|
|
21
|
+
|
|
22
|
+
export const searchIndexMigration: Migration = {
|
|
23
|
+
from: STATE_TRANSFER_RUN_PACKAGE,
|
|
24
|
+
to: STATE_SEARCH_INDEX,
|
|
25
|
+
name: 'AddSearchIndex',
|
|
26
|
+
kind: 'expand',
|
|
27
|
+
release: '0.10.0',
|
|
28
|
+
async up(db: Db) {
|
|
29
|
+
const t = db.dialect.types
|
|
30
|
+
const postgres = db.dialect.name === 'postgres'
|
|
31
|
+
await db.exec(
|
|
32
|
+
`CREATE TABLE search_document (
|
|
33
|
+
id ${t.identity},
|
|
34
|
+
node_id ${t.integer} NOT NULL REFERENCES node (id) ON DELETE CASCADE,
|
|
35
|
+
culture ${t.varchar(14)} NOT NULL,
|
|
36
|
+
name ${t.text} NOT NULL,
|
|
37
|
+
body ${t.text} NOT NULL,
|
|
38
|
+
fields ${t.json} NOT NULL,
|
|
39
|
+
field_count ${t.integer} NOT NULL,
|
|
40
|
+
indexed_at ${t.timestamp} NOT NULL${
|
|
41
|
+
postgres
|
|
42
|
+
? `,
|
|
43
|
+
terms tsvector GENERATED ALWAYS AS (
|
|
44
|
+
setweight(to_tsvector('simple', name), 'A') || setweight(to_tsvector('simple', body), 'B')
|
|
45
|
+
) STORED`
|
|
46
|
+
: ''
|
|
47
|
+
},
|
|
48
|
+
UNIQUE (node_id, culture)
|
|
49
|
+
)`,
|
|
50
|
+
)
|
|
51
|
+
if (postgres) {
|
|
52
|
+
await db.exec('CREATE INDEX ix_search_document_terms ON search_document USING GIN (terms)')
|
|
53
|
+
} else {
|
|
54
|
+
await db.exec(
|
|
55
|
+
`CREATE VIRTUAL TABLE search_document_fts USING fts5(
|
|
56
|
+
name, body, content='search_document', content_rowid='id',
|
|
57
|
+
tokenize='unicode61 remove_diacritics 2'
|
|
58
|
+
)`,
|
|
59
|
+
)
|
|
60
|
+
await db.exec(
|
|
61
|
+
`CREATE TRIGGER search_document_ai AFTER INSERT ON search_document BEGIN
|
|
62
|
+
INSERT INTO search_document_fts (rowid, name, body) VALUES (new.id, new.name, new.body);
|
|
63
|
+
END`,
|
|
64
|
+
)
|
|
65
|
+
await db.exec(
|
|
66
|
+
`CREATE TRIGGER search_document_ad AFTER DELETE ON search_document BEGIN
|
|
67
|
+
INSERT INTO search_document_fts (search_document_fts, rowid, name, body)
|
|
68
|
+
VALUES ('delete', old.id, old.name, old.body);
|
|
69
|
+
END`,
|
|
70
|
+
)
|
|
71
|
+
await db.exec(
|
|
72
|
+
`CREATE TRIGGER search_document_au AFTER UPDATE ON search_document BEGIN
|
|
73
|
+
INSERT INTO search_document_fts (search_document_fts, rowid, name, body)
|
|
74
|
+
VALUES ('delete', old.id, old.name, old.body);
|
|
75
|
+
INSERT INTO search_document_fts (rowid, name, body) VALUES (new.id, new.name, new.body);
|
|
76
|
+
END`,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
await db.exec(
|
|
80
|
+
`CREATE TABLE search_index_queue (
|
|
81
|
+
node_id ${t.integer} PRIMARY KEY REFERENCES node (id) ON DELETE CASCADE,
|
|
82
|
+
generation ${t.integer} NOT NULL,
|
|
83
|
+
queued_at ${t.timestamp} NOT NULL
|
|
84
|
+
)`,
|
|
85
|
+
)
|
|
86
|
+
await db.exec(
|
|
87
|
+
`INSERT INTO search_index_queue (node_id, generation, queued_at)
|
|
88
|
+
SELECT id, 1, ${db.dialect.now()} FROM node WHERE node_object_type IN (?, ?, ?)`,
|
|
89
|
+
[ObjectTypes.Document, ObjectTypes.Media, ObjectTypes.Member],
|
|
90
|
+
)
|
|
91
|
+
},
|
|
92
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The words a stored value contributes to the search index, and the folding
|
|
3
|
+
* both the index and a query go through so they meet. docs/search.md.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Lowercase words without diacritics: letters and digits only, everything else
|
|
8
|
+
* a separator. The index stores text in this form, so the full-text engines on
|
|
9
|
+
* both dialects only ever split on spaces, and match the same way.
|
|
10
|
+
*/
|
|
11
|
+
export function searchWords(text: string): string[] {
|
|
12
|
+
return text
|
|
13
|
+
.normalize('NFKD')
|
|
14
|
+
.replace(/\p{M}+/gu, '')
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
17
|
+
.filter((word) => word.length > 0)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The distinct words of a query, at most a dozen: what every result must hold by prefix. */
|
|
21
|
+
export function searchTerms(query: string): string[] {
|
|
22
|
+
return [...new Set(searchWords(query))].slice(0, 12)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const IDENTIFIER =
|
|
26
|
+
/^(?:umb:\/\/\S+|\{?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}?)$/i
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Keys inside an editor's JSON that hold structure rather than words: block
|
|
30
|
+
* and media references, editor aliases, crops. Their values are never text an
|
|
31
|
+
* editor wrote.
|
|
32
|
+
*/
|
|
33
|
+
const STRUCTURAL_KEYS = new Set([
|
|
34
|
+
'alias',
|
|
35
|
+
'contentKey',
|
|
36
|
+
'contentTypeKey',
|
|
37
|
+
'crops',
|
|
38
|
+
'culture',
|
|
39
|
+
'editorAlias',
|
|
40
|
+
'expose',
|
|
41
|
+
'focalPoint',
|
|
42
|
+
'icon',
|
|
43
|
+
'key',
|
|
44
|
+
'layout',
|
|
45
|
+
'mediaKey',
|
|
46
|
+
'mediaTypeAlias',
|
|
47
|
+
'segment',
|
|
48
|
+
'settingsKey',
|
|
49
|
+
'settingsTypeKey',
|
|
50
|
+
'udi',
|
|
51
|
+
'unique',
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
const ENTITIES: Record<string, string> = {
|
|
55
|
+
amp: '&',
|
|
56
|
+
lt: '<',
|
|
57
|
+
gt: '>',
|
|
58
|
+
quot: '"',
|
|
59
|
+
apos: "'",
|
|
60
|
+
nbsp: ' ',
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stripMarkup(text: string): string {
|
|
64
|
+
if (!text.includes('<') && !text.includes('&')) return text
|
|
65
|
+
return text
|
|
66
|
+
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
|
|
67
|
+
.replace(/<[^>]*>/g, ' ')
|
|
68
|
+
.replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (entity, name: string) => {
|
|
69
|
+
if (name.startsWith('#x') || name.startsWith('#X'))
|
|
70
|
+
return String.fromCodePoint(Number.parseInt(name.slice(2), 16))
|
|
71
|
+
if (name.startsWith('#')) return String.fromCodePoint(Number(name.slice(1)))
|
|
72
|
+
return ENTITIES[name.toLowerCase()] ?? entity
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Whether an element type's property is kept out of search: `searchable = false`. */
|
|
77
|
+
export type UnsearchableProperty = (contentTypeKey: string, alias: string) => boolean
|
|
78
|
+
|
|
79
|
+
const NOTHING_UNSEARCHABLE: UnsearchableProperty = () => false
|
|
80
|
+
|
|
81
|
+
function collect(value: unknown, out: string[], unsearchable: UnsearchableProperty): void {
|
|
82
|
+
if (typeof value === 'string') {
|
|
83
|
+
const trimmed = value.trim()
|
|
84
|
+
if (trimmed.length === 0 || IDENTIFIER.test(trimmed)) return
|
|
85
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
86
|
+
try {
|
|
87
|
+
collect(JSON.parse(trimmed), out, unsearchable)
|
|
88
|
+
return
|
|
89
|
+
} catch {
|
|
90
|
+
// Not JSON after all; it is text that happens to start with a bracket.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const text = stripMarkup(trimmed).replace(/\s+/g, ' ').trim()
|
|
94
|
+
if (text.length > 0) out.push(text)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
if (Array.isArray(value)) {
|
|
98
|
+
for (const item of value) collect(item, out, unsearchable)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
if (!value || typeof value !== 'object') return
|
|
102
|
+
const record = value as Record<string, unknown>
|
|
103
|
+
// A block's element: its values are listed by alias, or (older format) keyed by it.
|
|
104
|
+
const elementType = typeof record.contentTypeKey === 'string' ? record.contentTypeKey : undefined
|
|
105
|
+
for (const [key, item] of Object.entries(record)) {
|
|
106
|
+
if (STRUCTURAL_KEYS.has(key)) continue
|
|
107
|
+
if (elementType && unsearchable(elementType, key)) continue
|
|
108
|
+
if (elementType && key === 'values' && Array.isArray(item)) {
|
|
109
|
+
for (const entry of item) {
|
|
110
|
+
const alias = (entry as { alias?: unknown } | null)?.alias
|
|
111
|
+
if (typeof alias === 'string' && unsearchable(elementType, alias)) continue
|
|
112
|
+
collect(entry, out, unsearchable)
|
|
113
|
+
}
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
collect(item, out, unsearchable)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The readable text in a stored value: plain strings as they are, markup without
|
|
122
|
+
* its tags, and the strings inside an editor's JSON (blocks, rich text, pickers
|
|
123
|
+
* with captions) without the keys and references that hold it together, or
|
|
124
|
+
* the values of a block's properties marked `searchable = false`.
|
|
125
|
+
*/
|
|
126
|
+
export function valueText(
|
|
127
|
+
raw: unknown,
|
|
128
|
+
unsearchable: UnsearchableProperty = NOTHING_UNSEARCHABLE,
|
|
129
|
+
): string[] {
|
|
130
|
+
const out: string[] = []
|
|
131
|
+
collect(raw, out, unsearchable)
|
|
132
|
+
return out
|
|
133
|
+
}
|