@crombolo/data 0.0.0-stage → 0.9.1
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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +31 -4
- package/src/change-report.ts +166 -0
- package/src/cluster.ts +108 -0
- package/src/database.ts +121 -0
- package/src/dialect.ts +128 -0
- package/src/index.ts +60 -0
- package/src/locks.ts +103 -0
- package/src/maintenance.ts +51 -0
- package/src/migrations.ts +431 -0
- package/src/node-ref.ts +107 -0
- package/src/pending.ts +25 -0
- package/src/repositories/bundles.ts +191 -0
- package/src/repositories/content-types.ts +831 -0
- package/src/repositories/data-types.ts +222 -0
- package/src/repositories/dictionary.ts +259 -0
- package/src/repositories/documents.ts +2389 -0
- package/src/repositories/domains.ts +108 -0
- package/src/repositories/folders.ts +120 -0
- package/src/repositories/form-entries.ts +314 -0
- package/src/repositories/form-workflows.ts +213 -0
- package/src/repositories/languages.ts +108 -0
- package/src/repositories/log-searches.ts +33 -0
- package/src/repositories/members.ts +427 -0
- package/src/repositories/nodes.ts +361 -0
- package/src/repositories/notifications.ts +54 -0
- package/src/repositories/public-access.ts +187 -0
- package/src/repositories/redirects.ts +274 -0
- package/src/repositories/references.ts +162 -0
- package/src/repositories/tags.ts +93 -0
- package/src/repositories/templates.ts +114 -0
- package/src/repositories/transfer-runs.ts +190 -0
- package/src/repositories/user-data.ts +90 -0
- package/src/repositories/users.ts +898 -0
- package/src/schema/assistant.ts +74 -0
- package/src/schema/built-in-content-types.ts +106 -0
- package/src/schema/built-in-media-types.ts +335 -0
- package/src/schema/built-in-member-types.ts +61 -0
- package/src/schema/bundle-components.ts +49 -0
- package/src/schema/bundles-rename.ts +32 -0
- package/src/schema/change-report.ts +46 -0
- package/src/schema/client-id-prefix.ts +43 -0
- package/src/schema/content-editing.ts +50 -0
- package/src/schema/content-seed.ts +586 -0
- package/src/schema/content.ts +263 -0
- package/src/schema/created-packages.ts +56 -0
- package/src/schema/elements.ts +62 -0
- package/src/schema/form-permission-prefix.ts +50 -0
- package/src/schema/form-permissions.ts +26 -0
- package/src/schema/form-workflows.ts +54 -0
- package/src/schema/forms.ts +63 -0
- package/src/schema/group-permissions.ts +20 -0
- package/src/schema/identity.ts +176 -0
- package/src/schema/log-viewer.ts +68 -0
- package/src/schema/member-property-type.ts +30 -0
- package/src/schema/members.ts +97 -0
- package/src/schema/plan.ts +73 -0
- package/src/schema/public-access.ts +56 -0
- package/src/schema/recycle-bin.ts +21 -0
- package/src/schema/redirects.ts +57 -0
- package/src/schema/rich-text-ui.ts +34 -0
- package/src/schema/seed.ts +298 -0
- package/src/schema/server-role.ts +21 -0
- package/src/schema/since-version.ts +26 -0
- package/src/schema/transfer-runs.ts +71 -0
- package/src/schema/upgrade-report.ts +43 -0
- package/src/schema/users-localisation.ts +85 -0
- package/src/schema/values.ts +108 -0
- package/src/schema-ops.ts +35 -0
- package/src/schema-state.ts +196 -0
- package/README.md +0 -3
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redirects: the routes a page used to answer on, and the rules a site declares.
|
|
3
|
+
*
|
|
4
|
+
* Tracking writes a rule per route a publish or a move retired; the site's own
|
|
5
|
+
* `redirects` config is synced into the same table so the backoffice can list
|
|
6
|
+
* everything in force, and those rows are marked `config` and refuse to be
|
|
7
|
+
* deleted through the API — the file owns them. A rule an administrator wrote in
|
|
8
|
+
* the backoffice is `manual`, and is the only kind this repository will let a
|
|
9
|
+
* bundle touch (`server/src/bundles.ts`).
|
|
10
|
+
*
|
|
11
|
+
* A rule is identified by what it matches (kind, pattern, hostname root and
|
|
12
|
+
* culture), so re-registering a route replaces where it points instead of
|
|
13
|
+
* stacking another row behind it, which is how Umbraco's "most recent wins"
|
|
14
|
+
* ends up behaving without the duplicates.
|
|
15
|
+
*/
|
|
16
|
+
import { normaliseUuid } from '@crombolo/core'
|
|
17
|
+
import type { Db } from '../database.ts'
|
|
18
|
+
import { DbDate } from '../dialect.ts'
|
|
19
|
+
|
|
20
|
+
export type RedirectMatchKind = 'exact' | 'prefix' | 'regex'
|
|
21
|
+
export type RedirectTargetKind = 'document' | 'path' | 'url'
|
|
22
|
+
/**
|
|
23
|
+
* Where a rule came from, and — because `ORDER` sorts on this column — its
|
|
24
|
+
* precedence: `config` before `manual` before `tracked`, which is alphabetical
|
|
25
|
+
* and so needs no CASE expression. That order is the intent, not a coincidence:
|
|
26
|
+
* a rule in the site's code outranks one an administrator typed, which outranks
|
|
27
|
+
* one a rename recorded by itself.
|
|
28
|
+
*/
|
|
29
|
+
export type RedirectSource = 'tracked' | 'config' | 'manual'
|
|
30
|
+
|
|
31
|
+
export interface RedirectRow {
|
|
32
|
+
key: string
|
|
33
|
+
source: RedirectSource
|
|
34
|
+
matchKind: RedirectMatchKind
|
|
35
|
+
pattern: string
|
|
36
|
+
rootKey: string | null
|
|
37
|
+
culture: string | null
|
|
38
|
+
targetKind: RedirectTargetKind
|
|
39
|
+
target: string
|
|
40
|
+
statusCode: number
|
|
41
|
+
sortOrder: number
|
|
42
|
+
createDate: Date
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A rule to store; `key` and `createDate` are assigned when it is new. */
|
|
46
|
+
export type RedirectInput = Omit<RedirectRow, 'key' | 'createDate'>
|
|
47
|
+
|
|
48
|
+
interface Row {
|
|
49
|
+
key: string
|
|
50
|
+
source: string
|
|
51
|
+
match_kind: string
|
|
52
|
+
pattern: string
|
|
53
|
+
root_key: string | null
|
|
54
|
+
culture: string | null
|
|
55
|
+
target_kind: string
|
|
56
|
+
target: string
|
|
57
|
+
status_code: number
|
|
58
|
+
sort_order: number
|
|
59
|
+
create_date: unknown
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const SELECT = `SELECT key, source, match_kind, pattern, root_key, culture,
|
|
63
|
+
target_kind, target, status_code, sort_order, create_date
|
|
64
|
+
FROM redirect_url`
|
|
65
|
+
|
|
66
|
+
/** By source — see `RedirectSource` — then by the rule's own order, newest first. */
|
|
67
|
+
const ORDER = 'ORDER BY source, sort_order, create_date DESC, id DESC'
|
|
68
|
+
|
|
69
|
+
const hydrate = (row: Row): RedirectRow => ({
|
|
70
|
+
key: normaliseUuid(String(row.key)),
|
|
71
|
+
source: String(row.source) as RedirectSource,
|
|
72
|
+
matchKind: String(row.match_kind) as RedirectMatchKind,
|
|
73
|
+
pattern: String(row.pattern),
|
|
74
|
+
rootKey: row.root_key === null ? null : String(row.root_key),
|
|
75
|
+
culture: row.culture === null ? null : String(row.culture),
|
|
76
|
+
targetKind: String(row.target_kind) as RedirectTargetKind,
|
|
77
|
+
target: String(row.target),
|
|
78
|
+
statusCode: Number(row.status_code),
|
|
79
|
+
sortOrder: Number(row.sort_order),
|
|
80
|
+
createDate: DbDate.fromDb(row.create_date) ?? new Date(0),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
/** A digest of everything a rule matches on; see migration 015. */
|
|
84
|
+
export function matchHash(input: {
|
|
85
|
+
source: RedirectSource
|
|
86
|
+
matchKind: RedirectMatchKind
|
|
87
|
+
pattern: string
|
|
88
|
+
rootKey: string | null
|
|
89
|
+
culture: string | null
|
|
90
|
+
}): string {
|
|
91
|
+
const parts = [
|
|
92
|
+
input.source,
|
|
93
|
+
input.matchKind,
|
|
94
|
+
input.pattern,
|
|
95
|
+
input.rootKey ?? '',
|
|
96
|
+
(input.culture ?? '').toLowerCase(),
|
|
97
|
+
]
|
|
98
|
+
return new Bun.CryptoHasher('sha256').update(parts.join('\u0000')).digest('hex')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class RedirectRepository {
|
|
102
|
+
#db: Db
|
|
103
|
+
|
|
104
|
+
constructor(db: Db) {
|
|
105
|
+
this.#db = db
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Every rule, in the order they are matched. */
|
|
109
|
+
async all(): Promise<RedirectRow[]> {
|
|
110
|
+
const rows = await this.#db.query<Row>(`${SELECT} ${ORDER}`)
|
|
111
|
+
return rows.map(hydrate)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async byKey(key: string): Promise<RedirectRow | undefined> {
|
|
115
|
+
const rows = await this.#db.query<Row>(`${SELECT} WHERE key = ?`, [normaliseUuid(key)])
|
|
116
|
+
return rows[0] ? hydrate(rows[0]) : undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A page of rules, newest first, optionally filtered on either URL. Umbraco's
|
|
121
|
+
* dashboard searches the original URL; matching the destination too costs
|
|
122
|
+
* nothing and is what someone auditing "where does this page get traffic
|
|
123
|
+
* from" actually wants.
|
|
124
|
+
*/
|
|
125
|
+
async list(options: { filter?: string; skip?: number; take?: number } = {}): Promise<{
|
|
126
|
+
total: number
|
|
127
|
+
items: RedirectRow[]
|
|
128
|
+
}> {
|
|
129
|
+
const filter = options.filter?.trim().toLowerCase()
|
|
130
|
+
const where = filter ? 'WHERE LOWER(pattern) LIKE ? OR LOWER(target) LIKE ?' : ''
|
|
131
|
+
const params = filter ? [`%${filter}%`, `%${filter}%`] : []
|
|
132
|
+
const counted = await this.#db.query<{ total: number }>(
|
|
133
|
+
`SELECT COUNT(*) AS total FROM redirect_url ${where}`,
|
|
134
|
+
params,
|
|
135
|
+
)
|
|
136
|
+
const rows = await this.#db.query<Row>(`${SELECT} ${where} ${ORDER} LIMIT ? OFFSET ?`, [
|
|
137
|
+
...params,
|
|
138
|
+
options.take ?? 100,
|
|
139
|
+
options.skip ?? 0,
|
|
140
|
+
])
|
|
141
|
+
return { total: Number(counted[0]?.total ?? 0), items: rows.map(hydrate) }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The rules pointing at one document, newest first. */
|
|
145
|
+
async byDocument(
|
|
146
|
+
documentKey: string,
|
|
147
|
+
options: { skip?: number; take?: number } = {},
|
|
148
|
+
): Promise<{ total: number; items: RedirectRow[] }> {
|
|
149
|
+
const target = normaliseUuid(documentKey)
|
|
150
|
+
const where = "WHERE target_kind = 'document' AND LOWER(target) = ?"
|
|
151
|
+
const counted = await this.#db.query<{ total: number }>(
|
|
152
|
+
`SELECT COUNT(*) AS total FROM redirect_url ${where}`,
|
|
153
|
+
[target],
|
|
154
|
+
)
|
|
155
|
+
const rows = await this.#db.query<Row>(`${SELECT} ${where} ${ORDER} LIMIT ? OFFSET ?`, [
|
|
156
|
+
target,
|
|
157
|
+
options.take ?? 100,
|
|
158
|
+
options.skip ?? 0,
|
|
159
|
+
])
|
|
160
|
+
return { total: Number(counted[0]?.total ?? 0), items: rows.map(hydrate) }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Stores a rule, replacing any existing one matching the same thing. The
|
|
165
|
+
* returned row carries the key, which is a fresh one only when the rule is new.
|
|
166
|
+
*/
|
|
167
|
+
async save(input: RedirectInput): Promise<RedirectRow> {
|
|
168
|
+
const hash = matchHash(input)
|
|
169
|
+
const now = new Date()
|
|
170
|
+
return this.#db.transaction(async (tx) => {
|
|
171
|
+
const existing = await tx.query<{ key: string }>(
|
|
172
|
+
'SELECT key FROM redirect_url WHERE match_hash = ?',
|
|
173
|
+
[hash],
|
|
174
|
+
)
|
|
175
|
+
if (existing[0]) {
|
|
176
|
+
const key = normaliseUuid(String(existing[0].key))
|
|
177
|
+
await tx.exec(
|
|
178
|
+
`UPDATE redirect_url
|
|
179
|
+
SET target_kind = ?, target = ?, status_code = ?, sort_order = ?, create_date = ?
|
|
180
|
+
WHERE key = ?`,
|
|
181
|
+
[
|
|
182
|
+
input.targetKind,
|
|
183
|
+
input.target,
|
|
184
|
+
input.statusCode,
|
|
185
|
+
input.sortOrder,
|
|
186
|
+
DbDate.toDb(now),
|
|
187
|
+
key,
|
|
188
|
+
],
|
|
189
|
+
)
|
|
190
|
+
return { ...input, key, createDate: now }
|
|
191
|
+
}
|
|
192
|
+
const key = crypto.randomUUID()
|
|
193
|
+
await tx.exec(
|
|
194
|
+
`INSERT INTO redirect_url
|
|
195
|
+
(key, source, match_kind, pattern, root_key, culture,
|
|
196
|
+
target_kind, target, status_code, sort_order, match_hash, create_date)
|
|
197
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
198
|
+
[
|
|
199
|
+
key,
|
|
200
|
+
input.source,
|
|
201
|
+
input.matchKind,
|
|
202
|
+
input.pattern,
|
|
203
|
+
input.rootKey,
|
|
204
|
+
input.culture,
|
|
205
|
+
input.targetKind,
|
|
206
|
+
input.target,
|
|
207
|
+
input.statusCode,
|
|
208
|
+
input.sortOrder,
|
|
209
|
+
hash,
|
|
210
|
+
DbDate.toDb(now),
|
|
211
|
+
],
|
|
212
|
+
)
|
|
213
|
+
return { ...input, key, createDate: now }
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Deletes one rule. `false` means there was nothing with that key. */
|
|
218
|
+
async delete(key: string): Promise<boolean> {
|
|
219
|
+
const found = await this.byKey(key)
|
|
220
|
+
if (!found) return false
|
|
221
|
+
await this.#db.exec('DELETE FROM redirect_url WHERE key = ?', [normaliseUuid(key)])
|
|
222
|
+
return true
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Drops any rule that matches `path` under `rootKey` and points at this
|
|
227
|
+
* document — the case where a page was renamed and then renamed back, which
|
|
228
|
+
* would otherwise leave a rule redirecting a live URL to itself.
|
|
229
|
+
*/
|
|
230
|
+
async removeSelfReferencing(
|
|
231
|
+
documentKey: string,
|
|
232
|
+
rootKey: string | null,
|
|
233
|
+
path: string,
|
|
234
|
+
): Promise<void> {
|
|
235
|
+
await this.#db.exec(
|
|
236
|
+
`DELETE FROM redirect_url
|
|
237
|
+
WHERE source = 'tracked' AND target_kind = 'document'
|
|
238
|
+
AND LOWER(target) = ? AND match_kind = 'exact' AND LOWER(pattern) = ?
|
|
239
|
+
AND ${rootKey === null ? 'root_key IS NULL' : 'root_key = ?'}`,
|
|
240
|
+
rootKey === null
|
|
241
|
+
? [normaliseUuid(documentKey), path.toLowerCase()]
|
|
242
|
+
: [normaliseUuid(documentKey), path.toLowerCase(), rootKey],
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Brings the `config` rows into line with the site's configuration: the ones
|
|
248
|
+
* it still declares are stored in order, and any left over from a previous
|
|
249
|
+
* boot are removed. Tracked rows are untouched.
|
|
250
|
+
*/
|
|
251
|
+
async syncConfigured(inputs: readonly Omit<RedirectInput, 'source' | 'sortOrder'>[]): Promise<{
|
|
252
|
+
stored: number
|
|
253
|
+
removed: number
|
|
254
|
+
}> {
|
|
255
|
+
const kept = new Set<string>()
|
|
256
|
+
let sortOrder = 0
|
|
257
|
+
for (const input of inputs) {
|
|
258
|
+
const rule: RedirectInput = { ...input, source: 'config', sortOrder: sortOrder++ }
|
|
259
|
+
kept.add(matchHash(rule))
|
|
260
|
+
await this.save(rule)
|
|
261
|
+
}
|
|
262
|
+
const stale = await this.#db.query<{ match_hash: string }>(
|
|
263
|
+
"SELECT match_hash FROM redirect_url WHERE source = 'config'",
|
|
264
|
+
)
|
|
265
|
+
let removed = 0
|
|
266
|
+
for (const row of stale) {
|
|
267
|
+
const hash = String(row.match_hash)
|
|
268
|
+
if (kept.has(hash)) continue
|
|
269
|
+
await this.#db.exec('DELETE FROM redirect_url WHERE match_hash = ?', [hash])
|
|
270
|
+
removed += 1
|
|
271
|
+
}
|
|
272
|
+
return { stored: kept.size, removed }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What refers to what, derived from the values themselves.
|
|
3
|
+
*
|
|
4
|
+
* Umbraco keeps a `umbracoRelation` row per reference, written when content is
|
|
5
|
+
* saved. Deriving the answer from the current property values instead cannot go
|
|
6
|
+
* stale — a relation table is only as good as the last save that maintained it —
|
|
7
|
+
* and needs no migration. The cost is a scan of `property_value`, which is
|
|
8
|
+
* acceptable for an editor asking "what breaks if I delete this?".
|
|
9
|
+
*
|
|
10
|
+
* A picker stores its target as `umb://document/<hex>` or a bare uuid, so both
|
|
11
|
+
* spellings are matched.
|
|
12
|
+
*/
|
|
13
|
+
import { normaliseUuid } from '@crombolo/core'
|
|
14
|
+
import type { Db } from '../database.ts'
|
|
15
|
+
|
|
16
|
+
export interface ReferencingNode {
|
|
17
|
+
key: string
|
|
18
|
+
name: string | null
|
|
19
|
+
objectType: string | null
|
|
20
|
+
published: boolean | null
|
|
21
|
+
contentTypeKey: string | null
|
|
22
|
+
contentTypeAlias: string | null
|
|
23
|
+
contentTypeIcon: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The two spellings a key appears in inside a stored value. */
|
|
27
|
+
function patterns(key: string): [string, string] {
|
|
28
|
+
const dashed = normaliseUuid(key)
|
|
29
|
+
return [`%${dashed}%`, `%${dashed.replaceAll('-', '')}%`]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class ReferenceRepository {
|
|
33
|
+
#db: Db
|
|
34
|
+
|
|
35
|
+
constructor(db: Db) {
|
|
36
|
+
this.#db = db
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Node ids whose current values mention any of `keys`, excluding `keys` themselves. */
|
|
40
|
+
async #referencingIds(keys: readonly string[]): Promise<Map<string, number[]>> {
|
|
41
|
+
const found = new Map<string, number[]>()
|
|
42
|
+
for (const key of keys) {
|
|
43
|
+
const [dashed, plain] = patterns(key)
|
|
44
|
+
const rows = await this.#db.query<{ id: number }>(
|
|
45
|
+
`SELECT DISTINCT pv.node_id AS id
|
|
46
|
+
FROM property_value pv
|
|
47
|
+
WHERE pv.is_current = ?
|
|
48
|
+
AND ( LOWER(pv.varchar_value) LIKE ? OR LOWER(pv.text_value) LIKE ?
|
|
49
|
+
OR LOWER(pv.varchar_value) LIKE ? OR LOWER(pv.text_value) LIKE ? )`,
|
|
50
|
+
[this.#db.dialect.boolValue(true), dashed, dashed, plain, plain],
|
|
51
|
+
)
|
|
52
|
+
found.set(
|
|
53
|
+
normaliseUuid(key),
|
|
54
|
+
rows.map((row) => Number(row.id)),
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
return found
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async #describe(ids: readonly number[]): Promise<ReferencingNode[]> {
|
|
61
|
+
if (ids.length === 0) return []
|
|
62
|
+
const holes = ids.map(() => '?').join(', ')
|
|
63
|
+
const rows = await this.#db.query<{
|
|
64
|
+
unique_id: string
|
|
65
|
+
text: string | null
|
|
66
|
+
node_object_type: string | null
|
|
67
|
+
published: unknown
|
|
68
|
+
type_key: string | null
|
|
69
|
+
type_alias: string | null
|
|
70
|
+
type_icon: string | null
|
|
71
|
+
}>(
|
|
72
|
+
`SELECT n.unique_id, n.text, n.node_object_type,
|
|
73
|
+
d.published AS published,
|
|
74
|
+
tn.unique_id AS type_key, ct.alias AS type_alias, ct.icon AS type_icon
|
|
75
|
+
FROM node n
|
|
76
|
+
LEFT JOIN content c ON c.node_id = n.id
|
|
77
|
+
LEFT JOIN content_type ct ON ct.node_id = c.content_type_id
|
|
78
|
+
LEFT JOIN node tn ON tn.id = ct.node_id
|
|
79
|
+
LEFT JOIN document d ON d.node_id = n.id
|
|
80
|
+
WHERE n.id IN (${holes})
|
|
81
|
+
ORDER BY n.text`,
|
|
82
|
+
[...ids],
|
|
83
|
+
)
|
|
84
|
+
return rows.map((row) => ({
|
|
85
|
+
key: String(row.unique_id),
|
|
86
|
+
name: row.text,
|
|
87
|
+
objectType: row.node_object_type,
|
|
88
|
+
published: row.published === null || row.published === undefined ? null : !!row.published,
|
|
89
|
+
contentTypeKey: row.type_key ? String(row.type_key) : null,
|
|
90
|
+
contentTypeAlias: row.type_alias,
|
|
91
|
+
contentTypeIcon: row.type_icon,
|
|
92
|
+
}))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** What refers to this one, newest name order, paged. */
|
|
96
|
+
async referencedBy(
|
|
97
|
+
key: string,
|
|
98
|
+
skip: number,
|
|
99
|
+
take: number,
|
|
100
|
+
): Promise<{ total: number; items: ReferencingNode[] }> {
|
|
101
|
+
const self = await this.#db.query<{ id: number }>('SELECT id FROM node WHERE unique_id = ?', [
|
|
102
|
+
normaliseUuid(key),
|
|
103
|
+
])
|
|
104
|
+
const selfId = self[0]?.id
|
|
105
|
+
const ids = (await this.#referencingIds([key])).get(normaliseUuid(key)) ?? []
|
|
106
|
+
const others = ids.filter((id) => id !== Number(selfId))
|
|
107
|
+
return { total: others.length, items: await this.#describe(others.slice(skip, skip + take)) }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Descendants of this node that something else refers to — what an editor
|
|
112
|
+
* needs before deleting a branch.
|
|
113
|
+
*/
|
|
114
|
+
async referencedDescendants(
|
|
115
|
+
key: string,
|
|
116
|
+
skip: number,
|
|
117
|
+
take: number,
|
|
118
|
+
): Promise<{ total: number; items: string[] }> {
|
|
119
|
+
const self = await this.#db.query<{ id: number; path: string }>(
|
|
120
|
+
'SELECT id, path FROM node WHERE unique_id = ?',
|
|
121
|
+
[normaliseUuid(key)],
|
|
122
|
+
)
|
|
123
|
+
const node = self[0]
|
|
124
|
+
if (!node) return { total: 0, items: [] }
|
|
125
|
+
const descendants = await this.#db.query<{ id: number; unique_id: string }>(
|
|
126
|
+
'SELECT id, unique_id FROM node WHERE path LIKE ? AND id <> ?',
|
|
127
|
+
[`${node.path},%`, node.id],
|
|
128
|
+
)
|
|
129
|
+
const referenced: string[] = []
|
|
130
|
+
for (const descendant of descendants) {
|
|
131
|
+
const ids = (await this.#referencingIds([String(descendant.unique_id)])).values().next()
|
|
132
|
+
.value as number[] | undefined
|
|
133
|
+
// A reference from inside the branch being deleted does not count.
|
|
134
|
+
const outside = (ids ?? []).filter(
|
|
135
|
+
(id) => !descendants.some((d) => Number(d.id) === id) && id !== Number(node.id),
|
|
136
|
+
)
|
|
137
|
+
if (outside.length > 0) referenced.push(String(descendant.unique_id))
|
|
138
|
+
}
|
|
139
|
+
return { total: referenced.length, items: referenced.slice(skip, skip + take) }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Of these keys, the ones something refers to. */
|
|
143
|
+
async areReferenced(
|
|
144
|
+
keys: readonly string[],
|
|
145
|
+
skip: number,
|
|
146
|
+
take: number,
|
|
147
|
+
): Promise<{ total: number; items: string[] }> {
|
|
148
|
+
const wanted = keys.map(normaliseUuid)
|
|
149
|
+
const found = await this.#referencingIds(wanted)
|
|
150
|
+
const selfIds = new Map<string, number>()
|
|
151
|
+
for (const key of wanted) {
|
|
152
|
+
const rows = await this.#db.query<{ id: number }>('SELECT id FROM node WHERE unique_id = ?', [
|
|
153
|
+
key,
|
|
154
|
+
])
|
|
155
|
+
if (rows[0]) selfIds.set(key, Number(rows[0].id))
|
|
156
|
+
}
|
|
157
|
+
const referenced = wanted.filter((key) =>
|
|
158
|
+
(found.get(key) ?? []).some((id) => id !== selfIds.get(key)),
|
|
159
|
+
)
|
|
160
|
+
return { total: referenced.length, items: referenced.slice(skip, skip + take) }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tags, read from the current values of Tags properties rather than kept in a
|
|
3
|
+
* table of their own: what the tags editor's suggestions and `GET /tag` list.
|
|
4
|
+
* A tag's group comes from its data type's configuration, as in Umbraco.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
import type { Db } from '../database.ts'
|
|
8
|
+
|
|
9
|
+
export interface TagRow {
|
|
10
|
+
id: string
|
|
11
|
+
text: string
|
|
12
|
+
group: string
|
|
13
|
+
culture: string | null
|
|
14
|
+
nodeCount: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A stable id for a tag: Umbraco's are rows; these are derived from what identifies one. */
|
|
18
|
+
function tagId(group: string, text: string, culture: string | null): string {
|
|
19
|
+
const hex = createHash('sha1')
|
|
20
|
+
.update(`${group}\u0000${text}\u0000${culture ?? ''}`)
|
|
21
|
+
.digest('hex')
|
|
22
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-${((Number.parseInt(hex.slice(16, 17), 16) & 0x3) | 0x8).toString(16)}${hex.slice(17, 20)}-${hex.slice(20, 32)}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseTags(raw: unknown): string[] {
|
|
26
|
+
if (typeof raw !== 'string' || raw.trim() === '') return []
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(raw)
|
|
29
|
+
if (Array.isArray(parsed)) return parsed.map(String)
|
|
30
|
+
} catch {
|
|
31
|
+
// CSV storage
|
|
32
|
+
}
|
|
33
|
+
return raw.split(',')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class TagRepository {
|
|
37
|
+
#db: Db
|
|
38
|
+
|
|
39
|
+
constructor(db: Db) {
|
|
40
|
+
this.#db = db
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async all(
|
|
44
|
+
filter: { query?: string; group?: string; culture?: string | null } = {},
|
|
45
|
+
): Promise<TagRow[]> {
|
|
46
|
+
const rows = await this.#db.query(
|
|
47
|
+
`SELECT pv.node_id, pv.text_value, pv.varchar_value, d.config, l.iso_code
|
|
48
|
+
FROM property_value pv
|
|
49
|
+
JOIN property_type p ON p.id = pv.property_type_id
|
|
50
|
+
JOIN data_type d ON d.node_id = p.data_type_id
|
|
51
|
+
JOIN node n ON n.id = pv.node_id
|
|
52
|
+
LEFT JOIN language l ON l.id = pv.language_id
|
|
53
|
+
WHERE pv.is_current = ? AND d.editor_alias = ? AND n.trashed = ?`,
|
|
54
|
+
[this.#db.dialect.boolValue(true), 'Umbraco.Tags', this.#db.dialect.boolValue(false)],
|
|
55
|
+
)
|
|
56
|
+
const tags = new Map<string, TagRow & { nodes: Set<number> }>()
|
|
57
|
+
for (const row of rows) {
|
|
58
|
+
let group = 'default'
|
|
59
|
+
try {
|
|
60
|
+
const config = JSON.parse(String(row.config ?? '{}')) as Record<string, unknown>
|
|
61
|
+
if (typeof config.group === 'string' && config.group) group = config.group
|
|
62
|
+
} catch {
|
|
63
|
+
// an unreadable configuration keeps the default group
|
|
64
|
+
}
|
|
65
|
+
const culture = (row.iso_code as string | null) ?? null
|
|
66
|
+
for (const raw of parseTags(row.text_value ?? row.varchar_value)) {
|
|
67
|
+
const text = raw.trim()
|
|
68
|
+
if (!text) continue
|
|
69
|
+
const id = tagId(group, text.toLowerCase(), culture)
|
|
70
|
+
const tag = tags.get(id) ?? {
|
|
71
|
+
id,
|
|
72
|
+
text,
|
|
73
|
+
group,
|
|
74
|
+
culture,
|
|
75
|
+
nodeCount: 0,
|
|
76
|
+
nodes: new Set<number>(),
|
|
77
|
+
}
|
|
78
|
+
tag.nodes.add(Number(row.node_id))
|
|
79
|
+
tag.nodeCount = tag.nodes.size
|
|
80
|
+
tags.set(id, tag)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const query = filter.query?.trim().toLowerCase()
|
|
84
|
+
return [...tags.values()]
|
|
85
|
+
.filter((t) => !query || t.text.toLowerCase().includes(query))
|
|
86
|
+
.filter((t) => !filter.group || t.group === filter.group)
|
|
87
|
+
.filter(
|
|
88
|
+
(t) => filter.culture === undefined || (t.culture ?? null) === (filter.culture ?? null),
|
|
89
|
+
)
|
|
90
|
+
.map(({ nodes: _nodes, ...tag }) => tag)
|
|
91
|
+
.sort((a, b) => a.text.localeCompare(b.text))
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Templates. The database row carries the alias and the tree placement; the view
|
|
3
|
+
* file lives on disk, and the file system is the source of truth for its content.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
type ComponentModel,
|
|
7
|
+
normaliseUuid,
|
|
8
|
+
ObjectTypes,
|
|
9
|
+
type Page,
|
|
10
|
+
SystemNodes,
|
|
11
|
+
} from '@crombolo/core'
|
|
12
|
+
import type { Db } from '../database.ts'
|
|
13
|
+
import { NodeRepository } from './nodes.ts'
|
|
14
|
+
|
|
15
|
+
export interface ComponentFileStore {
|
|
16
|
+
read(alias: string): Promise<string | undefined>
|
|
17
|
+
write(alias: string, content: string): Promise<void>
|
|
18
|
+
remove(alias: string): Promise<void>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class ComponentRepository {
|
|
22
|
+
#db: Db
|
|
23
|
+
#nodes: NodeRepository
|
|
24
|
+
#files: ComponentFileStore | undefined
|
|
25
|
+
|
|
26
|
+
constructor(db: Db, files?: ComponentFileStore) {
|
|
27
|
+
this.#db = db
|
|
28
|
+
this.#nodes = new NodeRepository(db)
|
|
29
|
+
this.#files = files
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get nodes(): NodeRepository {
|
|
33
|
+
return this.#nodes
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async byKey(key: string): Promise<ComponentModel | undefined> {
|
|
37
|
+
const rows = await this.#db.query(
|
|
38
|
+
`SELECT n.unique_id, n.text, t.alias FROM template t
|
|
39
|
+
JOIN node n ON n.id = t.node_id WHERE n.unique_id = ?`,
|
|
40
|
+
[normaliseUuid(key)],
|
|
41
|
+
)
|
|
42
|
+
const row = rows[0]
|
|
43
|
+
if (!row) return undefined
|
|
44
|
+
const alias = String(row.alias)
|
|
45
|
+
return {
|
|
46
|
+
key: normaliseUuid(String(row.unique_id)),
|
|
47
|
+
name: String(row.text ?? ''),
|
|
48
|
+
alias,
|
|
49
|
+
content: (await this.#files?.read(alias)) ?? null,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async byAlias(alias: string): Promise<ComponentModel | undefined> {
|
|
54
|
+
const rows = await this.#db.query<{ unique_id: string }>(
|
|
55
|
+
'SELECT n.unique_id FROM template t JOIN node n ON n.id = t.node_id WHERE t.alias = ?',
|
|
56
|
+
[alias],
|
|
57
|
+
)
|
|
58
|
+
return rows[0] ? this.byKey(String(rows[0].unique_id)) : undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async all(): Promise<ComponentModel[]> {
|
|
62
|
+
return (await this.list(0, 10_000)).items
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async list(skip: number, take: number): Promise<Page<ComponentModel>> {
|
|
66
|
+
const page = await this.#nodes.children(SystemNodes.Root, ObjectTypes.Template, skip, take)
|
|
67
|
+
const items: ComponentModel[] = []
|
|
68
|
+
for (const node of page.items) {
|
|
69
|
+
const template = await this.byKey(node.key)
|
|
70
|
+
if (template) items.push(template)
|
|
71
|
+
}
|
|
72
|
+
return { total: page.total, items }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async save(model: ComponentModel, userId?: number): Promise<ComponentModel> {
|
|
76
|
+
const saved = await this.#db.transaction(async (tx) => {
|
|
77
|
+
const repo = new ComponentRepository(tx, this.#files)
|
|
78
|
+
const existing = await repo.nodes.byKey(model.key)
|
|
79
|
+
if (existing) {
|
|
80
|
+
await repo.nodes.rename(existing.id, model.name)
|
|
81
|
+
await tx.exec('UPDATE template SET alias = ? WHERE node_id = ?', [model.alias, existing.id])
|
|
82
|
+
} else {
|
|
83
|
+
const node = await repo.nodes.create({
|
|
84
|
+
key: model.key,
|
|
85
|
+
parentId: SystemNodes.Root,
|
|
86
|
+
objectType: ObjectTypes.Template,
|
|
87
|
+
text: model.name,
|
|
88
|
+
userId,
|
|
89
|
+
})
|
|
90
|
+
await tx.exec('INSERT INTO template (node_id, alias) VALUES (?, ?)', [node.id, model.alias])
|
|
91
|
+
}
|
|
92
|
+
return model
|
|
93
|
+
})
|
|
94
|
+
if (model.content !== null && model.content !== undefined) {
|
|
95
|
+
await this.#files?.write(model.alias, model.content)
|
|
96
|
+
}
|
|
97
|
+
const result = await this.byKey(saved.key)
|
|
98
|
+
if (!result) throw new Error('Template could not be read back after saving.')
|
|
99
|
+
return result
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async delete(key: string): Promise<boolean> {
|
|
103
|
+
const template = await this.byKey(key)
|
|
104
|
+
const node = await this.#nodes.byKey(key)
|
|
105
|
+
if (!template || !node) return false
|
|
106
|
+
await this.#db.transaction(async (tx) => {
|
|
107
|
+
await tx.exec('DELETE FROM content_type_template WHERE template_node_id = ?', [node.id])
|
|
108
|
+
await tx.exec('DELETE FROM template WHERE node_id = ?', [node.id])
|
|
109
|
+
await tx.exec('DELETE FROM node WHERE id = ?', [node.id])
|
|
110
|
+
})
|
|
111
|
+
await this.#files?.remove(template.alias)
|
|
112
|
+
return true
|
|
113
|
+
}
|
|
114
|
+
}
|