@crombolo/data 0.15.0 → 0.17.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/index.ts +3 -0
- package/src/repositories/content-notifications.ts +187 -0
- package/src/repositories/documents.ts +31 -8
- package/src/repositories/notifications.ts +16 -2
- package/src/schema/content-notifications.ts +53 -0
- package/src/schema/plan.ts +4 -0
- package/src/schema/trashed-date.ts +23 -0
- package/src/search-text.ts +16 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/data",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "SQLite and Postgres storage for crombolo: schema, migrations and repositories.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,6 +28,6 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/core": "0.
|
|
31
|
+
"@crombolo/core": "0.17.0"
|
|
32
32
|
}
|
|
33
33
|
}
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ export * from './pending.ts'
|
|
|
14
14
|
export * from './relations.ts'
|
|
15
15
|
export * from './repositories/approvals.ts'
|
|
16
16
|
export * from './repositories/bundles.ts'
|
|
17
|
+
export * from './repositories/content-notifications.ts'
|
|
17
18
|
export * from './repositories/content-types.ts'
|
|
18
19
|
export * from './repositories/data-types.ts'
|
|
19
20
|
export * from './repositories/delivery-member-tokens.ts'
|
|
@@ -53,6 +54,7 @@ export * from './schema/bundles-rename.ts'
|
|
|
53
54
|
export * from './schema/change-report.ts'
|
|
54
55
|
export * from './schema/client-id-prefix.ts'
|
|
55
56
|
export * from './schema/content.ts'
|
|
57
|
+
export * from './schema/content-notifications.ts'
|
|
56
58
|
export * from './schema/content-seed.ts'
|
|
57
59
|
export * from './schema/created-packages.ts'
|
|
58
60
|
export * from './schema/delivery-member-tokens.ts'
|
|
@@ -77,6 +79,7 @@ export * from './schema/site-search.ts'
|
|
|
77
79
|
export * from './schema/transfer-answers.ts'
|
|
78
80
|
export * from './schema/transfer-run-package.ts'
|
|
79
81
|
export * from './schema/transfer-runs.ts'
|
|
82
|
+
export * from './schema/trashed-date.ts'
|
|
80
83
|
export * from './schema/two-factor.ts'
|
|
81
84
|
export * from './schema/upgrade-report.ts'
|
|
82
85
|
export * from './schema/users-localisation.ts'
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The content notification outbox (story 045): who is to hear of which change
|
|
3
|
+
* to which page, and whether they have. A row is claimed by one node, the same
|
|
4
|
+
* way a form workflow run is: the `state = 'pending'` in the UPDATE is the
|
|
5
|
+
* whole mechanism.
|
|
6
|
+
*/
|
|
7
|
+
import type { Db } from '../database.ts'
|
|
8
|
+
import { DbDate } from '../dialect.ts'
|
|
9
|
+
|
|
10
|
+
export type ContentNotificationState = 'pending' | 'running' | 'done' | 'failed' | 'skipped'
|
|
11
|
+
|
|
12
|
+
export interface ContentNotification {
|
|
13
|
+
id: string
|
|
14
|
+
recipientUserKey: string
|
|
15
|
+
actionId: string
|
|
16
|
+
nodeKey: string
|
|
17
|
+
nodeName: string
|
|
18
|
+
actorUserKey: string | null
|
|
19
|
+
actorName: string | null
|
|
20
|
+
detail: string | null
|
|
21
|
+
occurredAt: Date
|
|
22
|
+
state: ContentNotificationState
|
|
23
|
+
attempts: number
|
|
24
|
+
lastError: string | null
|
|
25
|
+
runAfter: Date
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface NewContentNotification {
|
|
29
|
+
recipientUserKey: string
|
|
30
|
+
actionId: string
|
|
31
|
+
nodeKey: string
|
|
32
|
+
nodeName: string
|
|
33
|
+
actorUserKey: string | null
|
|
34
|
+
actorName: string | null
|
|
35
|
+
detail: string | null
|
|
36
|
+
occurredAt: Date
|
|
37
|
+
runAfter: Date
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const COLUMNS = `id, recipient_user_key, action_id, node_key, node_name, actor_user_key, actor_name,
|
|
41
|
+
detail, occurred_at, state, attempts, last_error, run_after`
|
|
42
|
+
|
|
43
|
+
function hydrate(row: Record<string, unknown>): ContentNotification {
|
|
44
|
+
const text = (value: unknown) => (value === null || value === undefined ? null : String(value))
|
|
45
|
+
return {
|
|
46
|
+
id: String(row.id),
|
|
47
|
+
recipientUserKey: String(row.recipient_user_key),
|
|
48
|
+
actionId: String(row.action_id),
|
|
49
|
+
nodeKey: String(row.node_key),
|
|
50
|
+
nodeName: String(row.node_name),
|
|
51
|
+
actorUserKey: text(row.actor_user_key),
|
|
52
|
+
actorName: text(row.actor_name),
|
|
53
|
+
detail: text(row.detail),
|
|
54
|
+
occurredAt: DbDate.fromDb(row.occurred_at) ?? new Date(0),
|
|
55
|
+
state: String(row.state) as ContentNotificationState,
|
|
56
|
+
attempts: Number(row.attempts),
|
|
57
|
+
lastError: text(row.last_error),
|
|
58
|
+
runAfter: DbDate.fromDb(row.run_after) ?? new Date(0),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class ContentNotificationRepository {
|
|
63
|
+
#db: Db
|
|
64
|
+
|
|
65
|
+
constructor(db: Db) {
|
|
66
|
+
this.#db = db
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async enqueue(rows: readonly NewContentNotification[]): Promise<void> {
|
|
70
|
+
if (rows.length === 0) return
|
|
71
|
+
const now = DbDate.toDb(new Date())
|
|
72
|
+
await this.#db.transaction(async (tx) => {
|
|
73
|
+
for (const row of rows)
|
|
74
|
+
await tx.exec(
|
|
75
|
+
`INSERT INTO content_notification (id, recipient_user_key, action_id, node_key, node_name,
|
|
76
|
+
actor_user_key, actor_name, detail, occurred_at, state, attempts, last_error, run_after,
|
|
77
|
+
create_date, update_date)
|
|
78
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, NULL, ?, ?, ?)`,
|
|
79
|
+
[
|
|
80
|
+
crypto.randomUUID(),
|
|
81
|
+
row.recipientUserKey,
|
|
82
|
+
row.actionId,
|
|
83
|
+
row.nodeKey,
|
|
84
|
+
row.nodeName,
|
|
85
|
+
row.actorUserKey,
|
|
86
|
+
row.actorName,
|
|
87
|
+
row.detail,
|
|
88
|
+
DbDate.toDb(row.occurredAt),
|
|
89
|
+
DbDate.toDb(row.runAfter),
|
|
90
|
+
now,
|
|
91
|
+
now,
|
|
92
|
+
],
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The people with something due to them: each one is a single e-mail. */
|
|
98
|
+
async dueRecipients(now = new Date(), limit = 25): Promise<string[]> {
|
|
99
|
+
const rows = await this.#db.query<{ recipient_user_key: string }>(
|
|
100
|
+
`SELECT recipient_user_key FROM content_notification
|
|
101
|
+
WHERE state = 'pending' AND run_after <= ?
|
|
102
|
+
GROUP BY recipient_user_key ORDER BY MIN(run_after) LIMIT ?`,
|
|
103
|
+
[DbDate.toDb(now), limit],
|
|
104
|
+
)
|
|
105
|
+
return rows.map((row) => String(row.recipient_user_key))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Takes everything due to one person, or nothing when another node got there first. */
|
|
109
|
+
async claimFor(recipientUserKey: string, now = new Date()): Promise<ContentNotification[]> {
|
|
110
|
+
const rows = await this.#db.query(
|
|
111
|
+
`UPDATE content_notification
|
|
112
|
+
SET state = 'running', attempts = attempts + 1, update_date = ?
|
|
113
|
+
WHERE recipient_user_key = ? AND state = 'pending' AND run_after <= ?
|
|
114
|
+
RETURNING ${COLUMNS}`,
|
|
115
|
+
[DbDate.toDb(now), recipientUserKey, DbDate.toDb(now)],
|
|
116
|
+
)
|
|
117
|
+
return rows.map(hydrate).sort((a, b) => a.occurredAt.getTime() - b.occurredAt.getTime())
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async #finish(
|
|
121
|
+
ids: readonly string[],
|
|
122
|
+
state: ContentNotificationState,
|
|
123
|
+
error: string | null,
|
|
124
|
+
runAfter?: Date,
|
|
125
|
+
) {
|
|
126
|
+
if (ids.length === 0) return
|
|
127
|
+
const marks = ids.map(() => '?').join(', ')
|
|
128
|
+
await this.#db.exec(
|
|
129
|
+
`UPDATE content_notification SET state = ?, last_error = ?, update_date = ?${runAfter ? ', run_after = ?' : ''}
|
|
130
|
+
WHERE id IN (${marks})`,
|
|
131
|
+
[
|
|
132
|
+
state,
|
|
133
|
+
error?.slice(0, 2000) ?? null,
|
|
134
|
+
DbDate.toDb(new Date()),
|
|
135
|
+
...(runAfter ? [DbDate.toDb(runAfter)] : []),
|
|
136
|
+
...ids,
|
|
137
|
+
],
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
succeed(ids: readonly string[]): Promise<void> {
|
|
142
|
+
return this.#finish(ids, 'done', null)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Not sent, and not to be: the reason is kept for whoever looks. */
|
|
146
|
+
skip(ids: readonly string[], reason: string): Promise<void> {
|
|
147
|
+
return this.#finish(ids, 'skipped', reason)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A failure: back in the queue at `retryAt`, or finished when there is none. */
|
|
151
|
+
fail(ids: readonly string[], error: string, retryAt?: Date): Promise<void> {
|
|
152
|
+
return this.#finish(ids, retryAt ? 'pending' : 'failed', error, retryAt)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Rows a node took and never finished — it died — go back in the queue. */
|
|
156
|
+
async releaseStale(olderThan: Date): Promise<number> {
|
|
157
|
+
const rows = await this.#db.query(
|
|
158
|
+
`UPDATE content_notification SET state = 'pending', update_date = ?
|
|
159
|
+
WHERE state = 'running' AND update_date < ? RETURNING id`,
|
|
160
|
+
[DbDate.toDb(new Date()), DbDate.toDb(olderThan)],
|
|
161
|
+
)
|
|
162
|
+
return rows.length
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async prune(before: Date): Promise<number> {
|
|
166
|
+
const rows = await this.#db.query(
|
|
167
|
+
`DELETE FROM content_notification WHERE state IN ('done', 'skipped') AND update_date < ? RETURNING id`,
|
|
168
|
+
[DbDate.toDb(before)],
|
|
169
|
+
)
|
|
170
|
+
return rows.length
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async forRecipient(recipientUserKey: string): Promise<ContentNotification[]> {
|
|
174
|
+
const rows = await this.#db.query(
|
|
175
|
+
`SELECT ${COLUMNS} FROM content_notification WHERE recipient_user_key = ? ORDER BY occurred_at`,
|
|
176
|
+
[recipientUserKey],
|
|
177
|
+
)
|
|
178
|
+
return rows.map(hydrate)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async all(): Promise<ContentNotification[]> {
|
|
182
|
+
const rows = await this.#db.query(
|
|
183
|
+
`SELECT ${COLUMNS} FROM content_notification ORDER BY occurred_at`,
|
|
184
|
+
)
|
|
185
|
+
return rows.map(hydrate)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -1071,10 +1071,14 @@ export class DocumentRepository {
|
|
|
1071
1071
|
).map((row) => Number(row.id))
|
|
1072
1072
|
const t = tx.dialect.boolValue(true)
|
|
1073
1073
|
const f = tx.dialect.boolValue(false)
|
|
1074
|
-
await tx.exec(
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1074
|
+
await tx.exec(
|
|
1075
|
+
'UPDATE document SET original_parent_id = ?, trashed_date = ? WHERE node_id = ?',
|
|
1076
|
+
[
|
|
1077
|
+
node.parentId === SystemNodes.Root ? null : node.parentId,
|
|
1078
|
+
DbDate.toDb(new Date()),
|
|
1079
|
+
node.id,
|
|
1080
|
+
],
|
|
1081
|
+
)
|
|
1078
1082
|
// The whole branch goes with it, and stays unpublished while trashed.
|
|
1079
1083
|
await tx.exec('UPDATE node SET trashed = ? WHERE id = ? OR path LIKE ?', [
|
|
1080
1084
|
t,
|
|
@@ -1162,9 +1166,10 @@ export class DocumentRepository {
|
|
|
1162
1166
|
node.id,
|
|
1163
1167
|
`${node.path},%`,
|
|
1164
1168
|
])
|
|
1165
|
-
await tx.exec(
|
|
1166
|
-
|
|
1167
|
-
|
|
1169
|
+
await tx.exec(
|
|
1170
|
+
'UPDATE document SET original_parent_id = NULL, trashed_date = NULL WHERE node_id = ?',
|
|
1171
|
+
[node.id],
|
|
1172
|
+
)
|
|
1168
1173
|
}
|
|
1169
1174
|
await repo.nodes.move(node.id, parentId)
|
|
1170
1175
|
await tx.exec('UPDATE node SET sort_order = ? WHERE id = ?', [
|
|
@@ -1313,7 +1318,10 @@ export class DocumentRepository {
|
|
|
1313
1318
|
await repo.#nextSortOrder(parentId, node.id),
|
|
1314
1319
|
node.id,
|
|
1315
1320
|
])
|
|
1316
|
-
await tx.exec(
|
|
1321
|
+
await tx.exec(
|
|
1322
|
+
'UPDATE document SET original_parent_id = NULL, trashed_date = NULL WHERE node_id = ?',
|
|
1323
|
+
[node.id],
|
|
1324
|
+
)
|
|
1317
1325
|
return 'restored' as const
|
|
1318
1326
|
}),
|
|
1319
1327
|
)
|
|
@@ -1643,6 +1651,21 @@ export class DocumentRepository {
|
|
|
1643
1651
|
return (await this.trashed(null, 0, Number.MAX_SAFE_INTEGER)).items
|
|
1644
1652
|
}
|
|
1645
1653
|
|
|
1654
|
+
/** When each item went into the recycle bin; absent for one trashed before that was recorded. */
|
|
1655
|
+
async trashedDates(nodeIds: readonly number[]): Promise<Map<number, Date>> {
|
|
1656
|
+
if (nodeIds.length === 0) return new Map()
|
|
1657
|
+
const rows = await this.#db.query<{ node_id: number; trashed_date: unknown }>(
|
|
1658
|
+
`SELECT node_id, trashed_date FROM document WHERE trashed_date IS NOT NULL AND node_id IN (${nodeIds.map(() => '?').join(', ')})`,
|
|
1659
|
+
[...nodeIds],
|
|
1660
|
+
)
|
|
1661
|
+
const out = new Map<number, Date>()
|
|
1662
|
+
for (const row of rows) {
|
|
1663
|
+
const date = DbDate.fromDb(row.trashed_date)
|
|
1664
|
+
if (date) out.set(Number(row.node_id), date)
|
|
1665
|
+
}
|
|
1666
|
+
return out
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1646
1669
|
async #nextSortOrder(parentId: number, excludingId: number): Promise<number> {
|
|
1647
1670
|
const rows = await this.#db.query<{ next: number | null }>(
|
|
1648
1671
|
'SELECT MAX(sort_order) AS next FROM node WHERE parent_id = ? AND node_object_type = ? AND id <> ?',
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Which actions on a node a user asked to be notified of (Umbraco's
|
|
3
|
-
* `umbracoUser2NodeNotify`).
|
|
4
|
-
*
|
|
3
|
+
* `umbracoUser2NodeNotify`). Delivered by e-mail through the content
|
|
4
|
+
* notification outbox (story 045); kept regardless while a site has no e-mail.
|
|
5
5
|
*/
|
|
6
6
|
import type { Db } from '../database.ts'
|
|
7
7
|
|
|
@@ -51,4 +51,18 @@ export class NotificationRepository {
|
|
|
51
51
|
)
|
|
52
52
|
})
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Who asked to hear of an action on any node of a path: a subscription on a
|
|
57
|
+
* page covers the pages under it, as Umbraco's does.
|
|
58
|
+
*/
|
|
59
|
+
async subscribersFor(actionId: string, pathNodeIds: readonly number[]): Promise<number[]> {
|
|
60
|
+
if (pathNodeIds.length === 0) return []
|
|
61
|
+
const rows = await this.#db.query<{ user_id: number }>(
|
|
62
|
+
`SELECT DISTINCT user_id FROM user_notification
|
|
63
|
+
WHERE action = ? AND node_id IN (${pathNodeIds.map(() => '?').join(', ')})`,
|
|
64
|
+
[actionId, ...pathNodeIds],
|
|
65
|
+
)
|
|
66
|
+
return rows.map((row) => Number(row.user_id))
|
|
67
|
+
}
|
|
54
68
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration 043 — content notifications delivered (story 045).
|
|
3
|
+
*
|
|
4
|
+
* `content_notification` is the outbox: one row per person per change to a
|
|
5
|
+
* page they asked to hear about, sent by a background job and claimed by one
|
|
6
|
+
* node. It holds names as they were, and no foreign keys, so deleting a page or
|
|
7
|
+
* a user afterwards cannot break a row. `user_notification` gains the index a
|
|
8
|
+
* lookup by page needs.
|
|
9
|
+
*/
|
|
10
|
+
import type { Db } from '../database.ts'
|
|
11
|
+
import type { Migration } from '../migrations.ts'
|
|
12
|
+
import { STATE_TRASHED_DATE } from './trashed-date.ts'
|
|
13
|
+
|
|
14
|
+
export const STATE_CONTENT_NOTIFICATIONS = '{5a7c2e19-0043-4d8f-b361-0f4e9a2c7d43}'
|
|
15
|
+
|
|
16
|
+
export const contentNotificationsMigration: Migration = {
|
|
17
|
+
from: STATE_TRASHED_DATE,
|
|
18
|
+
to: STATE_CONTENT_NOTIFICATIONS,
|
|
19
|
+
name: 'AddContentNotifications',
|
|
20
|
+
kind: 'expand',
|
|
21
|
+
release: '0.17.0',
|
|
22
|
+
async up(db: Db) {
|
|
23
|
+
const t = db.dialect.types
|
|
24
|
+
await db.exec(
|
|
25
|
+
`CREATE TABLE content_notification (
|
|
26
|
+
id ${t.varchar(64)} PRIMARY KEY,
|
|
27
|
+
recipient_user_key ${t.uuid} NOT NULL,
|
|
28
|
+
action_id ${t.varchar(64)} NOT NULL,
|
|
29
|
+
node_key ${t.uuid} NOT NULL,
|
|
30
|
+
node_name ${t.text} NOT NULL,
|
|
31
|
+
actor_user_key ${t.uuid},
|
|
32
|
+
actor_name ${t.text},
|
|
33
|
+
detail ${t.text},
|
|
34
|
+
occurred_at ${t.timestamp} NOT NULL,
|
|
35
|
+
state ${t.varchar(16)} NOT NULL,
|
|
36
|
+
attempts ${t.integer} NOT NULL,
|
|
37
|
+
last_error ${t.text},
|
|
38
|
+
run_after ${t.timestamp} NOT NULL,
|
|
39
|
+
create_date ${t.timestamp} NOT NULL,
|
|
40
|
+
update_date ${t.timestamp} NOT NULL
|
|
41
|
+
)`,
|
|
42
|
+
)
|
|
43
|
+
await db.exec(
|
|
44
|
+
'CREATE INDEX ix_content_notification_due ON content_notification (state, run_after)',
|
|
45
|
+
)
|
|
46
|
+
await db.exec(
|
|
47
|
+
'CREATE INDEX ix_content_notification_recipient ON content_notification (recipient_user_key, state)',
|
|
48
|
+
)
|
|
49
|
+
await db.exec(
|
|
50
|
+
'CREATE INDEX ix_user_notification_node_action ON user_notification (node_id, action)',
|
|
51
|
+
)
|
|
52
|
+
},
|
|
53
|
+
}
|
package/src/schema/plan.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { changeReportMigration } from './change-report.ts'
|
|
|
23
23
|
import { clientIdPrefixMigration } from './client-id-prefix.ts'
|
|
24
24
|
import { contentMigration } from './content.ts'
|
|
25
25
|
import { contentEditingMigration } from './content-editing.ts'
|
|
26
|
+
import { contentNotificationsMigration } from './content-notifications.ts'
|
|
26
27
|
import { createdPackagesMigration } from './created-packages.ts'
|
|
27
28
|
import { deliveryMemberTokensMigration } from './delivery-member-tokens.ts'
|
|
28
29
|
import { diagnosticsMigration } from './diagnostics.ts'
|
|
@@ -49,6 +50,7 @@ import { siteSearchMigration } from './site-search.ts'
|
|
|
49
50
|
import { transferAnswersMigration } from './transfer-answers.ts'
|
|
50
51
|
import { transferRunPackageMigration } from './transfer-run-package.ts'
|
|
51
52
|
import { transferRunsMigration } from './transfer-runs.ts'
|
|
53
|
+
import { trashedDateMigration } from './trashed-date.ts'
|
|
52
54
|
import { twoFactorMigration } from './two-factor.ts'
|
|
53
55
|
import { upgradeReportMigration } from './upgrade-report.ts'
|
|
54
56
|
import { usersLocalisationMigration } from './users-localisation.ts'
|
|
@@ -97,4 +99,6 @@ export const cromboloPlan = new MigrationPlan([
|
|
|
97
99
|
siteSearchMigration,
|
|
98
100
|
twoFactorMigration,
|
|
99
101
|
remoteMigration,
|
|
102
|
+
trashedDateMigration,
|
|
103
|
+
contentNotificationsMigration,
|
|
100
104
|
])
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration 042 — `document.trashed_date`: when an item went into the recycle
|
|
3
|
+
* bin, so `crombolo recycle-bin empty --older-than` can leave what was trashed
|
|
4
|
+
* recently (story 152). Items trashed before this migration have none, and are
|
|
5
|
+
* treated as of unknown age.
|
|
6
|
+
*/
|
|
7
|
+
import type { Db } from '../database.ts'
|
|
8
|
+
import type { Migration } from '../migrations.ts'
|
|
9
|
+
import { schemaOps } from '../schema-ops.ts'
|
|
10
|
+
import { STATE_REMOTE } from './remote.ts'
|
|
11
|
+
|
|
12
|
+
export const STATE_TRASHED_DATE = '{8d3f1a62-0042-4b7e-9c15-2e6a4f0b8d43}'
|
|
13
|
+
|
|
14
|
+
export const trashedDateMigration: Migration = {
|
|
15
|
+
from: STATE_REMOTE,
|
|
16
|
+
to: STATE_TRASHED_DATE,
|
|
17
|
+
name: 'AddTrashedDate',
|
|
18
|
+
kind: 'expand',
|
|
19
|
+
release: '0.16.0',
|
|
20
|
+
async up(db: Db) {
|
|
21
|
+
await schemaOps(db).addColumn('document', 'trashed_date', db.dialect.types.timestamp)
|
|
22
|
+
},
|
|
23
|
+
}
|
package/src/search-text.ts
CHANGED
|
@@ -60,17 +60,24 @@ const ENTITIES: Record<string, string> = {
|
|
|
60
60
|
nbsp: ' ',
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** Decodes numeric entities and the few named ones markup actually carries. */
|
|
64
|
+
export function decodeEntities(text: string): string {
|
|
65
|
+
return text.replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (entity, name: string) => {
|
|
66
|
+
if (!name.startsWith('#')) return ENTITIES[name.toLowerCase()] ?? entity
|
|
67
|
+
const code =
|
|
68
|
+
name[1] === 'x' || name[1] === 'X'
|
|
69
|
+
? Number.parseInt(name.slice(2), 16)
|
|
70
|
+
: Number(name.slice(1))
|
|
71
|
+
// Past the last code point is not a character; left as written.
|
|
72
|
+
return code <= 0x10ffff ? String.fromCodePoint(code) : entity
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
function stripMarkup(text: string): string {
|
|
64
77
|
if (!text.includes('<') && !text.includes('&')) return text
|
|
65
|
-
return
|
|
66
|
-
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
|
|
67
|
-
|
|
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
|
-
})
|
|
78
|
+
return decodeEntities(
|
|
79
|
+
text.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]*>/g, ' '),
|
|
80
|
+
)
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
/** Whether an element type's property is kept out of search: `searchable = false`. */
|