@crombolo/data 0.0.0-stage → 0.10.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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +31 -4
- package/src/bundle-tables/builder.ts +264 -0
- package/src/bundle-tables/capability.ts +377 -0
- package/src/bundle-tables/chain.ts +105 -0
- package/src/bundle-tables/ddl.ts +124 -0
- package/src/bundle-tables/guard.ts +116 -0
- package/src/bundle-tables/index.ts +7 -0
- package/src/bundle-tables/names.ts +58 -0
- package/src/bundle-tables/schema.ts +485 -0
- package/src/change-report.ts +166 -0
- package/src/cluster.ts +108 -0
- package/src/database.ts +121 -0
- package/src/dialect.ts +136 -0
- package/src/index.ts +63 -0
- package/src/locks.ts +103 -0
- package/src/maintenance.ts +51 -0
- package/src/migrations.ts +547 -0
- package/src/node-ref.ts +107 -0
- package/src/pending.ts +25 -0
- package/src/repositories/bundles.ts +223 -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 +208 -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/bundle-package.ts +27 -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 +77 -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-run-package.ts +27 -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,2389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Documents, versions and publishing on the append-only value model.
|
|
3
|
+
*
|
|
4
|
+
* A value never changes in place: every change appends a `property_value` row
|
|
5
|
+
* tagged with the event that wrote it and the schema state the writer was at.
|
|
6
|
+
* `content_version` rows are events (save | publish | rollback | migrate).
|
|
7
|
+
* Publish is an event and a pointer, not a copy; rollback appends the old
|
|
8
|
+
* values under a new event. See docs/data-model.md.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
type DocumentAggregate,
|
|
12
|
+
type DocumentTreeItem,
|
|
13
|
+
type DocumentValidationError,
|
|
14
|
+
type DocumentValue,
|
|
15
|
+
type DocumentVariant,
|
|
16
|
+
type DocumentVersionSummary,
|
|
17
|
+
variesByCulture as flagVariesByCulture,
|
|
18
|
+
normaliseUuid,
|
|
19
|
+
ObjectTypes,
|
|
20
|
+
type Page,
|
|
21
|
+
STORAGE_COLUMN,
|
|
22
|
+
SystemNodes,
|
|
23
|
+
toEditorValue,
|
|
24
|
+
type ValueStorageTypeName,
|
|
25
|
+
type VariantState,
|
|
26
|
+
} from '@crombolo/core'
|
|
27
|
+
import { appendCacheInstruction } from '../cluster.ts'
|
|
28
|
+
import type { Db } from '../database.ts'
|
|
29
|
+
import { DbDate, fromDbBool } from '../dialect.ts'
|
|
30
|
+
import { Locks } from '../locks.ts'
|
|
31
|
+
import { isPending } from '../pending.ts'
|
|
32
|
+
import {
|
|
33
|
+
assertNodeMayWrite,
|
|
34
|
+
BASELINE_NODE_STATE,
|
|
35
|
+
type NodeSchemaState,
|
|
36
|
+
type SchemaStateRow,
|
|
37
|
+
visibleStateIdFor,
|
|
38
|
+
} from '../schema-state.ts'
|
|
39
|
+
import { NodeRepository, type NodeRow } from './nodes.ts'
|
|
40
|
+
|
|
41
|
+
export type EventKind = 'save' | 'publish' | 'rollback' | 'migrate'
|
|
42
|
+
|
|
43
|
+
interface PropertyTypeRow {
|
|
44
|
+
id: number
|
|
45
|
+
alias: string
|
|
46
|
+
name: string
|
|
47
|
+
variesByCulture: boolean
|
|
48
|
+
storage: ValueStorageTypeName
|
|
49
|
+
editorAlias: string
|
|
50
|
+
/** The data type's configuration, for converters that depend on it. */
|
|
51
|
+
config: Record<string, unknown>
|
|
52
|
+
mandatory: boolean
|
|
53
|
+
mandatoryMessage: string | null
|
|
54
|
+
regEx: string | null
|
|
55
|
+
regExMessage: string | null
|
|
56
|
+
/** Not yet live on this node: editable, never validated, never rendered. */
|
|
57
|
+
pending: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface LanguageRow {
|
|
61
|
+
id: number
|
|
62
|
+
isoCode: string
|
|
63
|
+
isDefault: boolean
|
|
64
|
+
isMandatory: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CurrentValue {
|
|
68
|
+
rowId: number
|
|
69
|
+
eventId: number
|
|
70
|
+
schemaStateId: number
|
|
71
|
+
alias: string
|
|
72
|
+
culture: string | null
|
|
73
|
+
segment: string | null
|
|
74
|
+
value: unknown
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface MigratedValue {
|
|
78
|
+
alias: string
|
|
79
|
+
culture: string | null
|
|
80
|
+
segment: string | null
|
|
81
|
+
value: unknown
|
|
82
|
+
/** The event to file the value under; defaults to the row it supersedes. */
|
|
83
|
+
eventId?: number
|
|
84
|
+
/** The storage column to write, when the value is in a new editor's format ahead of the cut-over. */
|
|
85
|
+
storage?: ValueStorageTypeName
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface ValueRow {
|
|
89
|
+
eventId: number
|
|
90
|
+
id: number
|
|
91
|
+
propertyTypeId: number
|
|
92
|
+
languageId: number | null
|
|
93
|
+
segment: string | null
|
|
94
|
+
raw: unknown
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface SaveDocumentInput {
|
|
98
|
+
key: string
|
|
99
|
+
contentTypeKey: string
|
|
100
|
+
componentKey: string | null
|
|
101
|
+
parentKey: string | null
|
|
102
|
+
values: DocumentValue[]
|
|
103
|
+
variants: Array<{ culture: string | null; segment: string | null; name: string }>
|
|
104
|
+
userId?: number
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A published document plus the values of its published version. */
|
|
108
|
+
export interface PublishedNodeRow {
|
|
109
|
+
id: number
|
|
110
|
+
key: string
|
|
111
|
+
parentId: number
|
|
112
|
+
level: number
|
|
113
|
+
path: string
|
|
114
|
+
sortOrder: number
|
|
115
|
+
name: string
|
|
116
|
+
contentTypeAlias: string
|
|
117
|
+
componentAlias: string | null
|
|
118
|
+
createDate: Date
|
|
119
|
+
updateDate: Date
|
|
120
|
+
cultures: string[]
|
|
121
|
+
/** Each published culture's name, as it was published. */
|
|
122
|
+
names: Record<string, string>
|
|
123
|
+
values: DocumentValue[]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* What a repository holds. Media, members and blueprints are stored exactly as
|
|
128
|
+
* documents are (node, content, versions, values); they differ in object type,
|
|
129
|
+
* recycle bin, and in never being published.
|
|
130
|
+
*/
|
|
131
|
+
export type ContentKind = 'document' | 'media' | 'member' | 'blueprint' | 'element'
|
|
132
|
+
|
|
133
|
+
const KINDS: Record<
|
|
134
|
+
ContentKind,
|
|
135
|
+
{ objectType: string; recycleBin: number | undefined; container: string | undefined }
|
|
136
|
+
> = {
|
|
137
|
+
document: {
|
|
138
|
+
objectType: ObjectTypes.Document,
|
|
139
|
+
recycleBin: SystemNodes.ContentRecycleBin,
|
|
140
|
+
container: undefined,
|
|
141
|
+
},
|
|
142
|
+
media: {
|
|
143
|
+
objectType: ObjectTypes.Media,
|
|
144
|
+
recycleBin: SystemNodes.MediaRecycleBin,
|
|
145
|
+
container: undefined,
|
|
146
|
+
},
|
|
147
|
+
// Members are a flat list at the tree root: no bin to trash them into, and
|
|
148
|
+
// no folders to gather them in.
|
|
149
|
+
member: {
|
|
150
|
+
objectType: ObjectTypes.Member,
|
|
151
|
+
recycleBin: undefined,
|
|
152
|
+
container: undefined,
|
|
153
|
+
},
|
|
154
|
+
blueprint: {
|
|
155
|
+
objectType: ObjectTypes.DocumentBlueprint,
|
|
156
|
+
recycleBin: undefined,
|
|
157
|
+
container: ObjectTypes.DocumentBlueprintContainer,
|
|
158
|
+
},
|
|
159
|
+
// Umbraco 18's Elements: publishable content with no URL, gathered in folders
|
|
160
|
+
// and trashed into a bin of its own at -22, as its Constants.System says.
|
|
161
|
+
element: {
|
|
162
|
+
objectType: ObjectTypes.Element,
|
|
163
|
+
recycleBin: SystemNodes.ElementRecycleBin,
|
|
164
|
+
container: ObjectTypes.ElementContainer,
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A step every save runs on its values before they are stored, knowing each
|
|
170
|
+
* property's editor: where uploaded files are placed and file facts filled in.
|
|
171
|
+
*/
|
|
172
|
+
export type ValueIntake = (
|
|
173
|
+
values: readonly DocumentValue[],
|
|
174
|
+
propertyTypes: ReadonlyArray<{ alias: string; editorAlias: string }>,
|
|
175
|
+
) => Promise<DocumentValue[]>
|
|
176
|
+
|
|
177
|
+
export interface DocumentRepositoryOptions {
|
|
178
|
+
/** The schema state this process runs at; writes are refused if the database is ahead. */
|
|
179
|
+
nodeState?: NodeSchemaState
|
|
180
|
+
/** Stamped on the cache instructions this repository appends. */
|
|
181
|
+
nodeId?: string
|
|
182
|
+
kind?: ContentKind
|
|
183
|
+
valueIntake?: ValueIntake
|
|
184
|
+
/**
|
|
185
|
+
* Set when the caller already holds the content-tree lock, so the writes
|
|
186
|
+
* below must not take it again.
|
|
187
|
+
*
|
|
188
|
+
* Neither lock manager survives re-entry: the SQLite one is a promise queue
|
|
189
|
+
* that would wait on itself for ever, and the Postgres one acquires inside
|
|
190
|
+
* its own `transaction`. Transactions *do* nest — both implementations join
|
|
191
|
+
* an enclosing one — so this flag is only ever about the lock.
|
|
192
|
+
*
|
|
193
|
+
* It exists for an operation that spans many documents and has to be atomic
|
|
194
|
+
* across all of them, like importing a content bundle: one lock and one
|
|
195
|
+
* transaction for the run, rather than one of each per node.
|
|
196
|
+
*/
|
|
197
|
+
inTreeLock?: boolean
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Thrown when a publish is refused by a business rule rather than failing. */
|
|
201
|
+
export class PublishBlockedError extends Error {}
|
|
202
|
+
|
|
203
|
+
const isEmpty = (value: unknown) =>
|
|
204
|
+
value === null || value === undefined || (typeof value === 'string' && value.trim() === '')
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Mandatory and pattern rules over a set of values. Invariant properties are
|
|
208
|
+
* checked once; culture-varying ones once per culture given. Pending
|
|
209
|
+
* properties are never checked.
|
|
210
|
+
*/
|
|
211
|
+
function validateValues(
|
|
212
|
+
propertyTypes: readonly PropertyTypeRow[],
|
|
213
|
+
values: readonly DocumentValue[],
|
|
214
|
+
cultures: readonly string[],
|
|
215
|
+
): DocumentValidationError[] {
|
|
216
|
+
const errors: DocumentValidationError[] = []
|
|
217
|
+
for (const type of propertyTypes) {
|
|
218
|
+
if (type.pending) continue
|
|
219
|
+
const targets: Array<string | null> = type.variesByCulture ? [...cultures] : [null]
|
|
220
|
+
if (type.variesByCulture && targets.length === 0) continue
|
|
221
|
+
for (const culture of targets) {
|
|
222
|
+
const value = values.find(
|
|
223
|
+
(v) =>
|
|
224
|
+
v.alias === type.alias &&
|
|
225
|
+
(type.variesByCulture ? v.culture === culture : true) &&
|
|
226
|
+
(v.segment ?? null) === null,
|
|
227
|
+
)
|
|
228
|
+
const messages: string[] = []
|
|
229
|
+
if (type.mandatory && (!value || isEmpty(value.value)))
|
|
230
|
+
messages.push(type.mandatoryMessage || 'Value cannot be empty')
|
|
231
|
+
if (type.regEx && value && !isEmpty(value.value) && typeof value.value === 'string') {
|
|
232
|
+
let matches = true
|
|
233
|
+
try {
|
|
234
|
+
matches = new RegExp(type.regEx).test(value.value)
|
|
235
|
+
} catch {
|
|
236
|
+
matches = true
|
|
237
|
+
}
|
|
238
|
+
if (!matches)
|
|
239
|
+
messages.push(
|
|
240
|
+
type.regExMessage || 'Value is invalid, it does not match the correct pattern',
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
if (messages.length > 0) errors.push({ alias: type.alias, culture, segment: null, messages })
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return errors
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const keyOf = (propertyTypeId: number, languageId: number | null, segment: string | null) =>
|
|
250
|
+
`${propertyTypeId}|${languageId ?? ''}|${segment ?? ''}`
|
|
251
|
+
|
|
252
|
+
export class DocumentRepository {
|
|
253
|
+
#db: Db
|
|
254
|
+
#nodes: NodeRepository
|
|
255
|
+
#nodeState: NodeSchemaState
|
|
256
|
+
#nodeId: string | undefined
|
|
257
|
+
readonly kind: ContentKind
|
|
258
|
+
readonly objectType: string
|
|
259
|
+
#recycleBin: number | undefined
|
|
260
|
+
#intake: ValueIntake | undefined
|
|
261
|
+
#inTreeLock: boolean
|
|
262
|
+
|
|
263
|
+
constructor(db: Db, options: DocumentRepositoryOptions = {}) {
|
|
264
|
+
this.#db = db
|
|
265
|
+
this.#nodes = new NodeRepository(db)
|
|
266
|
+
this.#nodeState = options.nodeState ?? BASELINE_NODE_STATE
|
|
267
|
+
this.#nodeId = options.nodeId
|
|
268
|
+
this.kind = options.kind ?? 'document'
|
|
269
|
+
this.objectType = KINDS[this.kind].objectType
|
|
270
|
+
this.#recycleBin = KINDS[this.kind].recycleBin
|
|
271
|
+
this.#intake = options.valueIntake
|
|
272
|
+
this.#inTreeLock = options.inTreeLock ?? false
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
get nodes(): NodeRepository {
|
|
276
|
+
return this.#nodes
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The content-tree lock, unless the caller already holds it. Every write that
|
|
281
|
+
* depends on sibling order or on a branch not moving under it goes through
|
|
282
|
+
* here rather than taking the lock directly, so `inTreeLock` is honoured in
|
|
283
|
+
* one place instead of six.
|
|
284
|
+
*/
|
|
285
|
+
#withTree<T>(fn: () => Promise<T>): Promise<T> {
|
|
286
|
+
return this.#inTreeLock ? fn() : this.#db.locks.withLock(Locks.ContentTree, fn)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
#child(tx: Db): DocumentRepository {
|
|
290
|
+
return new DocumentRepository(tx, {
|
|
291
|
+
nodeState: this.#nodeState,
|
|
292
|
+
nodeId: this.#nodeId,
|
|
293
|
+
kind: this.kind,
|
|
294
|
+
valueIntake: this.#intake,
|
|
295
|
+
// A child runs inside whatever its parent took.
|
|
296
|
+
inTreeLock: true,
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---------------------------------------------------------------- reads
|
|
301
|
+
|
|
302
|
+
async byKey(key: string): Promise<DocumentAggregate | undefined> {
|
|
303
|
+
const node = await this.#nodes.byKey(key)
|
|
304
|
+
if (!node || node.objectType !== this.objectType) return undefined
|
|
305
|
+
return this.#load(node)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async load(node: NodeRow): Promise<DocumentAggregate | undefined> {
|
|
309
|
+
return this.#load(node)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async children(parentKey: string | null, skip: number, take: number): Promise<Page<NodeRow>> {
|
|
313
|
+
const parentId = parentKey ? (await this.#nodes.byKey(parentKey))?.id : SystemNodes.Root
|
|
314
|
+
if (parentId === undefined) return { total: 0, items: [] }
|
|
315
|
+
return this.#nodes.children(parentId, this.objectType, skip, take)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The recycle bin's tree: trashed documents, top level being what was trashed directly. */
|
|
319
|
+
async trashed(parentKey: string | null, skip: number, take: number): Promise<Page<NodeRow>> {
|
|
320
|
+
const parentId = parentKey ? (await this.#nodes.byKey(parentKey))?.id : this.#recycleBin
|
|
321
|
+
if (parentId === undefined) return { total: 0, items: [] }
|
|
322
|
+
return this.#nodes.children(parentId, this.objectType, skip, take, true)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
siblings(key: string, before: number, after: number) {
|
|
326
|
+
return this.#nodes.siblings(key, this.objectType, before, after)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async search(
|
|
330
|
+
query: string,
|
|
331
|
+
options: { trashed?: boolean; parentKey?: string | null; skip: number; take: number },
|
|
332
|
+
): Promise<Page<NodeRow>> {
|
|
333
|
+
const parentId = options.parentKey
|
|
334
|
+
? (await this.#nodes.byKey(options.parentKey))?.id
|
|
335
|
+
: undefined
|
|
336
|
+
if (options.parentKey && parentId === undefined) return { total: 0, items: [] }
|
|
337
|
+
return this.#nodes.search(this.objectType, query, { ...options, parentId })
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async versions(key: string): Promise<DocumentVersionSummary[]> {
|
|
341
|
+
const node = await this.#nodes.byKey(key)
|
|
342
|
+
if (!node) return []
|
|
343
|
+
const rows = await this.#db.query(
|
|
344
|
+
`SELECT cv.id, cv.kind, cv.version_date, cv.current, cv.prevent_cleanup, dv.published, u.key AS user_key
|
|
345
|
+
FROM content_version cv
|
|
346
|
+
JOIN document_version dv ON dv.id = cv.id
|
|
347
|
+
LEFT JOIN user_account u ON u.id = cv.user_id
|
|
348
|
+
WHERE cv.node_id = ?
|
|
349
|
+
ORDER BY cv.id DESC`,
|
|
350
|
+
[node.id],
|
|
351
|
+
)
|
|
352
|
+
return rows.map((row) => ({
|
|
353
|
+
id: String(row.id),
|
|
354
|
+
documentKey: node.key,
|
|
355
|
+
versionDate: DbDate.fromDb(row.version_date) ?? new Date(0),
|
|
356
|
+
isCurrentDraft: fromDbBool(row.current),
|
|
357
|
+
isCurrentPublished: fromDbBool(row.published),
|
|
358
|
+
preventCleanup: fromDbBool(row.prevent_cleanup),
|
|
359
|
+
userKey: row.user_key ? normaliseUuid(String(row.user_key)) : null,
|
|
360
|
+
culture: null,
|
|
361
|
+
kind: String(row.kind) as EventKind,
|
|
362
|
+
}))
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Every published document with the values of its published version, for the
|
|
367
|
+
* published cache. A node reads as-of its own schema state, so a conversion
|
|
368
|
+
* appended by a newer deployment is invisible to it.
|
|
369
|
+
*/
|
|
370
|
+
/** Every published node of this kind, for the published cache. Elements too. */
|
|
371
|
+
async loadPublished(asOfSchemaStateId?: number): Promise<PublishedNodeRow[]> {
|
|
372
|
+
const t = this.#db.dialect.boolValue(true)
|
|
373
|
+
const f = this.#db.dialect.boolValue(false)
|
|
374
|
+
const rows = await this.#db.query(
|
|
375
|
+
`SELECT n.id, n.unique_id, n.parent_id, n.level, n.path, n.sort_order, n.create_date,
|
|
376
|
+
cv.id AS event_id, cv.text AS name, cv.version_date,
|
|
377
|
+
ct.alias AS content_type_alias, c.content_type_id, tpl.alias AS template_alias
|
|
378
|
+
FROM node n
|
|
379
|
+
JOIN content c ON c.node_id = n.id
|
|
380
|
+
JOIN content_type ct ON ct.node_id = c.content_type_id
|
|
381
|
+
JOIN document d ON d.node_id = n.id
|
|
382
|
+
JOIN content_version cv ON cv.node_id = n.id
|
|
383
|
+
JOIN document_version dv ON dv.id = cv.id AND dv.published = ?
|
|
384
|
+
LEFT JOIN template tpl ON tpl.node_id = dv.template_id
|
|
385
|
+
WHERE d.published = ? AND n.trashed = ? AND n.node_object_type = ?
|
|
386
|
+
ORDER BY n.level, n.sort_order, n.id`,
|
|
387
|
+
[t, t, f, this.objectType],
|
|
388
|
+
)
|
|
389
|
+
const languages = await this.#languages()
|
|
390
|
+
const published: PublishedNodeRow[] = []
|
|
391
|
+
for (const row of rows) {
|
|
392
|
+
// A pending property is never rendered, even if a template asks for it.
|
|
393
|
+
const propertyTypes = (await this.#propertyTypes(Number(row.content_type_id))).filter(
|
|
394
|
+
(p) => !p.pending,
|
|
395
|
+
)
|
|
396
|
+
const cultures = await this.#publishedCultureEvents(Number(row.id))
|
|
397
|
+
const values = await this.#readPublishedValues(
|
|
398
|
+
Number(row.id),
|
|
399
|
+
propertyTypes,
|
|
400
|
+
languages,
|
|
401
|
+
Number(row.event_id),
|
|
402
|
+
cultures,
|
|
403
|
+
asOfSchemaStateId,
|
|
404
|
+
)
|
|
405
|
+
published.push({
|
|
406
|
+
id: Number(row.id),
|
|
407
|
+
key: normaliseUuid(String(row.unique_id)),
|
|
408
|
+
parentId: Number(row.parent_id),
|
|
409
|
+
level: Number(row.level),
|
|
410
|
+
path: String(row.path),
|
|
411
|
+
sortOrder: Number(row.sort_order),
|
|
412
|
+
name: String(row.name ?? ''),
|
|
413
|
+
contentTypeAlias: String(row.content_type_alias),
|
|
414
|
+
componentAlias: (row.template_alias as string | null) ?? null,
|
|
415
|
+
createDate: DbDate.fromDb(row.create_date) ?? new Date(),
|
|
416
|
+
updateDate: DbDate.fromDb(row.version_date) ?? new Date(),
|
|
417
|
+
cultures: cultures.map((c) => c.isoCode),
|
|
418
|
+
names: Object.fromEntries(cultures.map((c) => [c.isoCode, c.name])),
|
|
419
|
+
values,
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
return published
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// --------------------------------------------------------------- writes
|
|
426
|
+
|
|
427
|
+
/** Creates a document with its first save event. Under the content-tree lock so sibling sort orders cannot race. */
|
|
428
|
+
async create(input: SaveDocumentInput): Promise<DocumentAggregate> {
|
|
429
|
+
return this.#withTree(async () =>
|
|
430
|
+
this.#db.transaction(async (tx) => {
|
|
431
|
+
const repo = this.#child(tx)
|
|
432
|
+
const state = await assertNodeMayWrite(tx, this.#nodeState)
|
|
433
|
+
const contentTypeNode = await repo.nodes.byKey(input.contentTypeKey)
|
|
434
|
+
if (!contentTypeNode) throw new Error(`Unknown content type '${input.contentTypeKey}'.`)
|
|
435
|
+
|
|
436
|
+
const parentId = input.parentKey
|
|
437
|
+
? ((await repo.nodes.byKey(input.parentKey))?.id ?? SystemNodes.Root)
|
|
438
|
+
: SystemNodes.Root
|
|
439
|
+
const name = input.variants[0]?.name ?? ''
|
|
440
|
+
const node = await repo.nodes.create({
|
|
441
|
+
key: input.key,
|
|
442
|
+
parentId,
|
|
443
|
+
objectType: this.objectType,
|
|
444
|
+
text: name,
|
|
445
|
+
userId: input.userId,
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
await tx.exec('INSERT INTO content (node_id, content_type_id) VALUES (?, ?)', [
|
|
449
|
+
node.id,
|
|
450
|
+
contentTypeNode.id,
|
|
451
|
+
])
|
|
452
|
+
await tx.exec('INSERT INTO document (node_id, published, edited) VALUES (?, ?, ?)', [
|
|
453
|
+
node.id,
|
|
454
|
+
tx.dialect.boolValue(false),
|
|
455
|
+
tx.dialect.boolValue(true),
|
|
456
|
+
])
|
|
457
|
+
|
|
458
|
+
const templateId = input.componentKey
|
|
459
|
+
? ((await repo.nodes.byKey(input.componentKey))?.id ?? null)
|
|
460
|
+
: null
|
|
461
|
+
const eventId = await repo.#insertEvent(
|
|
462
|
+
node.id,
|
|
463
|
+
'save',
|
|
464
|
+
name,
|
|
465
|
+
input.userId,
|
|
466
|
+
templateId,
|
|
467
|
+
false,
|
|
468
|
+
)
|
|
469
|
+
await repo.#appendValues(node.id, eventId, state, contentTypeNode.id, input.values)
|
|
470
|
+
await repo.#writeCultureVariations(node.id, eventId, input.variants)
|
|
471
|
+
|
|
472
|
+
const created = await repo.#load({ ...node, text: name })
|
|
473
|
+
if (!created) throw new Error('Document could not be read back after creation.')
|
|
474
|
+
return created
|
|
475
|
+
}),
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Documents of these types (by content type node id), trashed ones excluded. */
|
|
480
|
+
async documentsOfTypes(contentTypeNodeIds: readonly number[]): Promise<
|
|
481
|
+
Array<{
|
|
482
|
+
key: string
|
|
483
|
+
nodeId: number
|
|
484
|
+
name: string
|
|
485
|
+
contentTypeNodeId: number
|
|
486
|
+
published: boolean
|
|
487
|
+
}>
|
|
488
|
+
> {
|
|
489
|
+
if (contentTypeNodeIds.length === 0) return []
|
|
490
|
+
const rows = await this.#db.query(
|
|
491
|
+
`SELECT n.id, n.unique_id, n.text, c.content_type_id, d.published FROM content c
|
|
492
|
+
JOIN node n ON n.id = c.node_id JOIN document d ON d.node_id = n.id
|
|
493
|
+
WHERE c.content_type_id IN (${contentTypeNodeIds.map(() => '?').join(', ')}) AND n.trashed = ?
|
|
494
|
+
ORDER BY n.path`,
|
|
495
|
+
[...contentTypeNodeIds, this.#db.dialect.boolValue(false)],
|
|
496
|
+
)
|
|
497
|
+
return rows.map((row) => ({
|
|
498
|
+
key: normaliseUuid(String(row.unique_id)),
|
|
499
|
+
nodeId: Number(row.id),
|
|
500
|
+
name: String(row.text ?? ''),
|
|
501
|
+
contentTypeNodeId: Number(row.content_type_id),
|
|
502
|
+
published: fromDbBool(row.published),
|
|
503
|
+
}))
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Current values of the named properties, retired ones included: the check
|
|
508
|
+
* and the fix read what the editor no longer shows. Each carries the state
|
|
509
|
+
* that wrote it and the event it belongs to.
|
|
510
|
+
*/
|
|
511
|
+
async currentValuesByAlias(nodeId: number, aliases: readonly string[]): Promise<CurrentValue[]> {
|
|
512
|
+
if (aliases.length === 0) return []
|
|
513
|
+
const languages = await this.#languages()
|
|
514
|
+
const isoById = new Map(languages.map((l) => [l.id, l.isoCode]))
|
|
515
|
+
const rows = await this.#db.query(
|
|
516
|
+
`SELECT pv.id, pv.event_id, pv.schema_state_id, pv.language_id, pv.segment, pv.int_value, pv.decimal_value,
|
|
517
|
+
pv.date_value, pv.varchar_value, pv.text_value, p.alias, d.db_type
|
|
518
|
+
FROM property_value pv
|
|
519
|
+
JOIN property_type p ON p.id = pv.property_type_id
|
|
520
|
+
JOIN data_type d ON d.node_id = p.data_type_id
|
|
521
|
+
WHERE pv.node_id = ? AND pv.is_current = ? AND p.alias IN (${aliases.map(() => '?').join(', ')})`,
|
|
522
|
+
[nodeId, this.#db.dialect.boolValue(true), ...aliases],
|
|
523
|
+
)
|
|
524
|
+
const out: CurrentValue[] = []
|
|
525
|
+
for (const row of rows) {
|
|
526
|
+
const storage = String(row.db_type) as ValueStorageTypeName
|
|
527
|
+
const value = this.#fromStorage(
|
|
528
|
+
(row as Record<string, unknown>)[STORAGE_COLUMN[storage]],
|
|
529
|
+
storage,
|
|
530
|
+
)
|
|
531
|
+
out.push({
|
|
532
|
+
rowId: Number(row.id),
|
|
533
|
+
eventId: Number(row.event_id),
|
|
534
|
+
schemaStateId: Number(row.schema_state_id),
|
|
535
|
+
alias: String(row.alias),
|
|
536
|
+
culture: row.language_id === null ? null : (isoById.get(Number(row.language_id)) ?? null),
|
|
537
|
+
segment: (row.segment as string | null) ?? null,
|
|
538
|
+
value,
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
return out
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Appends values under an explicit schema state — the upgrade tool's write.
|
|
546
|
+
* Each value joins the event of the row it converts (so a published version
|
|
547
|
+
* keeps reading as one), or the document's published event, or its head.
|
|
548
|
+
* Not gated: the tool runs from the newer artifact by definition.
|
|
549
|
+
*/
|
|
550
|
+
async appendUnderState(
|
|
551
|
+
key: string,
|
|
552
|
+
values: readonly MigratedValue[],
|
|
553
|
+
options: { schemaStateId: number; userId?: number },
|
|
554
|
+
): Promise<boolean> {
|
|
555
|
+
return this.#db.transaction(async (tx) => {
|
|
556
|
+
const repo = this.#child(tx)
|
|
557
|
+
const node = await repo.nodes.byKey(key)
|
|
558
|
+
if (!node) return false
|
|
559
|
+
const contentTypeNodeId = await repo.#contentTypeNodeId(node.id)
|
|
560
|
+
if (!contentTypeNodeId) return false
|
|
561
|
+
const propertyTypes = await repo.#propertyTypes(contentTypeNodeId)
|
|
562
|
+
const byAlias = new Map(propertyTypes.map((p) => [p.alias, p]))
|
|
563
|
+
const languages = await repo.#languages()
|
|
564
|
+
const idByIso = new Map(languages.map((l) => [l.isoCode.toLowerCase(), l.id]))
|
|
565
|
+
const current = new Map(
|
|
566
|
+
(await repo.#currentValueRows(node.id)).map((row) => [
|
|
567
|
+
keyOf(row.propertyTypeId, row.languageId, row.segment),
|
|
568
|
+
row,
|
|
569
|
+
]),
|
|
570
|
+
)
|
|
571
|
+
const head = await repo.#headEvent(node.id)
|
|
572
|
+
const published = await tx.query<{ id: number }>(
|
|
573
|
+
'SELECT cv.id FROM content_version cv JOIN document_version dv ON dv.id = cv.id AND dv.published = ? WHERE cv.node_id = ? ORDER BY cv.id DESC LIMIT 1',
|
|
574
|
+
[tx.dialect.boolValue(true), node.id],
|
|
575
|
+
)
|
|
576
|
+
const fallbackEvent = Number(published[0]?.id ?? head?.id ?? 0)
|
|
577
|
+
let written = false
|
|
578
|
+
for (const value of values) {
|
|
579
|
+
const propertyType = byAlias.get(value.alias)
|
|
580
|
+
if (!propertyType) continue
|
|
581
|
+
const culture = propertyType.variesByCulture ? value.culture : null
|
|
582
|
+
const languageId = culture ? (idByIso.get(culture.toLowerCase()) ?? null) : null
|
|
583
|
+
const existing = current.get(keyOf(propertyType.id, languageId, value.segment ?? null))
|
|
584
|
+
const eventId = value.eventId ?? existing?.eventId ?? fallbackEvent
|
|
585
|
+
await repo.#insertValue(
|
|
586
|
+
node.id,
|
|
587
|
+
eventId,
|
|
588
|
+
options.schemaStateId,
|
|
589
|
+
value.storage ? { ...propertyType, storage: value.storage } : propertyType,
|
|
590
|
+
languageId,
|
|
591
|
+
value.segment ?? null,
|
|
592
|
+
value.value,
|
|
593
|
+
existing?.id,
|
|
594
|
+
)
|
|
595
|
+
written = true
|
|
596
|
+
}
|
|
597
|
+
if (written) await repo.#recomputeEdited(node.id)
|
|
598
|
+
return written
|
|
599
|
+
})
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Saves the draft: one save event, and a new value version for each value that changed. */
|
|
603
|
+
async update(input: SaveDocumentInput): Promise<DocumentAggregate | undefined> {
|
|
604
|
+
return this.#db.transaction(async (tx) => {
|
|
605
|
+
const repo = this.#child(tx)
|
|
606
|
+
const state = await assertNodeMayWrite(tx, this.#nodeState)
|
|
607
|
+
const node = await repo.nodes.byKey(input.key)
|
|
608
|
+
if (!node) return undefined
|
|
609
|
+
const contentTypeNodeId = await repo.#contentTypeNodeId(node.id)
|
|
610
|
+
if (!contentTypeNodeId) return undefined
|
|
611
|
+
|
|
612
|
+
const name = input.variants[0]?.name ?? node.text ?? ''
|
|
613
|
+
await repo.nodes.rename(node.id, name)
|
|
614
|
+
const templateId = input.componentKey
|
|
615
|
+
? ((await repo.nodes.byKey(input.componentKey))?.id ?? null)
|
|
616
|
+
: null
|
|
617
|
+
const eventId = await repo.#insertEvent(
|
|
618
|
+
node.id,
|
|
619
|
+
'save',
|
|
620
|
+
name,
|
|
621
|
+
input.userId,
|
|
622
|
+
templateId,
|
|
623
|
+
false,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
await repo.#appendValues(node.id, eventId, state, contentTypeNodeId, input.values)
|
|
627
|
+
await repo.#writeCultureVariations(node.id, eventId, input.variants)
|
|
628
|
+
await repo.#recomputeEdited(node.id)
|
|
629
|
+
return repo.#load({ ...node, text: name })
|
|
630
|
+
})
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Publish is an event and a pointer; nothing is copied. For a document that
|
|
635
|
+
* varies by culture, each culture published points at this event, and the
|
|
636
|
+
* others keep pointing at the event they were last published with, so their
|
|
637
|
+
* newer drafts stay unpublished. Every mandatory language must end up
|
|
638
|
+
* published, as Umbraco requires.
|
|
639
|
+
*/
|
|
640
|
+
async publish(
|
|
641
|
+
key: string,
|
|
642
|
+
cultures: readonly string[] | null,
|
|
643
|
+
): Promise<DocumentAggregate | undefined> {
|
|
644
|
+
return this.#withTree(async () =>
|
|
645
|
+
this.#db.transaction(async (tx) => {
|
|
646
|
+
const repo = this.#child(tx)
|
|
647
|
+
await assertNodeMayWrite(tx, this.#nodeState)
|
|
648
|
+
const node = await repo.nodes.byKey(key)
|
|
649
|
+
if (!node) return undefined
|
|
650
|
+
// An unpublished ancestor makes a published descendant unreachable, so
|
|
651
|
+
// Umbraco refuses the publish rather than creating a dead route.
|
|
652
|
+
if (!(await repo.#ancestorsPublished(node)))
|
|
653
|
+
throw new PublishBlockedError('An ancestor is not published.')
|
|
654
|
+
|
|
655
|
+
const variant = await repo.#variesByCulture(node.id)
|
|
656
|
+
const available = variant ? await repo.#availableCultures(node.id) : []
|
|
657
|
+
const targets = variant
|
|
658
|
+
? cultures && cultures.length > 0
|
|
659
|
+
? [...cultures]
|
|
660
|
+
: available
|
|
661
|
+
: null
|
|
662
|
+
if (targets) {
|
|
663
|
+
const missing = targets.filter(
|
|
664
|
+
(c) => !available.some((a) => a.toLowerCase() === c.toLowerCase()),
|
|
665
|
+
)
|
|
666
|
+
if (missing.length > 0)
|
|
667
|
+
throw new PublishBlockedError(
|
|
668
|
+
`The document has no ${missing.join(', ')} variant to publish.`,
|
|
669
|
+
)
|
|
670
|
+
const published = await repo.#publishedCultures(node.id)
|
|
671
|
+
const settled = new Set([...targets, ...published].map((c) => c.toLowerCase()))
|
|
672
|
+
const mandatory = (await repo.#languages()).filter(
|
|
673
|
+
(l) => l.isMandatory && !settled.has(l.isoCode.toLowerCase()),
|
|
674
|
+
)
|
|
675
|
+
if (mandatory.length > 0)
|
|
676
|
+
throw new PublishBlockedError(
|
|
677
|
+
`The mandatory ${mandatory.map((l) => l.isoCode).join(', ')} must be published too.`,
|
|
678
|
+
)
|
|
679
|
+
}
|
|
680
|
+
await repo.#assertMandatoryFilled(node, targets)
|
|
681
|
+
|
|
682
|
+
const head = await repo.#headEvent(node.id)
|
|
683
|
+
const t = tx.dialect.boolValue(true)
|
|
684
|
+
const f = tx.dialect.boolValue(false)
|
|
685
|
+
await tx.exec(
|
|
686
|
+
'UPDATE document_version SET published = ? WHERE id IN (SELECT id FROM content_version WHERE node_id = ?)',
|
|
687
|
+
[f, node.id],
|
|
688
|
+
)
|
|
689
|
+
const eventId = await repo.#insertEvent(
|
|
690
|
+
node.id,
|
|
691
|
+
'publish',
|
|
692
|
+
head?.name ?? node.text ?? '',
|
|
693
|
+
head?.userId,
|
|
694
|
+
head?.templateId ?? null,
|
|
695
|
+
true,
|
|
696
|
+
)
|
|
697
|
+
await tx.exec('UPDATE document SET published = ? WHERE node_id = ?', [t, node.id])
|
|
698
|
+
for (const iso of targets ?? []) await repo.#setCulturePublished(node.id, iso, eventId)
|
|
699
|
+
await repo.#recomputeEdited(node.id)
|
|
700
|
+
await repo.#instructContent(node.key)
|
|
701
|
+
return repo.#load(node)
|
|
702
|
+
}),
|
|
703
|
+
)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Why publishing this document would be refused, without publishing it.
|
|
708
|
+
*
|
|
709
|
+
* `publish` throws on the first of these, which is right for one node and
|
|
710
|
+
* wrong for a branch: a caller publishing a selection asks this first, so the
|
|
711
|
+
* whole thing is refused rather than half of it landing. The checks and their
|
|
712
|
+
* order are `publish`'s own.
|
|
713
|
+
*/
|
|
714
|
+
async publishBlockers(
|
|
715
|
+
key: string,
|
|
716
|
+
cultures: readonly string[] | null,
|
|
717
|
+
/**
|
|
718
|
+
* Node ids this caller is publishing in the same run, parents first.
|
|
719
|
+
* Without it, publishing a branch reports every child as blocked by the
|
|
720
|
+
* root that the same command is about to publish.
|
|
721
|
+
*/
|
|
722
|
+
alsoPublishing: ReadonlySet<number> = new Set(),
|
|
723
|
+
): Promise<string[]> {
|
|
724
|
+
const node = await this.#nodes.byKey(key)
|
|
725
|
+
if (!node) return ['there is no such document here']
|
|
726
|
+
const reasons: string[] = []
|
|
727
|
+
if (!(await this.#ancestorsPublished(node, alsoPublishing)))
|
|
728
|
+
reasons.push('an ancestor is not published')
|
|
729
|
+
|
|
730
|
+
const variant = await this.#variesByCulture(node.id)
|
|
731
|
+
const available = variant ? await this.#availableCultures(node.id) : []
|
|
732
|
+
const targets = variant ? (cultures && cultures.length > 0 ? [...cultures] : available) : null
|
|
733
|
+
if (targets) {
|
|
734
|
+
const missing = targets.filter(
|
|
735
|
+
(c) => !available.some((a) => a.toLowerCase() === c.toLowerCase()),
|
|
736
|
+
)
|
|
737
|
+
if (missing.length > 0) reasons.push(`there is no ${missing.join(', ')} variant to publish`)
|
|
738
|
+
const published = await this.#publishedCultures(node.id)
|
|
739
|
+
const settled = new Set([...targets, ...published].map((c) => c.toLowerCase()))
|
|
740
|
+
const mandatory = (await this.#languages()).filter(
|
|
741
|
+
(l) => l.isMandatory && !settled.has(l.isoCode.toLowerCase()),
|
|
742
|
+
)
|
|
743
|
+
if (mandatory.length > 0)
|
|
744
|
+
reasons.push(
|
|
745
|
+
`the mandatory ${mandatory.map((l) => l.isoCode).join(', ')} must be published too`,
|
|
746
|
+
)
|
|
747
|
+
}
|
|
748
|
+
try {
|
|
749
|
+
await this.#assertMandatoryFilled(node, targets)
|
|
750
|
+
} catch (error) {
|
|
751
|
+
reasons.push((error as Error).message)
|
|
752
|
+
}
|
|
753
|
+
return reasons
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Unpublishes the document, or only the cultures named. Taking a mandatory
|
|
758
|
+
* culture offline takes the whole document offline, as in Umbraco.
|
|
759
|
+
*/
|
|
760
|
+
async unpublish(
|
|
761
|
+
key: string,
|
|
762
|
+
cultures: readonly string[] | null,
|
|
763
|
+
): Promise<DocumentAggregate | undefined> {
|
|
764
|
+
return this.#db.transaction(async (tx) => {
|
|
765
|
+
const repo = this.#child(tx)
|
|
766
|
+
await assertNodeMayWrite(tx, this.#nodeState)
|
|
767
|
+
const node = await repo.nodes.byKey(key)
|
|
768
|
+
if (!node) return undefined
|
|
769
|
+
|
|
770
|
+
const mandatory = new Set(
|
|
771
|
+
(await repo.#languages()).filter((l) => l.isMandatory).map((l) => l.isoCode.toLowerCase()),
|
|
772
|
+
)
|
|
773
|
+
const everything =
|
|
774
|
+
!cultures || cultures.length === 0 || cultures.some((c) => mandatory.has(c.toLowerCase()))
|
|
775
|
+
const isoCodes = everything ? await repo.#availableCultures(node.id) : cultures
|
|
776
|
+
for (const iso of isoCodes) await repo.#setCulturePublished(node.id, iso, null)
|
|
777
|
+
if (everything || !(await repo.#anyCulturePublished(node.id))) {
|
|
778
|
+
// Events survive an unpublish; only the flags change.
|
|
779
|
+
await tx.exec(
|
|
780
|
+
'UPDATE document_version SET published = ? WHERE id IN (SELECT id FROM content_version WHERE node_id = ?)',
|
|
781
|
+
[tx.dialect.boolValue(false), node.id],
|
|
782
|
+
)
|
|
783
|
+
await tx.exec('UPDATE document SET published = ?, edited = ? WHERE node_id = ?', [
|
|
784
|
+
tx.dialect.boolValue(false),
|
|
785
|
+
tx.dialect.boolValue(true),
|
|
786
|
+
node.id,
|
|
787
|
+
])
|
|
788
|
+
}
|
|
789
|
+
await repo.#instructContent(node.key)
|
|
790
|
+
return repo.#load(node)
|
|
791
|
+
})
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Where a node stands in its own history: its latest event, and the event it
|
|
796
|
+
* is serving. Together these are "the state this node was in", which is what
|
|
797
|
+
* a content import records before touching it so a revert can put it back.
|
|
798
|
+
*/
|
|
799
|
+
async eventMarkers(
|
|
800
|
+
key: string,
|
|
801
|
+
): Promise<{ head: number | undefined; published: number | undefined } | undefined> {
|
|
802
|
+
const node = await this.#nodes.byKey(key)
|
|
803
|
+
if (!node) return undefined
|
|
804
|
+
return {
|
|
805
|
+
head: (await this.#headEvent(node.id))?.id,
|
|
806
|
+
published: await this.#publishedEventId(node.id),
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** Rollback appends the target version's values under a new event. History is never rewritten. */
|
|
811
|
+
async rollback(versionId: string): Promise<DocumentAggregate | undefined> {
|
|
812
|
+
return this.#db.transaction(async (tx) => {
|
|
813
|
+
const repo = this.#child(tx)
|
|
814
|
+
const state = await assertNodeMayWrite(tx, this.#nodeState)
|
|
815
|
+
const targetId = Number(versionId)
|
|
816
|
+
const rows = await tx.query<{ node_id: number; text: string | null; user_id: number | null }>(
|
|
817
|
+
'SELECT node_id, text, user_id FROM content_version WHERE id = ?',
|
|
818
|
+
[targetId],
|
|
819
|
+
)
|
|
820
|
+
const target = rows[0]
|
|
821
|
+
if (!target) return undefined
|
|
822
|
+
const node = await repo.nodes.byId(Number(target.node_id))
|
|
823
|
+
if (!node) return undefined
|
|
824
|
+
const contentTypeNodeId = await repo.#contentTypeNodeId(node.id)
|
|
825
|
+
if (!contentTypeNodeId) return undefined
|
|
826
|
+
|
|
827
|
+
const templateRows = await tx.query<{ template_id: number | null }>(
|
|
828
|
+
'SELECT template_id FROM document_version WHERE id = ?',
|
|
829
|
+
[targetId],
|
|
830
|
+
)
|
|
831
|
+
const name = String(target.text ?? node.text ?? '')
|
|
832
|
+
const eventId = await repo.#insertEvent(
|
|
833
|
+
node.id,
|
|
834
|
+
'rollback',
|
|
835
|
+
name,
|
|
836
|
+
undefined,
|
|
837
|
+
templateRows[0]?.template_id ?? null,
|
|
838
|
+
false,
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
const snapshot = await repo.#valueRowsAsOf(node.id, { eventId: targetId })
|
|
842
|
+
const current = await repo.#currentValueRows(node.id)
|
|
843
|
+
await repo.#appendRows(node.id, eventId, state, snapshot, current)
|
|
844
|
+
await repo.#copyCultureVariations(targetId, eventId)
|
|
845
|
+
await repo.nodes.rename(node.id, name)
|
|
846
|
+
await repo.#recomputeEdited(node.id)
|
|
847
|
+
return repo.#load({ ...node, text: name })
|
|
848
|
+
})
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
async setPreventCleanup(versionId: string, prevent: boolean): Promise<boolean> {
|
|
852
|
+
const rows = await this.#db.query<{ id: number }>(
|
|
853
|
+
'SELECT id FROM content_version WHERE id = ?',
|
|
854
|
+
[Number(versionId)],
|
|
855
|
+
)
|
|
856
|
+
if (!rows[0]) return false
|
|
857
|
+
await this.#db.exec('UPDATE content_version SET prevent_cleanup = ? WHERE id = ?', [
|
|
858
|
+
this.#db.dialect.boolValue(prevent),
|
|
859
|
+
Number(versionId),
|
|
860
|
+
])
|
|
861
|
+
return true
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
async moveToRecycleBin(key: string): Promise<boolean> {
|
|
865
|
+
const node = await this.#nodes.byKey(key)
|
|
866
|
+
if (!node || this.#recycleBin === undefined) return false
|
|
867
|
+
return this.#withTree(async () => {
|
|
868
|
+
const t = this.#db.dialect.boolValue(true)
|
|
869
|
+
const f = this.#db.dialect.boolValue(false)
|
|
870
|
+
// The branch root remembers where it came from; the rest keeps its parent.
|
|
871
|
+
await this.#db.exec('UPDATE document SET original_parent_id = ? WHERE node_id = ?', [
|
|
872
|
+
node.parentId === SystemNodes.Root ? null : node.parentId,
|
|
873
|
+
node.id,
|
|
874
|
+
])
|
|
875
|
+
// The whole branch goes with it, and stays unpublished while trashed.
|
|
876
|
+
await this.#db.exec(
|
|
877
|
+
`UPDATE node SET trashed = ?, parent_id = CASE WHEN id = ? THEN ? ELSE parent_id END
|
|
878
|
+
WHERE id = ? OR path LIKE ?`,
|
|
879
|
+
[t, node.id, this.#recycleBin, node.id, `${node.path},%`],
|
|
880
|
+
)
|
|
881
|
+
await this.#db.exec(
|
|
882
|
+
`UPDATE document SET published = ?, edited = ? WHERE node_id IN (SELECT id FROM node WHERE id = ? OR path LIKE ?)`,
|
|
883
|
+
[f, t, node.id, `${node.path},%`],
|
|
884
|
+
)
|
|
885
|
+
return true
|
|
886
|
+
})
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async delete(key: string): Promise<boolean> {
|
|
890
|
+
const node = await this.#nodes.byKey(key)
|
|
891
|
+
if (!node) return false
|
|
892
|
+
return this.#db.transaction(async (tx) => {
|
|
893
|
+
const ids = await tx.query<{ id: number }>(
|
|
894
|
+
'SELECT id FROM node WHERE id = ? OR path LIKE ?',
|
|
895
|
+
[node.id, `${node.path},%`],
|
|
896
|
+
)
|
|
897
|
+
for (const row of ids.map((r) => Number(r.id)).reverse()) {
|
|
898
|
+
await tx.exec('DELETE FROM property_value WHERE node_id = ?', [row])
|
|
899
|
+
await tx.exec('DELETE FROM content_schedule WHERE node_id = ?', [row])
|
|
900
|
+
await tx.exec('DELETE FROM user_notification WHERE node_id = ?', [row])
|
|
901
|
+
await tx.exec('DELETE FROM domain WHERE node_id = ?', [row])
|
|
902
|
+
await tx.exec(
|
|
903
|
+
'DELETE FROM content_version_culture_variation WHERE version_id IN (SELECT id FROM content_version WHERE node_id = ?)',
|
|
904
|
+
[row],
|
|
905
|
+
)
|
|
906
|
+
await tx.exec('DELETE FROM document_culture_variation WHERE node_id = ?', [row])
|
|
907
|
+
await tx.exec(
|
|
908
|
+
'DELETE FROM document_version WHERE id IN (SELECT id FROM content_version WHERE node_id = ?)',
|
|
909
|
+
[row],
|
|
910
|
+
)
|
|
911
|
+
await tx.exec('DELETE FROM content_version WHERE node_id = ?', [row])
|
|
912
|
+
await tx.exec('DELETE FROM document WHERE node_id = ?', [row])
|
|
913
|
+
await tx.exec('DELETE FROM content WHERE node_id = ?', [row])
|
|
914
|
+
await tx.exec('DELETE FROM node WHERE id = ?', [row])
|
|
915
|
+
}
|
|
916
|
+
return true
|
|
917
|
+
})
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Re-parents a node and its branch under `targetKey` (null: the root), last
|
|
922
|
+
* among its new siblings. Refused when the target is the node or below it.
|
|
923
|
+
*/
|
|
924
|
+
async move(key: string, targetKey: string | null): Promise<'moved' | 'notFound' | 'invalid'> {
|
|
925
|
+
return this.#withTree(async () =>
|
|
926
|
+
this.#db.transaction(async (tx) => {
|
|
927
|
+
const repo = this.#child(tx)
|
|
928
|
+
await assertNodeMayWrite(tx, this.#nodeState)
|
|
929
|
+
const node = await repo.nodes.byKey(key)
|
|
930
|
+
if (!node || node.objectType !== this.objectType) return 'notFound'
|
|
931
|
+
const target = targetKey ? await repo.nodes.byKey(targetKey) : undefined
|
|
932
|
+
const container = KINDS[this.kind].container
|
|
933
|
+
if (
|
|
934
|
+
targetKey &&
|
|
935
|
+
(!target || (target.objectType !== this.objectType && target.objectType !== container))
|
|
936
|
+
)
|
|
937
|
+
return 'notFound'
|
|
938
|
+
if (target && (target.id === node.id || `${target.path},`.startsWith(`${node.path},`)))
|
|
939
|
+
return 'invalid'
|
|
940
|
+
if (target?.trashed) return 'invalid'
|
|
941
|
+
const parentId = target?.id ?? SystemNodes.Root
|
|
942
|
+
await repo.nodes.move(node.id, parentId)
|
|
943
|
+
await tx.exec('UPDATE node SET sort_order = ? WHERE id = ?', [
|
|
944
|
+
await repo.#nextSortOrder(parentId, node.id),
|
|
945
|
+
node.id,
|
|
946
|
+
])
|
|
947
|
+
await repo.#instructContent(node.key)
|
|
948
|
+
return 'moved'
|
|
949
|
+
}),
|
|
950
|
+
)
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Copies a node's current draft (and, optionally, its branch) under
|
|
955
|
+
* `targetKey`. Copies are unpublished and take a free name among their new
|
|
956
|
+
* siblings (`Home (1)`), as Umbraco names them.
|
|
957
|
+
*/
|
|
958
|
+
async copy(
|
|
959
|
+
key: string,
|
|
960
|
+
targetKey: string | null,
|
|
961
|
+
options: { includeDescendants: boolean; userId?: number },
|
|
962
|
+
): Promise<string | undefined> {
|
|
963
|
+
const source = await this.byKey(key)
|
|
964
|
+
if (!source) return undefined
|
|
965
|
+
if (targetKey) {
|
|
966
|
+
const target = await this.#nodes.byKey(targetKey)
|
|
967
|
+
if (!target || target.objectType !== this.objectType) return undefined
|
|
968
|
+
}
|
|
969
|
+
const copyOne = async (from: DocumentAggregate, parentKey: string | null): Promise<string> => {
|
|
970
|
+
const siblings = await this.#siblingNames(parentKey)
|
|
971
|
+
const variants = from.variants.map((variant) => ({
|
|
972
|
+
culture: variant.culture,
|
|
973
|
+
segment: variant.segment,
|
|
974
|
+
name: variant.culture === null ? uniqueName(variant.name, siblings) : variant.name,
|
|
975
|
+
}))
|
|
976
|
+
const created = await this.create({
|
|
977
|
+
key: crypto.randomUUID(),
|
|
978
|
+
contentTypeKey: from.contentTypeKey,
|
|
979
|
+
componentKey: from.componentKey,
|
|
980
|
+
parentKey,
|
|
981
|
+
values: from.values,
|
|
982
|
+
variants,
|
|
983
|
+
userId: options.userId,
|
|
984
|
+
})
|
|
985
|
+
return created.key
|
|
986
|
+
}
|
|
987
|
+
const copyBranch = async (from: DocumentAggregate, parentKey: string | null) => {
|
|
988
|
+
const newKey = await copyOne(from, parentKey)
|
|
989
|
+
if (!options.includeDescendants) return newKey
|
|
990
|
+
const children = await this.children(from.key, 0, Number.MAX_SAFE_INTEGER)
|
|
991
|
+
for (const child of children.items) {
|
|
992
|
+
const loaded = await this.#load(child)
|
|
993
|
+
if (loaded) await copyBranch(loaded, newKey)
|
|
994
|
+
}
|
|
995
|
+
return newKey
|
|
996
|
+
}
|
|
997
|
+
return copyBranch(source, targetKey)
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** Sets the sort order of the given children of `parentKey` (null: the root). */
|
|
1001
|
+
async sort(
|
|
1002
|
+
parentKey: string | null,
|
|
1003
|
+
sorting: ReadonlyArray<{ key: string; sortOrder: number }>,
|
|
1004
|
+
): Promise<'sorted' | 'notFound'> {
|
|
1005
|
+
const parentId = parentKey ? (await this.#nodes.byKey(parentKey))?.id : SystemNodes.Root
|
|
1006
|
+
if (parentId === undefined) return 'notFound'
|
|
1007
|
+
return this.#withTree(async () =>
|
|
1008
|
+
this.#db.transaction(async (tx) => {
|
|
1009
|
+
await assertNodeMayWrite(tx, this.#nodeState)
|
|
1010
|
+
const nodes = await new NodeRepository(tx).byKeys(sorting.map((s) => s.key))
|
|
1011
|
+
const byKey = new Map(nodes.map((n) => [n.key, n]))
|
|
1012
|
+
for (const item of sorting) {
|
|
1013
|
+
const node = byKey.get(normaliseUuid(item.key))
|
|
1014
|
+
if (!node || node.parentId !== parentId || node.objectType !== this.objectType)
|
|
1015
|
+
return 'notFound'
|
|
1016
|
+
}
|
|
1017
|
+
for (const item of sorting) {
|
|
1018
|
+
const node = byKey.get(normaliseUuid(item.key)) as NodeRow
|
|
1019
|
+
await tx.exec('UPDATE node SET sort_order = ? WHERE id = ?', [item.sortOrder, node.id])
|
|
1020
|
+
await this.#child(tx).#instructContent(node.key)
|
|
1021
|
+
}
|
|
1022
|
+
return 'sorted'
|
|
1023
|
+
}),
|
|
1024
|
+
)
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** Re-sorts every child of `parentKey` by name or date, as the "Sort children" dialog asks. */
|
|
1028
|
+
async sortChildrenBy(
|
|
1029
|
+
parentKey: string | null,
|
|
1030
|
+
field: 'Name' | 'CreateDate' | 'UpdateDate',
|
|
1031
|
+
direction: 'Ascending' | 'Descending',
|
|
1032
|
+
): Promise<'sorted' | 'notFound'> {
|
|
1033
|
+
const children = await this.children(parentKey, 0, Number.MAX_SAFE_INTEGER)
|
|
1034
|
+
if (parentKey && !(await this.#nodes.byKey(parentKey))) return 'notFound'
|
|
1035
|
+
const loaded = await Promise.all(
|
|
1036
|
+
children.items.map(async (node) => ({ node, aggregate: await this.#load(node) })),
|
|
1037
|
+
)
|
|
1038
|
+
const sortKey = (entry: (typeof loaded)[number]): string | number => {
|
|
1039
|
+
const variant = entry.aggregate?.variants[0]
|
|
1040
|
+
if (field === 'Name') return (variant?.name ?? entry.node.text ?? '').toLowerCase()
|
|
1041
|
+
if (field === 'CreateDate') return (entry.node.createDate ?? new Date(0)).getTime()
|
|
1042
|
+
return (variant?.updateDate ?? entry.node.createDate ?? new Date(0)).getTime()
|
|
1043
|
+
}
|
|
1044
|
+
loaded.sort((a, b) => {
|
|
1045
|
+
const x = sortKey(a)
|
|
1046
|
+
const y = sortKey(b)
|
|
1047
|
+
const order = x < y ? -1 : x > y ? 1 : a.node.id - b.node.id
|
|
1048
|
+
return direction === 'Ascending' ? order : -order
|
|
1049
|
+
})
|
|
1050
|
+
return this.sort(
|
|
1051
|
+
parentKey,
|
|
1052
|
+
loaded.map((entry, index) => ({ key: entry.node.key, sortOrder: index })),
|
|
1053
|
+
)
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Takes a branch out of the recycle bin to `targetKey`, or back where it was
|
|
1058
|
+
* when no target is given. Restored content is unpublished.
|
|
1059
|
+
*/
|
|
1060
|
+
async restore(
|
|
1061
|
+
key: string,
|
|
1062
|
+
targetKey: string | null | undefined,
|
|
1063
|
+
): Promise<'restored' | 'notFound' | 'invalid'> {
|
|
1064
|
+
const node = await this.#nodes.byKey(key)
|
|
1065
|
+
if (!node || node.objectType !== this.objectType || !node.trashed) return 'notFound'
|
|
1066
|
+
let parentKey: string | null
|
|
1067
|
+
if (targetKey !== undefined && targetKey !== null) parentKey = targetKey
|
|
1068
|
+
else if (targetKey === null) parentKey = null
|
|
1069
|
+
else parentKey = (await this.originalParent(key)) ?? null
|
|
1070
|
+
const parent = parentKey ? await this.#nodes.byKey(parentKey) : undefined
|
|
1071
|
+
if (parentKey && (!parent || parent.trashed)) return 'invalid'
|
|
1072
|
+
return this.#withTree(async () =>
|
|
1073
|
+
this.#db.transaction(async (tx) => {
|
|
1074
|
+
const repo = this.#child(tx)
|
|
1075
|
+
await assertNodeMayWrite(tx, this.#nodeState)
|
|
1076
|
+
const parentId = parent?.id ?? SystemNodes.Root
|
|
1077
|
+
await tx.exec('UPDATE node SET trashed = ? WHERE id = ? OR path LIKE ?', [
|
|
1078
|
+
tx.dialect.boolValue(false),
|
|
1079
|
+
node.id,
|
|
1080
|
+
`${node.path},%`,
|
|
1081
|
+
])
|
|
1082
|
+
await repo.nodes.move(node.id, parentId)
|
|
1083
|
+
await tx.exec('UPDATE node SET sort_order = ? WHERE id = ?', [
|
|
1084
|
+
await repo.#nextSortOrder(parentId, node.id),
|
|
1085
|
+
node.id,
|
|
1086
|
+
])
|
|
1087
|
+
await tx.exec('UPDATE document SET original_parent_id = NULL WHERE node_id = ?', [node.id])
|
|
1088
|
+
return 'restored' as const
|
|
1089
|
+
}),
|
|
1090
|
+
)
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Publishes a node, then its descendants shallowest first: those already
|
|
1095
|
+
* published, or every one when `includeUnpublished`. A descendant that cannot
|
|
1096
|
+
* publish (a missing mandatory value, an unpublished parent) is reported and
|
|
1097
|
+
* skipped, and so is its branch below.
|
|
1098
|
+
*/
|
|
1099
|
+
async publishBranch(
|
|
1100
|
+
key: string,
|
|
1101
|
+
cultures: readonly string[] | null,
|
|
1102
|
+
includeUnpublished: boolean,
|
|
1103
|
+
): Promise<{ published: string[]; failed: Array<{ key: string; reason: string }> } | undefined> {
|
|
1104
|
+
const root = await this.#nodes.byKey(key)
|
|
1105
|
+
if (!root || root.objectType !== this.objectType || root.trashed) return undefined
|
|
1106
|
+
const published: string[] = []
|
|
1107
|
+
const failed: Array<{ key: string; reason: string }> = []
|
|
1108
|
+
try {
|
|
1109
|
+
await this.publish(root.key, cultures)
|
|
1110
|
+
published.push(root.key)
|
|
1111
|
+
} catch (error) {
|
|
1112
|
+
if (!(error instanceof PublishBlockedError)) throw error
|
|
1113
|
+
return { published, failed: [{ key: root.key, reason: error.message }] }
|
|
1114
|
+
}
|
|
1115
|
+
const rows = await this.#db.query(
|
|
1116
|
+
`SELECT n.id, n.unique_id, n.path, d.published FROM node n JOIN document d ON d.node_id = n.id
|
|
1117
|
+
WHERE n.path LIKE ? AND n.node_object_type = ? AND n.trashed = ?
|
|
1118
|
+
ORDER BY n.level, n.sort_order, n.id`,
|
|
1119
|
+
[`${root.path},%`, this.objectType, this.#db.dialect.boolValue(false)],
|
|
1120
|
+
)
|
|
1121
|
+
const skipped: string[] = []
|
|
1122
|
+
for (const row of rows) {
|
|
1123
|
+
const path = String(row.path)
|
|
1124
|
+
if (skipped.some((prefix) => path.startsWith(`${prefix},`))) continue
|
|
1125
|
+
const nodeKey = normaliseUuid(String(row.unique_id))
|
|
1126
|
+
if (!includeUnpublished && !fromDbBool(row.published)) {
|
|
1127
|
+
skipped.push(path)
|
|
1128
|
+
continue
|
|
1129
|
+
}
|
|
1130
|
+
try {
|
|
1131
|
+
await this.publish(nodeKey, cultures)
|
|
1132
|
+
published.push(nodeKey)
|
|
1133
|
+
} catch (error) {
|
|
1134
|
+
if (!(error instanceof PublishBlockedError)) throw error
|
|
1135
|
+
failed.push({ key: nodeKey, reason: error.message })
|
|
1136
|
+
skipped.push(path)
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
return { published, failed }
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/** A document as it stood at one of its versions: that version's name and values. */
|
|
1143
|
+
async atVersion(
|
|
1144
|
+
versionId: string,
|
|
1145
|
+
): Promise<{ versionId: string; document: DocumentAggregate } | undefined> {
|
|
1146
|
+
const id = Number(versionId)
|
|
1147
|
+
if (!Number.isInteger(id)) return undefined
|
|
1148
|
+
const rows = await this.#db.query<{ node_id: number; text: string | null }>(
|
|
1149
|
+
'SELECT node_id, text FROM content_version WHERE id = ?',
|
|
1150
|
+
[id],
|
|
1151
|
+
)
|
|
1152
|
+
const row = rows[0]
|
|
1153
|
+
if (!row) return undefined
|
|
1154
|
+
const node = await this.#nodes.byId(Number(row.node_id))
|
|
1155
|
+
if (!node || node.objectType !== this.objectType) return undefined
|
|
1156
|
+
const current = await this.#load(node)
|
|
1157
|
+
const contentTypeNodeId = await this.#contentTypeNodeId(node.id)
|
|
1158
|
+
if (!current || !contentTypeNodeId) return undefined
|
|
1159
|
+
const propertyTypes = await this.#propertyTypes(contentTypeNodeId)
|
|
1160
|
+
const values = await this.#readValues(node.id, propertyTypes, await this.#languages(), {
|
|
1161
|
+
eventId: id,
|
|
1162
|
+
})
|
|
1163
|
+
const name = String(row.text ?? node.text ?? '')
|
|
1164
|
+
return {
|
|
1165
|
+
versionId: String(id),
|
|
1166
|
+
document: {
|
|
1167
|
+
...current,
|
|
1168
|
+
values,
|
|
1169
|
+
variants: current.variants.map((v) => (v.culture === null ? { ...v, name } : v)),
|
|
1170
|
+
},
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Replaces the scheduled publish and unpublish of each culture given (null:
|
|
1176
|
+
* the invariant variant). A time of null clears that half of the schedule.
|
|
1177
|
+
*/
|
|
1178
|
+
async setSchedule(
|
|
1179
|
+
key: string,
|
|
1180
|
+
entries: ReadonlyArray<{
|
|
1181
|
+
culture: string | null
|
|
1182
|
+
publishTime: Date | null
|
|
1183
|
+
unpublishTime: Date | null
|
|
1184
|
+
}>,
|
|
1185
|
+
): Promise<boolean> {
|
|
1186
|
+
const node = await this.#nodes.byKey(key)
|
|
1187
|
+
if (!node || node.objectType !== this.objectType) return false
|
|
1188
|
+
const languages = await this.#languages()
|
|
1189
|
+
await this.#db.transaction(async (tx) => {
|
|
1190
|
+
for (const entry of entries) {
|
|
1191
|
+
const languageId = entry.culture
|
|
1192
|
+
? (languages.find((l) => l.isoCode.toLowerCase() === entry.culture?.toLowerCase())?.id ??
|
|
1193
|
+
null)
|
|
1194
|
+
: null
|
|
1195
|
+
await tx.exec(
|
|
1196
|
+
`DELETE FROM content_schedule WHERE node_id = ? AND ${languageId === null ? 'language_id IS NULL' : 'language_id = ?'}`,
|
|
1197
|
+
languageId === null ? [node.id] : [node.id, languageId],
|
|
1198
|
+
)
|
|
1199
|
+
const add = async (action: 'Release' | 'Expire', date: Date | null) => {
|
|
1200
|
+
if (!date) return
|
|
1201
|
+
await tx.exec(
|
|
1202
|
+
'INSERT INTO content_schedule (node_id, language_id, action, date) VALUES (?, ?, ?, ?)',
|
|
1203
|
+
[node.id, languageId, action, DbDate.toDb(date)],
|
|
1204
|
+
)
|
|
1205
|
+
}
|
|
1206
|
+
await add('Release', entry.publishTime)
|
|
1207
|
+
await add('Expire', entry.unpublishTime)
|
|
1208
|
+
}
|
|
1209
|
+
})
|
|
1210
|
+
return true
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
async schedules(
|
|
1214
|
+
key: string,
|
|
1215
|
+
): Promise<
|
|
1216
|
+
Array<{ id: number; culture: string | null; action: 'Release' | 'Expire'; date: Date }>
|
|
1217
|
+
> {
|
|
1218
|
+
const node = await this.#nodes.byKey(key)
|
|
1219
|
+
if (!node) return []
|
|
1220
|
+
const rows = await this.#db.query(
|
|
1221
|
+
`SELECT s.id, s.action, s.date, l.iso_code FROM content_schedule s
|
|
1222
|
+
LEFT JOIN language l ON l.id = s.language_id WHERE s.node_id = ? ORDER BY s.date`,
|
|
1223
|
+
[node.id],
|
|
1224
|
+
)
|
|
1225
|
+
return rows.map((row) => ({
|
|
1226
|
+
id: Number(row.id),
|
|
1227
|
+
culture: (row.iso_code as string | null) ?? null,
|
|
1228
|
+
action: String(row.action) as 'Release' | 'Expire',
|
|
1229
|
+
date: DbDate.fromDb(row.date) ?? new Date(0),
|
|
1230
|
+
}))
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/** Schedules whose time has come, oldest first. */
|
|
1234
|
+
async dueSchedules(
|
|
1235
|
+
now: Date,
|
|
1236
|
+
): Promise<
|
|
1237
|
+
Array<{ id: number; key: string; culture: string | null; action: 'Release' | 'Expire' }>
|
|
1238
|
+
> {
|
|
1239
|
+
const rows = await this.#db.query(
|
|
1240
|
+
`SELECT s.id, s.action, n.unique_id, l.iso_code FROM content_schedule s
|
|
1241
|
+
JOIN node n ON n.id = s.node_id LEFT JOIN language l ON l.id = s.language_id
|
|
1242
|
+
WHERE s.date <= ? AND n.node_object_type = ? ORDER BY s.date, s.id`,
|
|
1243
|
+
[DbDate.toDb(now), this.objectType],
|
|
1244
|
+
)
|
|
1245
|
+
return rows.map((row) => ({
|
|
1246
|
+
id: Number(row.id),
|
|
1247
|
+
key: normaliseUuid(String(row.unique_id)),
|
|
1248
|
+
culture: (row.iso_code as string | null) ?? null,
|
|
1249
|
+
action: String(row.action) as 'Release' | 'Expire',
|
|
1250
|
+
}))
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/** Takes a due schedule for this process; false when another node already did. */
|
|
1254
|
+
async claimSchedule(id: number): Promise<boolean> {
|
|
1255
|
+
const rows = await this.#db.query('DELETE FROM content_schedule WHERE id = ? RETURNING id', [
|
|
1256
|
+
id,
|
|
1257
|
+
])
|
|
1258
|
+
return rows.length > 0
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* Prunes old versions under the cleanup policy: the content type's own, else
|
|
1263
|
+
* `global`. Kept always: the current draft, the published version, and any
|
|
1264
|
+
* version pinned with prevent-cleanup. Kept by age: everything newer than
|
|
1265
|
+
* keep-all days, then the latest version of each day for keep-latest days.
|
|
1266
|
+
*
|
|
1267
|
+
* Values are append-only, so a pruned version's value row may still be what a
|
|
1268
|
+
* kept version reads ("latest per key at or before it"). Such a row is
|
|
1269
|
+
* re-pointed at the earliest kept version after it rather than deleted, so
|
|
1270
|
+
* every kept version still reads exactly what it did.
|
|
1271
|
+
*/
|
|
1272
|
+
async cleanupVersions(
|
|
1273
|
+
now: Date,
|
|
1274
|
+
global: { keepAllVersionsNewerThanDays: number; keepLatestVersionPerDayForDays: number },
|
|
1275
|
+
): Promise<{ nodes: number; versionsDeleted: number }> {
|
|
1276
|
+
const types = await this.#db.query(
|
|
1277
|
+
`SELECT c.node_id, ct.prevent_cleanup, ct.keep_all_versions_newer_than_days AS keep_all,
|
|
1278
|
+
ct.keep_latest_version_per_day_for_days AS keep_latest
|
|
1279
|
+
FROM content c JOIN content_type ct ON ct.node_id = c.content_type_id
|
|
1280
|
+
JOIN node n ON n.id = c.node_id WHERE n.node_object_type = ?`,
|
|
1281
|
+
[this.objectType],
|
|
1282
|
+
)
|
|
1283
|
+
const day = 24 * 60 * 60 * 1000
|
|
1284
|
+
let nodes = 0
|
|
1285
|
+
let versionsDeleted = 0
|
|
1286
|
+
for (const type of types) {
|
|
1287
|
+
if (fromDbBool(type.prevent_cleanup)) continue
|
|
1288
|
+
const keepAll =
|
|
1289
|
+
type.keep_all === null ? global.keepAllVersionsNewerThanDays : Number(type.keep_all)
|
|
1290
|
+
const keepLatest =
|
|
1291
|
+
type.keep_latest === null ? global.keepLatestVersionPerDayForDays : Number(type.keep_latest)
|
|
1292
|
+
const deleted = await this.#db.transaction((tx) =>
|
|
1293
|
+
this.#child(tx).#cleanupNode(
|
|
1294
|
+
Number(type.node_id),
|
|
1295
|
+
now.getTime() - keepAll * day,
|
|
1296
|
+
now.getTime() - keepLatest * day,
|
|
1297
|
+
),
|
|
1298
|
+
)
|
|
1299
|
+
if (deleted > 0) {
|
|
1300
|
+
nodes += 1
|
|
1301
|
+
versionsDeleted += deleted
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
return { nodes, versionsDeleted }
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
async #cleanupNode(
|
|
1308
|
+
nodeId: number,
|
|
1309
|
+
keepAllAfter: number,
|
|
1310
|
+
keepLatestAfter: number,
|
|
1311
|
+
): Promise<number> {
|
|
1312
|
+
const events = (
|
|
1313
|
+
await this.#db.query(
|
|
1314
|
+
`SELECT cv.id, cv.version_date, cv.current, cv.prevent_cleanup, dv.published
|
|
1315
|
+
FROM content_version cv JOIN document_version dv ON dv.id = cv.id
|
|
1316
|
+
WHERE cv.node_id = ? ORDER BY cv.id`,
|
|
1317
|
+
[nodeId],
|
|
1318
|
+
)
|
|
1319
|
+
).map((row) => ({
|
|
1320
|
+
id: Number(row.id),
|
|
1321
|
+
date: (DbDate.fromDb(row.version_date) ?? new Date(0)).getTime(),
|
|
1322
|
+
pinned:
|
|
1323
|
+
fromDbBool(row.current) || fromDbBool(row.published) || fromDbBool(row.prevent_cleanup),
|
|
1324
|
+
}))
|
|
1325
|
+
const kept = new Set<number>()
|
|
1326
|
+
const latestPerDay = new Map<string, number>()
|
|
1327
|
+
for (const event of events) {
|
|
1328
|
+
if (event.pinned || event.date >= keepAllAfter) kept.add(event.id)
|
|
1329
|
+
else if (event.date >= keepLatestAfter) {
|
|
1330
|
+
// Ascending ids: the last one seen for a day is that day's latest.
|
|
1331
|
+
latestPerDay.set(new Date(event.date).toISOString().slice(0, 10), event.id)
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
for (const id of latestPerDay.values()) kept.add(id)
|
|
1335
|
+
const pruned = events.filter((e) => !kept.has(e.id)).map((e) => e.id)
|
|
1336
|
+
if (pruned.length === 0) return 0
|
|
1337
|
+
const keptAscending = [...kept].sort((a, b) => a - b)
|
|
1338
|
+
const prunedSet = new Set(pruned)
|
|
1339
|
+
|
|
1340
|
+
const rows = await this.#db.query<{
|
|
1341
|
+
id: number
|
|
1342
|
+
property_type_id: number
|
|
1343
|
+
language_id: number | null
|
|
1344
|
+
segment: string | null
|
|
1345
|
+
event_id: number
|
|
1346
|
+
}>(
|
|
1347
|
+
`SELECT id, property_type_id, language_id, segment, event_id FROM property_value
|
|
1348
|
+
WHERE node_id = ? ORDER BY event_id, id`,
|
|
1349
|
+
[nodeId],
|
|
1350
|
+
)
|
|
1351
|
+
// Per key, the distinct events that wrote it, ascending.
|
|
1352
|
+
const eventsByKey = new Map<string, number[]>()
|
|
1353
|
+
for (const row of rows) {
|
|
1354
|
+
const key = keyOf(
|
|
1355
|
+
Number(row.property_type_id),
|
|
1356
|
+
row.language_id === null ? null : Number(row.language_id),
|
|
1357
|
+
row.segment,
|
|
1358
|
+
)
|
|
1359
|
+
const list = eventsByKey.get(key) ?? []
|
|
1360
|
+
if (list.at(-1) !== Number(row.event_id)) list.push(Number(row.event_id))
|
|
1361
|
+
eventsByKey.set(key, list)
|
|
1362
|
+
}
|
|
1363
|
+
for (const row of rows) {
|
|
1364
|
+
const eventId = Number(row.event_id)
|
|
1365
|
+
if (!prunedSet.has(eventId)) continue
|
|
1366
|
+
const key = keyOf(
|
|
1367
|
+
Number(row.property_type_id),
|
|
1368
|
+
row.language_id === null ? null : Number(row.language_id),
|
|
1369
|
+
row.segment,
|
|
1370
|
+
)
|
|
1371
|
+
const written = eventsByKey.get(key) as number[]
|
|
1372
|
+
const next = written[written.indexOf(eventId) + 1] ?? Number.POSITIVE_INFINITY
|
|
1373
|
+
const reader = keptAscending.find((k) => k >= eventId)
|
|
1374
|
+
if (reader !== undefined && reader < next)
|
|
1375
|
+
await this.#db.exec('UPDATE property_value SET event_id = ? WHERE id = ?', [
|
|
1376
|
+
reader,
|
|
1377
|
+
Number(row.id),
|
|
1378
|
+
])
|
|
1379
|
+
else await this.#db.exec('DELETE FROM property_value WHERE id = ?', [Number(row.id)])
|
|
1380
|
+
}
|
|
1381
|
+
const marks = pruned.map(() => '?').join(', ')
|
|
1382
|
+
await this.#db.exec(
|
|
1383
|
+
`DELETE FROM content_version_culture_variation WHERE version_id IN (${marks})`,
|
|
1384
|
+
pruned,
|
|
1385
|
+
)
|
|
1386
|
+
await this.#db.exec(`DELETE FROM document_version WHERE id IN (${marks})`, pruned)
|
|
1387
|
+
await this.#db.exec(`DELETE FROM content_version WHERE id IN (${marks})`, pruned)
|
|
1388
|
+
return pruned.length
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/** Every top-level item in this kind's recycle bin, for emptying it. */
|
|
1392
|
+
async trashedRoots(): Promise<NodeRow[]> {
|
|
1393
|
+
return (await this.trashed(null, 0, Number.MAX_SAFE_INTEGER)).items
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
async #nextSortOrder(parentId: number, excludingId: number): Promise<number> {
|
|
1397
|
+
const rows = await this.#db.query<{ next: number | null }>(
|
|
1398
|
+
'SELECT MAX(sort_order) AS next FROM node WHERE parent_id = ? AND node_object_type = ? AND id <> ?',
|
|
1399
|
+
[parentId, this.objectType, excludingId],
|
|
1400
|
+
)
|
|
1401
|
+
const current = rows[0]?.next
|
|
1402
|
+
return current === null || current === undefined ? 0 : Number(current) + 1
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
async #siblingNames(parentKey: string | null): Promise<Set<string>> {
|
|
1406
|
+
const siblings = await this.children(parentKey, 0, Number.MAX_SAFE_INTEGER)
|
|
1407
|
+
return new Set(siblings.items.map((node) => (node.text ?? '').toLowerCase()))
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// ------------------------------------------------------------ internals
|
|
1411
|
+
|
|
1412
|
+
async #load(node: NodeRow): Promise<DocumentAggregate | undefined> {
|
|
1413
|
+
const t = this.#db.dialect.boolValue(true)
|
|
1414
|
+
const rows = await this.#db.query(
|
|
1415
|
+
`SELECT c.content_type_id, ct.alias AS content_type_alias, ct.icon AS content_type_icon,
|
|
1416
|
+
ct.list_view AS content_type_collection,
|
|
1417
|
+
ctn.unique_id AS content_type_key, d.published, d.edited,
|
|
1418
|
+
head.text AS draft_name, head.version_date AS draft_date,
|
|
1419
|
+
hv.template_id, tn.unique_id AS template_key,
|
|
1420
|
+
pub.version_date AS published_date
|
|
1421
|
+
FROM node n
|
|
1422
|
+
JOIN content c ON c.node_id = n.id
|
|
1423
|
+
JOIN content_type ct ON ct.node_id = c.content_type_id
|
|
1424
|
+
JOIN node ctn ON ctn.id = ct.node_id
|
|
1425
|
+
JOIN document d ON d.node_id = n.id
|
|
1426
|
+
JOIN content_version head ON head.node_id = n.id AND head.current = ?
|
|
1427
|
+
JOIN document_version hv ON hv.id = head.id
|
|
1428
|
+
LEFT JOIN node tn ON tn.id = hv.template_id
|
|
1429
|
+
LEFT JOIN (
|
|
1430
|
+
SELECT pc.node_id, pc.version_date FROM content_version pc
|
|
1431
|
+
JOIN document_version pd ON pd.id = pc.id AND pd.published = ?
|
|
1432
|
+
) pub ON pub.node_id = n.id
|
|
1433
|
+
WHERE n.id = ?`,
|
|
1434
|
+
[t, t, node.id],
|
|
1435
|
+
)
|
|
1436
|
+
const row = rows[0]
|
|
1437
|
+
if (!row) return undefined
|
|
1438
|
+
|
|
1439
|
+
const propertyTypes = await this.#propertyTypes(Number(row.content_type_id))
|
|
1440
|
+
const languages = await this.#languages()
|
|
1441
|
+
const values = await this.#readValues(node.id, propertyTypes, languages, {})
|
|
1442
|
+
const variants = await this.#readVariants(node, row)
|
|
1443
|
+
const parent = node.parentId > 0 ? await this.#nodes.byId(node.parentId) : undefined
|
|
1444
|
+
|
|
1445
|
+
return {
|
|
1446
|
+
key: node.key,
|
|
1447
|
+
contentTypeKey: normaliseUuid(String(row.content_type_key)),
|
|
1448
|
+
contentTypeAlias: String(row.content_type_alias),
|
|
1449
|
+
contentTypeIcon: String(row.content_type_icon ?? 'icon-document'),
|
|
1450
|
+
contentTypeCollectionKey: row.content_type_collection
|
|
1451
|
+
? normaliseUuid(String(row.content_type_collection))
|
|
1452
|
+
: null,
|
|
1453
|
+
componentKey: row.template_key ? normaliseUuid(String(row.template_key)) : null,
|
|
1454
|
+
parentKey: parent && parent.id !== SystemNodes.Root ? parent.key : null,
|
|
1455
|
+
sortOrder: node.sortOrder,
|
|
1456
|
+
isTrashed: node.trashed,
|
|
1457
|
+
published: fromDbBool(row.published),
|
|
1458
|
+
edited: fromDbBool(row.edited),
|
|
1459
|
+
values,
|
|
1460
|
+
variants,
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
async #contentTypeNodeId(nodeId: number): Promise<number | undefined> {
|
|
1465
|
+
const rows = await this.#db.query<{ content_type_id: number }>(
|
|
1466
|
+
'SELECT content_type_id FROM content WHERE node_id = ?',
|
|
1467
|
+
[nodeId],
|
|
1468
|
+
)
|
|
1469
|
+
return rows[0] ? Number(rows[0].content_type_id) : undefined
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
async #languages(): Promise<LanguageRow[]> {
|
|
1473
|
+
const rows = await this.#db.query('SELECT id, iso_code, is_default, is_mandatory FROM language')
|
|
1474
|
+
return rows.map((row) => ({
|
|
1475
|
+
id: Number(row.id),
|
|
1476
|
+
isoCode: String(row.iso_code),
|
|
1477
|
+
isDefault: fromDbBool(row.is_default),
|
|
1478
|
+
isMandatory: fromDbBool(row.is_mandatory),
|
|
1479
|
+
}))
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/** Property types of a content type, including everything it composes; retired ones excluded. */
|
|
1483
|
+
async #propertyTypes(contentTypeNodeId: number): Promise<PropertyTypeRow[]> {
|
|
1484
|
+
const rows = await this.#db.query(
|
|
1485
|
+
`WITH RECURSIVE ancestry(id) AS (
|
|
1486
|
+
SELECT ?
|
|
1487
|
+
UNION
|
|
1488
|
+
SELECT c.parent_content_type_id FROM content_type_composition c JOIN ancestry a ON a.id = c.child_content_type_id
|
|
1489
|
+
)
|
|
1490
|
+
SELECT p.id, p.alias, p.name, p.variations, p.mandatory, p.mandatory_message,
|
|
1491
|
+
p.validation_reg_exp, p.validation_reg_exp_message, d.db_type, d.editor_alias, d.config,
|
|
1492
|
+
p.since_version, ss.version AS since_v, ss.revision AS since_r
|
|
1493
|
+
FROM property_type p JOIN data_type d ON d.node_id = p.data_type_id
|
|
1494
|
+
LEFT JOIN schema_state ss ON ss.id = p.since_state_id
|
|
1495
|
+
WHERE p.content_type_id IN (SELECT id FROM ancestry) AND p.retired_at IS NULL
|
|
1496
|
+
ORDER BY p.sort_order, p.id`,
|
|
1497
|
+
[contentTypeNodeId],
|
|
1498
|
+
)
|
|
1499
|
+
return rows.map((row) => ({
|
|
1500
|
+
id: Number(row.id),
|
|
1501
|
+
alias: String(row.alias),
|
|
1502
|
+
name: String(row.name),
|
|
1503
|
+
variesByCulture: flagVariesByCulture(Number(row.variations)),
|
|
1504
|
+
storage: String(row.db_type) as ValueStorageTypeName,
|
|
1505
|
+
editorAlias: String(row.editor_alias),
|
|
1506
|
+
config: parseConfig(row.config),
|
|
1507
|
+
mandatory: fromDbBool(row.mandatory),
|
|
1508
|
+
mandatoryMessage: (row.mandatory_message as string | null) ?? null,
|
|
1509
|
+
regEx: (row.validation_reg_exp as string | null) ?? null,
|
|
1510
|
+
regExMessage: (row.validation_reg_exp_message as string | null) ?? null,
|
|
1511
|
+
pending: isPending(
|
|
1512
|
+
{
|
|
1513
|
+
since: row.since_v
|
|
1514
|
+
? { version: String(row.since_v), revision: String(row.since_r ?? '0') }
|
|
1515
|
+
: null,
|
|
1516
|
+
sinceVersion: (row.since_version as string | null) ?? null,
|
|
1517
|
+
},
|
|
1518
|
+
this.#nodeState,
|
|
1519
|
+
),
|
|
1520
|
+
}))
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* What a read on this node sees: the current rows, unless the database has
|
|
1525
|
+
* states newer than the node — then the latest row per key at the node's own
|
|
1526
|
+
* state, so an early conversion appended under a prepared state is invisible
|
|
1527
|
+
* to the editor on an old node as well as to its renderer.
|
|
1528
|
+
*/
|
|
1529
|
+
async #visibleValueRows(nodeId: number): Promise<ValueRow[]> {
|
|
1530
|
+
const stateId = await visibleStateIdFor(this.#db, this.#nodeState)
|
|
1531
|
+
return stateId === undefined
|
|
1532
|
+
? this.#currentValueRows(nodeId)
|
|
1533
|
+
: this.#valueRowsAsOf(nodeId, { schemaStateId: stateId })
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
async #currentValueRows(nodeId: number): Promise<ValueRow[]> {
|
|
1537
|
+
const rows = await this.#db.query(
|
|
1538
|
+
`SELECT id, event_id, property_type_id, language_id, segment, int_value, decimal_value, date_value, varchar_value, text_value
|
|
1539
|
+
FROM property_value WHERE node_id = ? AND is_current = ?`,
|
|
1540
|
+
[nodeId, this.#db.dialect.boolValue(true)],
|
|
1541
|
+
)
|
|
1542
|
+
return rows.map((row) => this.#toValueRow(row))
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/** Latest value per key at or before an event and/or a schema state. */
|
|
1546
|
+
async #valueRowsAsOf(
|
|
1547
|
+
nodeId: number,
|
|
1548
|
+
asOf: { eventId?: number; schemaStateId?: number },
|
|
1549
|
+
): Promise<ValueRow[]> {
|
|
1550
|
+
const conditions = ['node_id = ?']
|
|
1551
|
+
const params: unknown[] = [nodeId]
|
|
1552
|
+
if (asOf.eventId !== undefined) {
|
|
1553
|
+
conditions.push('event_id <= ?')
|
|
1554
|
+
params.push(asOf.eventId)
|
|
1555
|
+
}
|
|
1556
|
+
if (asOf.schemaStateId !== undefined) {
|
|
1557
|
+
conditions.push('schema_state_id <= ?')
|
|
1558
|
+
params.push(asOf.schemaStateId)
|
|
1559
|
+
}
|
|
1560
|
+
const rows = await this.#db.query(
|
|
1561
|
+
`SELECT pv.id, pv.event_id, pv.property_type_id, pv.language_id, pv.segment,
|
|
1562
|
+
pv.int_value, pv.decimal_value, pv.date_value, pv.varchar_value, pv.text_value
|
|
1563
|
+
FROM property_value pv
|
|
1564
|
+
JOIN (
|
|
1565
|
+
SELECT property_type_id, language_id, segment, MAX(id) AS id
|
|
1566
|
+
FROM property_value WHERE ${conditions.join(' AND ')}
|
|
1567
|
+
GROUP BY property_type_id, language_id, segment
|
|
1568
|
+
) latest ON latest.id = pv.id`,
|
|
1569
|
+
params,
|
|
1570
|
+
)
|
|
1571
|
+
return rows.map((row) => this.#toValueRow(row))
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
#toValueRow(row: Record<string, unknown>): ValueRow {
|
|
1575
|
+
return {
|
|
1576
|
+
id: Number(row.id),
|
|
1577
|
+
propertyTypeId: Number(row.property_type_id),
|
|
1578
|
+
languageId:
|
|
1579
|
+
row.language_id === null || row.language_id === undefined ? null : Number(row.language_id),
|
|
1580
|
+
segment: (row.segment as string | null) ?? null,
|
|
1581
|
+
eventId: Number(row.event_id),
|
|
1582
|
+
raw: row,
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
async #readValues(
|
|
1587
|
+
nodeId: number,
|
|
1588
|
+
propertyTypes: readonly PropertyTypeRow[],
|
|
1589
|
+
languages: readonly LanguageRow[],
|
|
1590
|
+
asOf: { eventId?: number; schemaStateId?: number },
|
|
1591
|
+
): Promise<DocumentValue[]> {
|
|
1592
|
+
if (propertyTypes.length === 0) return []
|
|
1593
|
+
const rows =
|
|
1594
|
+
asOf.eventId === undefined && asOf.schemaStateId === undefined
|
|
1595
|
+
? await this.#visibleValueRows(nodeId)
|
|
1596
|
+
: await this.#valueRowsAsOf(nodeId, asOf)
|
|
1597
|
+
return this.#valuesFromRows(rows, propertyTypes, languages)
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
#valuesFromRows(
|
|
1601
|
+
rows: readonly ValueRow[],
|
|
1602
|
+
propertyTypes: readonly PropertyTypeRow[],
|
|
1603
|
+
languages: readonly LanguageRow[],
|
|
1604
|
+
): DocumentValue[] {
|
|
1605
|
+
const byId = new Map(propertyTypes.map((p) => [p.id, p]))
|
|
1606
|
+
const isoById = new Map(languages.map((l) => [l.id, l.isoCode]))
|
|
1607
|
+
const values: DocumentValue[] = []
|
|
1608
|
+
for (const row of rows) {
|
|
1609
|
+
const propertyType = byId.get(row.propertyTypeId)
|
|
1610
|
+
if (!propertyType) continue
|
|
1611
|
+
const raw = (row.raw as Record<string, unknown>)[STORAGE_COLUMN[propertyType.storage]]
|
|
1612
|
+
const value = this.#fromStorage(raw, propertyType.storage)
|
|
1613
|
+
if (value === null) continue
|
|
1614
|
+
values.push({
|
|
1615
|
+
alias: propertyType.alias,
|
|
1616
|
+
culture: row.languageId === null ? null : (isoById.get(row.languageId) ?? null),
|
|
1617
|
+
segment: row.segment,
|
|
1618
|
+
value: toEditorValue(propertyType.editorAlias, value),
|
|
1619
|
+
editorAlias: propertyType.editorAlias,
|
|
1620
|
+
config: propertyType.config,
|
|
1621
|
+
})
|
|
1622
|
+
}
|
|
1623
|
+
return values
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
#fromStorage(raw: unknown, storage: ValueStorageTypeName): unknown {
|
|
1627
|
+
if (raw === null || raw === undefined) return null
|
|
1628
|
+
switch (storage) {
|
|
1629
|
+
case 'Integer':
|
|
1630
|
+
case 'Decimal':
|
|
1631
|
+
return Number(raw)
|
|
1632
|
+
case 'Date':
|
|
1633
|
+
return DbDate.fromDb(raw)?.toISOString() ?? null
|
|
1634
|
+
default:
|
|
1635
|
+
return raw
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
#toStorage(value: unknown, storage: ValueStorageTypeName): unknown {
|
|
1640
|
+
if (value === null || value === undefined) return null
|
|
1641
|
+
switch (storage) {
|
|
1642
|
+
case 'Integer':
|
|
1643
|
+
return typeof value === 'boolean' ? (value ? 1 : 0) : Number(value)
|
|
1644
|
+
case 'Decimal':
|
|
1645
|
+
return String(Number(value))
|
|
1646
|
+
case 'Date': {
|
|
1647
|
+
if (value instanceof Date) return DbDate.toDb(value)
|
|
1648
|
+
// The date pickers send wall-clock time with no zone ("2026-01-02 03:04:05");
|
|
1649
|
+
// it is kept as that wall-clock time, not shifted by the server's zone.
|
|
1650
|
+
const text = String(value).trim()
|
|
1651
|
+
const naive = /^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?$/.test(text)
|
|
1652
|
+
const date = new Date(
|
|
1653
|
+
naive ? `${text.replace(' ', 'T')}${text.length === 10 ? 'T00:00:00' : ''}Z` : text,
|
|
1654
|
+
)
|
|
1655
|
+
return Number.isNaN(date.getTime()) ? null : DbDate.toDb(date)
|
|
1656
|
+
}
|
|
1657
|
+
default:
|
|
1658
|
+
return typeof value === 'string' ? value : JSON.stringify(value)
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/** Whether a stored cell already holds an incoming value, so an unchanged value appends nothing. */
|
|
1663
|
+
#storedEquals(raw: unknown, incoming: unknown, storage: ValueStorageTypeName): boolean {
|
|
1664
|
+
const a = this.#fromStorage(raw, storage)
|
|
1665
|
+
const b = this.#fromStorage(this.#toStorage(incoming, storage), storage)
|
|
1666
|
+
if (a === null || b === null) return a === b
|
|
1667
|
+
return String(a) === String(b)
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
/**
|
|
1671
|
+
* Appends a value version for every incoming value that differs from the
|
|
1672
|
+
* current one, and a cleared version for every current value that is absent.
|
|
1673
|
+
*/
|
|
1674
|
+
async #appendValues(
|
|
1675
|
+
nodeId: number,
|
|
1676
|
+
eventId: number,
|
|
1677
|
+
state: SchemaStateRow,
|
|
1678
|
+
contentTypeNodeId: number,
|
|
1679
|
+
incoming: readonly DocumentValue[],
|
|
1680
|
+
): Promise<void> {
|
|
1681
|
+
const propertyTypes = await this.#propertyTypes(contentTypeNodeId)
|
|
1682
|
+
const values = this.#intake ? await this.#intake(incoming, propertyTypes) : incoming
|
|
1683
|
+
const byAlias = new Map(propertyTypes.map((p) => [p.alias, p]))
|
|
1684
|
+
const byId = new Map(propertyTypes.map((p) => [p.id, p]))
|
|
1685
|
+
const languages = await this.#languages()
|
|
1686
|
+
const idByIso = new Map(languages.map((l) => [l.isoCode.toLowerCase(), l.id]))
|
|
1687
|
+
// Equality is judged against what this node sees, so re-saving an unchanged
|
|
1688
|
+
// value never overwrites a conversion appended under a newer state; the
|
|
1689
|
+
// row superseded is the true current one.
|
|
1690
|
+
const current = new Map(
|
|
1691
|
+
(await this.#visibleValueRows(nodeId)).map((row) => [
|
|
1692
|
+
keyOf(row.propertyTypeId, row.languageId, row.segment),
|
|
1693
|
+
row,
|
|
1694
|
+
]),
|
|
1695
|
+
)
|
|
1696
|
+
const trueCurrent = new Map(
|
|
1697
|
+
(await this.#currentValueRows(nodeId)).map((row) => [
|
|
1698
|
+
keyOf(row.propertyTypeId, row.languageId, row.segment),
|
|
1699
|
+
row,
|
|
1700
|
+
]),
|
|
1701
|
+
)
|
|
1702
|
+
|
|
1703
|
+
const seen = new Set<string>()
|
|
1704
|
+
for (const value of values) {
|
|
1705
|
+
const propertyType = byAlias.get(value.alias)
|
|
1706
|
+
if (!propertyType) continue
|
|
1707
|
+
const culture = propertyType.variesByCulture ? value.culture : null
|
|
1708
|
+
const languageId = culture ? (idByIso.get(culture.toLowerCase()) ?? null) : null
|
|
1709
|
+
const key = keyOf(propertyType.id, languageId, value.segment ?? null)
|
|
1710
|
+
if (seen.has(key)) continue
|
|
1711
|
+
seen.add(key)
|
|
1712
|
+
const existing = current.get(key)
|
|
1713
|
+
const raw = existing
|
|
1714
|
+
? (existing.raw as Record<string, unknown>)[STORAGE_COLUMN[propertyType.storage]]
|
|
1715
|
+
: null
|
|
1716
|
+
if (existing && this.#storedEquals(raw, value.value, propertyType.storage)) continue
|
|
1717
|
+
if (!existing && (value.value === null || value.value === undefined)) continue
|
|
1718
|
+
await this.#insertValue(
|
|
1719
|
+
nodeId,
|
|
1720
|
+
eventId,
|
|
1721
|
+
state.id,
|
|
1722
|
+
propertyType,
|
|
1723
|
+
languageId,
|
|
1724
|
+
value.segment ?? null,
|
|
1725
|
+
value.value,
|
|
1726
|
+
trueCurrent.get(key)?.id ?? existing?.id,
|
|
1727
|
+
)
|
|
1728
|
+
}
|
|
1729
|
+
// A value that was current but is no longer sent is cleared, as a version.
|
|
1730
|
+
for (const [key, row] of current) {
|
|
1731
|
+
if (seen.has(key)) continue
|
|
1732
|
+
const propertyType = byId.get(row.propertyTypeId)
|
|
1733
|
+
if (!propertyType) continue
|
|
1734
|
+
const raw = (row.raw as Record<string, unknown>)[STORAGE_COLUMN[propertyType.storage]]
|
|
1735
|
+
if (raw === null || raw === undefined) continue
|
|
1736
|
+
await this.#insertValue(
|
|
1737
|
+
nodeId,
|
|
1738
|
+
eventId,
|
|
1739
|
+
state.id,
|
|
1740
|
+
propertyType,
|
|
1741
|
+
row.languageId,
|
|
1742
|
+
row.segment,
|
|
1743
|
+
null,
|
|
1744
|
+
trueCurrent.get(key)?.id ?? row.id,
|
|
1745
|
+
)
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
/** Makes `target` the current set: used by rollback. */
|
|
1750
|
+
async #appendRows(
|
|
1751
|
+
nodeId: number,
|
|
1752
|
+
eventId: number,
|
|
1753
|
+
state: SchemaStateRow,
|
|
1754
|
+
target: readonly ValueRow[],
|
|
1755
|
+
current: readonly ValueRow[],
|
|
1756
|
+
): Promise<void> {
|
|
1757
|
+
const types = new Map<number, PropertyTypeRow>()
|
|
1758
|
+
for (const row of [...target, ...current]) {
|
|
1759
|
+
if (types.has(row.propertyTypeId)) continue
|
|
1760
|
+
const found = await this.#db.query(
|
|
1761
|
+
'SELECT p.id, p.alias, p.variations, d.db_type, d.editor_alias FROM property_type p JOIN data_type d ON d.node_id = p.data_type_id WHERE p.id = ?',
|
|
1762
|
+
[row.propertyTypeId],
|
|
1763
|
+
)
|
|
1764
|
+
const r = found[0]
|
|
1765
|
+
if (r)
|
|
1766
|
+
types.set(row.propertyTypeId, {
|
|
1767
|
+
id: Number(r.id),
|
|
1768
|
+
alias: String(r.alias),
|
|
1769
|
+
name: String(r.alias),
|
|
1770
|
+
variesByCulture: flagVariesByCulture(Number(r.variations)),
|
|
1771
|
+
storage: String(r.db_type) as ValueStorageTypeName,
|
|
1772
|
+
editorAlias: String(r.editor_alias),
|
|
1773
|
+
config: {},
|
|
1774
|
+
// Only storage matters here: writes never validate or render.
|
|
1775
|
+
mandatory: false,
|
|
1776
|
+
mandatoryMessage: null,
|
|
1777
|
+
regEx: null,
|
|
1778
|
+
regExMessage: null,
|
|
1779
|
+
pending: false,
|
|
1780
|
+
})
|
|
1781
|
+
}
|
|
1782
|
+
const currentByKey = new Map(
|
|
1783
|
+
current.map((row) => [keyOf(row.propertyTypeId, row.languageId, row.segment), row]),
|
|
1784
|
+
)
|
|
1785
|
+
const seen = new Set<string>()
|
|
1786
|
+
for (const row of target) {
|
|
1787
|
+
const propertyType = types.get(row.propertyTypeId)
|
|
1788
|
+
if (!propertyType) continue
|
|
1789
|
+
const key = keyOf(row.propertyTypeId, row.languageId, row.segment)
|
|
1790
|
+
seen.add(key)
|
|
1791
|
+
const column = STORAGE_COLUMN[propertyType.storage]
|
|
1792
|
+
const wanted = this.#fromStorage(
|
|
1793
|
+
(row.raw as Record<string, unknown>)[column],
|
|
1794
|
+
propertyType.storage,
|
|
1795
|
+
)
|
|
1796
|
+
const existing = currentByKey.get(key)
|
|
1797
|
+
if (
|
|
1798
|
+
existing &&
|
|
1799
|
+
this.#storedEquals(
|
|
1800
|
+
(existing.raw as Record<string, unknown>)[column],
|
|
1801
|
+
wanted,
|
|
1802
|
+
propertyType.storage,
|
|
1803
|
+
)
|
|
1804
|
+
)
|
|
1805
|
+
continue
|
|
1806
|
+
await this.#insertValue(
|
|
1807
|
+
nodeId,
|
|
1808
|
+
eventId,
|
|
1809
|
+
state.id,
|
|
1810
|
+
propertyType,
|
|
1811
|
+
row.languageId,
|
|
1812
|
+
row.segment,
|
|
1813
|
+
wanted,
|
|
1814
|
+
existing?.id,
|
|
1815
|
+
)
|
|
1816
|
+
}
|
|
1817
|
+
for (const [key, row] of currentByKey) {
|
|
1818
|
+
if (seen.has(key)) continue
|
|
1819
|
+
const propertyType = types.get(row.propertyTypeId)
|
|
1820
|
+
if (!propertyType) continue
|
|
1821
|
+
await this.#insertValue(
|
|
1822
|
+
nodeId,
|
|
1823
|
+
eventId,
|
|
1824
|
+
state.id,
|
|
1825
|
+
propertyType,
|
|
1826
|
+
row.languageId,
|
|
1827
|
+
row.segment,
|
|
1828
|
+
null,
|
|
1829
|
+
row.id,
|
|
1830
|
+
)
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
async #insertValue(
|
|
1835
|
+
nodeId: number,
|
|
1836
|
+
eventId: number,
|
|
1837
|
+
schemaStateId: number,
|
|
1838
|
+
propertyType: PropertyTypeRow,
|
|
1839
|
+
languageId: number | null,
|
|
1840
|
+
segment: string | null,
|
|
1841
|
+
value: unknown,
|
|
1842
|
+
supersedes: number | undefined,
|
|
1843
|
+
): Promise<void> {
|
|
1844
|
+
const column = STORAGE_COLUMN[propertyType.storage]
|
|
1845
|
+
if (supersedes !== undefined) {
|
|
1846
|
+
await this.#db.exec('UPDATE property_value SET is_current = ? WHERE id = ?', [
|
|
1847
|
+
this.#db.dialect.boolValue(false),
|
|
1848
|
+
supersedes,
|
|
1849
|
+
])
|
|
1850
|
+
}
|
|
1851
|
+
await this.#db.exec(
|
|
1852
|
+
`INSERT INTO property_value (node_id, property_type_id, language_id, segment, event_id, schema_state_id, is_current, ${column}, sortable_value)
|
|
1853
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1854
|
+
[
|
|
1855
|
+
nodeId,
|
|
1856
|
+
propertyType.id,
|
|
1857
|
+
languageId,
|
|
1858
|
+
segment,
|
|
1859
|
+
eventId,
|
|
1860
|
+
schemaStateId,
|
|
1861
|
+
this.#db.dialect.boolValue(true),
|
|
1862
|
+
this.#toStorage(value, propertyType.storage),
|
|
1863
|
+
typeof value === 'string' ? value.slice(0, 512) : null,
|
|
1864
|
+
],
|
|
1865
|
+
)
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/**
|
|
1869
|
+
* A mandatory property must have a current value before publishing. Pending
|
|
1870
|
+
* properties are exempt: a field created early for the next deploy never
|
|
1871
|
+
* blocks a publish on the live site.
|
|
1872
|
+
*/
|
|
1873
|
+
async #assertMandatoryFilled(node: NodeRow, cultures: readonly string[] | null): Promise<void> {
|
|
1874
|
+
const contentTypeNodeId = await this.#contentTypeNodeId(node.id)
|
|
1875
|
+
if (!contentTypeNodeId) return
|
|
1876
|
+
const propertyTypes = await this.#propertyTypes(contentTypeNodeId)
|
|
1877
|
+
const languages = await this.#languages()
|
|
1878
|
+
const values = await this.#readValues(node.id, propertyTypes, languages, {})
|
|
1879
|
+
const errors = validateValues(
|
|
1880
|
+
propertyTypes,
|
|
1881
|
+
values,
|
|
1882
|
+
cultures ?? languages.map((l) => l.isoCode),
|
|
1883
|
+
)
|
|
1884
|
+
if (errors.length > 0) {
|
|
1885
|
+
const names = errors.map(
|
|
1886
|
+
(e) => `'${propertyTypes.find((p) => p.alias === e.alias)?.name ?? e.alias}'`,
|
|
1887
|
+
)
|
|
1888
|
+
throw new PublishBlockedError(
|
|
1889
|
+
`Required: ${[...new Set(names)].join(', ')} must have a value before publishing.`,
|
|
1890
|
+
)
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
/**
|
|
1895
|
+
* What the editor is told before a save: mandatory and pattern rules over the
|
|
1896
|
+
* values as sent, per culture the document has (or the cultures asked for).
|
|
1897
|
+
* Pending properties are exempt, as at publish.
|
|
1898
|
+
*/
|
|
1899
|
+
async validate(
|
|
1900
|
+
input: {
|
|
1901
|
+
contentTypeKey?: string
|
|
1902
|
+
key?: string
|
|
1903
|
+
values: readonly DocumentValue[]
|
|
1904
|
+
variants: SaveDocumentInput['variants']
|
|
1905
|
+
},
|
|
1906
|
+
cultures: readonly string[] | null = null,
|
|
1907
|
+
): Promise<DocumentValidationError[]> {
|
|
1908
|
+
let contentTypeNodeId: number | undefined
|
|
1909
|
+
if (input.contentTypeKey)
|
|
1910
|
+
contentTypeNodeId = (await this.#nodes.byKey(input.contentTypeKey))?.id
|
|
1911
|
+
else if (input.key) {
|
|
1912
|
+
const node = await this.#nodes.byKey(input.key)
|
|
1913
|
+
if (node) contentTypeNodeId = await this.#contentTypeNodeId(node.id)
|
|
1914
|
+
}
|
|
1915
|
+
if (!contentTypeNodeId) return []
|
|
1916
|
+
const propertyTypes = await this.#propertyTypes(contentTypeNodeId)
|
|
1917
|
+
const variantCultures = input.variants
|
|
1918
|
+
.map((v) => v.culture)
|
|
1919
|
+
.filter((c): c is string => typeof c === 'string')
|
|
1920
|
+
return validateValues(propertyTypes, input.values, cultures ?? variantCultures)
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
/** Creates and publishes in one go, or refuses before creating anything. */
|
|
1924
|
+
async createAndPublish(
|
|
1925
|
+
input: SaveDocumentInput,
|
|
1926
|
+
cultures: readonly string[] | null,
|
|
1927
|
+
): Promise<
|
|
1928
|
+
{ ok: true; document: DocumentAggregate } | { ok: false; errors: DocumentValidationError[] }
|
|
1929
|
+
> {
|
|
1930
|
+
const errors = await this.validate(input, cultures)
|
|
1931
|
+
if (errors.length > 0) return { ok: false, errors }
|
|
1932
|
+
if (input.parentKey) {
|
|
1933
|
+
const parent = await this.#nodes.byKey(input.parentKey)
|
|
1934
|
+
if (parent && !(await this.#ancestorsPublished({ ...parent, path: `${parent.path},0` })))
|
|
1935
|
+
throw new PublishBlockedError('An ancestor is not published.')
|
|
1936
|
+
}
|
|
1937
|
+
const created = await this.create(input)
|
|
1938
|
+
const published = await this.publish(created.key, cultures)
|
|
1939
|
+
return { ok: true, document: published ?? created }
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
async updateAndPublish(
|
|
1943
|
+
input: SaveDocumentInput,
|
|
1944
|
+
cultures: readonly string[] | null,
|
|
1945
|
+
): Promise<
|
|
1946
|
+
| { ok: true; document: DocumentAggregate }
|
|
1947
|
+
| { ok: false; errors: DocumentValidationError[] }
|
|
1948
|
+
| undefined
|
|
1949
|
+
> {
|
|
1950
|
+
const errors = await this.validate(input, cultures)
|
|
1951
|
+
if (errors.length > 0) return { ok: false, errors }
|
|
1952
|
+
const updated = await this.update(input)
|
|
1953
|
+
if (!updated) return undefined
|
|
1954
|
+
const published = await this.publish(input.key, cultures)
|
|
1955
|
+
return { ok: true, document: published ?? updated }
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
/** The published version's values, or undefined when nothing is published. */
|
|
1959
|
+
async byKeyPublished(key: string): Promise<DocumentAggregate | undefined> {
|
|
1960
|
+
const node = await this.#nodes.byKey(key)
|
|
1961
|
+
if (!node) return undefined
|
|
1962
|
+
const draft = await this.#load(node)
|
|
1963
|
+
if (!draft?.published) return undefined
|
|
1964
|
+
const published = await this.#db.query<{ id: number }>(
|
|
1965
|
+
'SELECT cv.id FROM content_version cv JOIN document_version dv ON dv.id = cv.id AND dv.published = ? WHERE cv.node_id = ? ORDER BY cv.id DESC LIMIT 1',
|
|
1966
|
+
[this.#db.dialect.boolValue(true), node.id],
|
|
1967
|
+
)
|
|
1968
|
+
const eventId = Number(published[0]?.id)
|
|
1969
|
+
const contentTypeNodeId = await this.#contentTypeNodeId(node.id)
|
|
1970
|
+
if (!contentTypeNodeId || !eventId) return undefined
|
|
1971
|
+
const propertyTypes = await this.#propertyTypes(contentTypeNodeId)
|
|
1972
|
+
const values = await this.#readPublishedValues(
|
|
1973
|
+
node.id,
|
|
1974
|
+
propertyTypes,
|
|
1975
|
+
await this.#languages(),
|
|
1976
|
+
eventId,
|
|
1977
|
+
await this.#publishedCultureEvents(node.id),
|
|
1978
|
+
)
|
|
1979
|
+
return { ...draft, values }
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
/** The content tree's view of documents, one query per page for the aggregates it decorates with. */
|
|
1983
|
+
async treeItems(rows: readonly NodeRow[]): Promise<DocumentTreeItem[]> {
|
|
1984
|
+
const counts = await this.#nodes.childCounts(
|
|
1985
|
+
rows.map((row) => row.id),
|
|
1986
|
+
this.objectType,
|
|
1987
|
+
)
|
|
1988
|
+
const items: DocumentTreeItem[] = []
|
|
1989
|
+
for (const row of rows) {
|
|
1990
|
+
const aggregate = await this.#load(row)
|
|
1991
|
+
if (!aggregate) continue
|
|
1992
|
+
const ancestors = await this.#nodes.ancestors(row.key)
|
|
1993
|
+
items.push({
|
|
1994
|
+
key: row.key,
|
|
1995
|
+
name: row.text ?? '',
|
|
1996
|
+
hasChildren: (counts.get(row.id) ?? 0) > 0,
|
|
1997
|
+
parentKey: aggregate.parentKey,
|
|
1998
|
+
contentTypeKey: aggregate.contentTypeKey,
|
|
1999
|
+
contentTypeCollectionKey: aggregate.contentTypeCollectionKey ?? null,
|
|
2000
|
+
icon: aggregate.contentTypeIcon,
|
|
2001
|
+
isTrashed: row.trashed,
|
|
2002
|
+
createDate: row.createDate ?? new Date(),
|
|
2003
|
+
ancestorKeys: ancestors.filter((a) => a.objectType === this.objectType).map((a) => a.key),
|
|
2004
|
+
variants: aggregate.variants.map((v) => ({
|
|
2005
|
+
culture: v.culture,
|
|
2006
|
+
name: v.name,
|
|
2007
|
+
state: v.state,
|
|
2008
|
+
})),
|
|
2009
|
+
})
|
|
2010
|
+
}
|
|
2011
|
+
return items
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
/** Where a trashed document was before the bin, when it was not at the root. */
|
|
2015
|
+
async originalParent(key: string): Promise<string | null | undefined> {
|
|
2016
|
+
const node = await this.#nodes.byKey(key)
|
|
2017
|
+
if (!node) return undefined
|
|
2018
|
+
const rows = await this.#db.query<{ original_parent_id: number | null }>(
|
|
2019
|
+
'SELECT original_parent_id FROM document WHERE node_id = ?',
|
|
2020
|
+
[node.id],
|
|
2021
|
+
)
|
|
2022
|
+
const parentId = rows[0]?.original_parent_id
|
|
2023
|
+
if (parentId === null || parentId === undefined) return null
|
|
2024
|
+
return (await this.#nodes.byId(Number(parentId)))?.key ?? null
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
/** Other nodes drop their published cache when they see this. */
|
|
2028
|
+
async #instructContent(key: string): Promise<void> {
|
|
2029
|
+
await appendCacheInstruction(this.#db, { kind: 'content', payload: { key }, by: this.#nodeId })
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
async #insertEvent(
|
|
2033
|
+
nodeId: number,
|
|
2034
|
+
kind: EventKind,
|
|
2035
|
+
name: string,
|
|
2036
|
+
userId: number | undefined,
|
|
2037
|
+
templateId: number | null,
|
|
2038
|
+
published: boolean,
|
|
2039
|
+
): Promise<number> {
|
|
2040
|
+
const t = this.#db.dialect.boolValue(true)
|
|
2041
|
+
const f = this.#db.dialect.boolValue(false)
|
|
2042
|
+
await this.#db.exec('UPDATE content_version SET current = ? WHERE node_id = ?', [f, nodeId])
|
|
2043
|
+
await this.#db.exec(
|
|
2044
|
+
`INSERT INTO content_version (node_id, kind, version_date, user_id, current, text, prevent_cleanup) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
2045
|
+
[nodeId, kind, DbDate.toDb(new Date()), userId ?? null, t, name, f],
|
|
2046
|
+
)
|
|
2047
|
+
const rows = await this.#db.query<{ id: number }>(
|
|
2048
|
+
'SELECT MAX(id) AS id FROM content_version WHERE node_id = ?',
|
|
2049
|
+
[nodeId],
|
|
2050
|
+
)
|
|
2051
|
+
const eventId = Number(rows[0]?.id)
|
|
2052
|
+
await this.#db.exec(
|
|
2053
|
+
'INSERT INTO document_version (id, template_id, published) VALUES (?, ?, ?)',
|
|
2054
|
+
[eventId, templateId, this.#db.dialect.boolValue(published)],
|
|
2055
|
+
)
|
|
2056
|
+
return eventId
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
async #headEvent(
|
|
2060
|
+
nodeId: number,
|
|
2061
|
+
): Promise<
|
|
2062
|
+
{ id: number; name: string; userId: number | undefined; templateId: number | null } | undefined
|
|
2063
|
+
> {
|
|
2064
|
+
const rows = await this.#db.query(
|
|
2065
|
+
`SELECT cv.id, cv.text, cv.user_id, dv.template_id FROM content_version cv JOIN document_version dv ON dv.id = cv.id
|
|
2066
|
+
WHERE cv.node_id = ? ORDER BY cv.id DESC LIMIT 1`,
|
|
2067
|
+
[nodeId],
|
|
2068
|
+
)
|
|
2069
|
+
const row = rows[0]
|
|
2070
|
+
if (!row) return undefined
|
|
2071
|
+
return {
|
|
2072
|
+
id: Number(row.id),
|
|
2073
|
+
name: String(row.text ?? ''),
|
|
2074
|
+
userId: row.user_id === null || row.user_id === undefined ? undefined : Number(row.user_id),
|
|
2075
|
+
templateId:
|
|
2076
|
+
row.template_id === null || row.template_id === undefined ? null : Number(row.template_id),
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
async #publishedEventId(nodeId: number): Promise<number | undefined> {
|
|
2081
|
+
const rows = await this.#db.query<{ id: number }>(
|
|
2082
|
+
'SELECT cv.id FROM content_version cv JOIN document_version dv ON dv.id = cv.id WHERE cv.node_id = ? AND dv.published = ?',
|
|
2083
|
+
[nodeId, this.#db.dialect.boolValue(true)],
|
|
2084
|
+
)
|
|
2085
|
+
return rows[0] ? Number(rows[0].id) : undefined
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
/** `edited`: any value newer than the published event, or a changed name. */
|
|
2089
|
+
async #recomputeEdited(nodeId: number): Promise<void> {
|
|
2090
|
+
const publishedId = await this.#publishedEventId(nodeId)
|
|
2091
|
+
let edited = true
|
|
2092
|
+
if (publishedId !== undefined) {
|
|
2093
|
+
const newer = await this.#db.query<{ n: number }>(
|
|
2094
|
+
'SELECT COUNT(*) AS n FROM property_value WHERE node_id = ? AND event_id > ?',
|
|
2095
|
+
[nodeId, publishedId],
|
|
2096
|
+
)
|
|
2097
|
+
const names = await this.#db.query<{ head: string | null; pub: string | null }>(
|
|
2098
|
+
`SELECT (SELECT text FROM content_version WHERE node_id = ? ORDER BY id DESC LIMIT 1) AS head,
|
|
2099
|
+
(SELECT text FROM content_version WHERE id = ?) AS pub`,
|
|
2100
|
+
[nodeId, publishedId],
|
|
2101
|
+
)
|
|
2102
|
+
edited = Number(newer[0]?.n ?? 0) > 0 || (names[0]?.head ?? '') !== (names[0]?.pub ?? '')
|
|
2103
|
+
}
|
|
2104
|
+
await this.#db.exec('UPDATE document SET edited = ? WHERE node_id = ?', [
|
|
2105
|
+
this.#db.dialect.boolValue(edited),
|
|
2106
|
+
nodeId,
|
|
2107
|
+
])
|
|
2108
|
+
// A published culture is edited when its values, the invariant ones, or its name moved on
|
|
2109
|
+
for (const culture of await this.#publishedCultureEvents(nodeId)) {
|
|
2110
|
+
const newer = await this.#db.query<{ n: number }>(
|
|
2111
|
+
`SELECT COUNT(*) AS n FROM property_value
|
|
2112
|
+
WHERE node_id = ? AND event_id > ? AND (language_id = ? OR language_id IS NULL)`,
|
|
2113
|
+
[nodeId, culture.eventId, culture.languageId],
|
|
2114
|
+
)
|
|
2115
|
+
const names = await this.#db.query<{ name: string | null; published_name: string | null }>(
|
|
2116
|
+
'SELECT name, published_name FROM document_culture_variation WHERE node_id = ? AND language_id = ?',
|
|
2117
|
+
[nodeId, culture.languageId],
|
|
2118
|
+
)
|
|
2119
|
+
const cultureEdited =
|
|
2120
|
+
Number(newer[0]?.n ?? 0) > 0 || (names[0]?.name ?? '') !== (names[0]?.published_name ?? '')
|
|
2121
|
+
await this.#db.exec(
|
|
2122
|
+
'UPDATE document_culture_variation SET edited = ? WHERE node_id = ? AND language_id = ?',
|
|
2123
|
+
[this.#db.dialect.boolValue(cultureEdited), nodeId, culture.languageId],
|
|
2124
|
+
)
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
async #readVariants(node: NodeRow, row: Record<string, unknown>): Promise<DocumentVariant[]> {
|
|
2129
|
+
const variants = await this.#readVariantsUnscheduled(node, row)
|
|
2130
|
+
const schedules = await this.schedules(node.key)
|
|
2131
|
+
if (schedules.length === 0) return variants
|
|
2132
|
+
return variants.map((variant) => {
|
|
2133
|
+
const mine = schedules.filter(
|
|
2134
|
+
(s) => (s.culture ?? '').toLowerCase() === (variant.culture ?? '').toLowerCase(),
|
|
2135
|
+
)
|
|
2136
|
+
return {
|
|
2137
|
+
...variant,
|
|
2138
|
+
scheduledPublishDate: mine.find((s) => s.action === 'Release')?.date ?? null,
|
|
2139
|
+
scheduledUnpublishDate: mine.find((s) => s.action === 'Expire')?.date ?? null,
|
|
2140
|
+
}
|
|
2141
|
+
})
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
async #readVariantsUnscheduled(
|
|
2145
|
+
node: NodeRow,
|
|
2146
|
+
row: Record<string, unknown>,
|
|
2147
|
+
): Promise<DocumentVariant[]> {
|
|
2148
|
+
const published = fromDbBool(row.published)
|
|
2149
|
+
const edited = fromDbBool(row.edited)
|
|
2150
|
+
const createDate = node.createDate ?? new Date()
|
|
2151
|
+
const updateDate = DbDate.fromDb(row.draft_date) ?? createDate
|
|
2152
|
+
const publishDate = DbDate.fromDb(row.published_date) ?? null
|
|
2153
|
+
const cultureRows = await this.#db.query(
|
|
2154
|
+
`SELECT dcv.name, dcv.published, dcv.edited, dcv.available, l.iso_code
|
|
2155
|
+
FROM document_culture_variation dcv JOIN language l ON l.id = dcv.language_id WHERE dcv.node_id = ?`,
|
|
2156
|
+
[node.id],
|
|
2157
|
+
)
|
|
2158
|
+
if (cultureRows.length === 0) {
|
|
2159
|
+
return [
|
|
2160
|
+
{
|
|
2161
|
+
culture: null,
|
|
2162
|
+
segment: null,
|
|
2163
|
+
name: String(row.draft_name ?? node.text ?? ''),
|
|
2164
|
+
state: stateFor(published, edited, node.trashed),
|
|
2165
|
+
createDate,
|
|
2166
|
+
updateDate,
|
|
2167
|
+
publishDate,
|
|
2168
|
+
},
|
|
2169
|
+
]
|
|
2170
|
+
}
|
|
2171
|
+
return cultureRows.map((cultureRow) => ({
|
|
2172
|
+
culture: String(cultureRow.iso_code),
|
|
2173
|
+
segment: null,
|
|
2174
|
+
name: String(cultureRow.name ?? ''),
|
|
2175
|
+
state: stateFor(
|
|
2176
|
+
fromDbBool(cultureRow.published),
|
|
2177
|
+
fromDbBool(cultureRow.edited),
|
|
2178
|
+
node.trashed,
|
|
2179
|
+
fromDbBool(cultureRow.available),
|
|
2180
|
+
),
|
|
2181
|
+
createDate,
|
|
2182
|
+
updateDate,
|
|
2183
|
+
publishDate,
|
|
2184
|
+
}))
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
async #writeCultureVariations(
|
|
2188
|
+
nodeId: number,
|
|
2189
|
+
eventId: number,
|
|
2190
|
+
variants: ReadonlyArray<{ culture: string | null; name: string }>,
|
|
2191
|
+
): Promise<void> {
|
|
2192
|
+
const cultured = variants.filter((variant) => variant.culture !== null)
|
|
2193
|
+
if (cultured.length === 0) return
|
|
2194
|
+
const languages = await this.#languages()
|
|
2195
|
+
const idByIso = new Map(languages.map((l) => [l.isoCode.toLowerCase(), l.id]))
|
|
2196
|
+
const now = DbDate.toDb(new Date())
|
|
2197
|
+
const t = this.#db.dialect.boolValue(true)
|
|
2198
|
+
const f = this.#db.dialect.boolValue(false)
|
|
2199
|
+
for (const variant of cultured) {
|
|
2200
|
+
const languageId = idByIso.get(String(variant.culture).toLowerCase())
|
|
2201
|
+
if (!languageId) continue
|
|
2202
|
+
await this.#db.exec(
|
|
2203
|
+
'INSERT INTO content_version_culture_variation (version_id, language_id, name, date) VALUES (?, ?, ?, ?)',
|
|
2204
|
+
[eventId, languageId, variant.name, now],
|
|
2205
|
+
)
|
|
2206
|
+
const existing = await this.#db.query<{ id: number }>(
|
|
2207
|
+
'SELECT id FROM document_culture_variation WHERE node_id = ? AND language_id = ?',
|
|
2208
|
+
[nodeId, languageId],
|
|
2209
|
+
)
|
|
2210
|
+
if (existing[0]) {
|
|
2211
|
+
await this.#db.exec(
|
|
2212
|
+
'UPDATE document_culture_variation SET name = ?, available = ?, edited = ? WHERE id = ?',
|
|
2213
|
+
[variant.name, t, t, Number(existing[0].id)],
|
|
2214
|
+
)
|
|
2215
|
+
} else {
|
|
2216
|
+
await this.#db.exec(
|
|
2217
|
+
'INSERT INTO document_culture_variation (node_id, language_id, edited, available, published, name) VALUES (?, ?, ?, ?, ?, ?)',
|
|
2218
|
+
[nodeId, languageId, t, t, f, variant.name],
|
|
2219
|
+
)
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
async #copyCultureVariations(fromEventId: number, toEventId: number): Promise<void> {
|
|
2225
|
+
await this.#db.exec(
|
|
2226
|
+
`INSERT INTO content_version_culture_variation (version_id, language_id, name, date, available_user_id)
|
|
2227
|
+
SELECT ?, language_id, name, date, available_user_id FROM content_version_culture_variation WHERE version_id = ?`,
|
|
2228
|
+
[toEventId, fromEventId],
|
|
2229
|
+
)
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
async #availableCultures(nodeId: number): Promise<string[]> {
|
|
2233
|
+
const rows = await this.#db.query<{ iso_code: string }>(
|
|
2234
|
+
'SELECT l.iso_code FROM document_culture_variation dcv JOIN language l ON l.id = dcv.language_id WHERE dcv.node_id = ? AND dcv.available = ?',
|
|
2235
|
+
[nodeId, this.#db.dialect.boolValue(true)],
|
|
2236
|
+
)
|
|
2237
|
+
return rows.map((row) => String(row.iso_code))
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
async #publishedCultures(nodeId: number): Promise<string[]> {
|
|
2241
|
+
const rows = await this.#db.query<{ iso_code: string }>(
|
|
2242
|
+
'SELECT l.iso_code FROM document_culture_variation dcv JOIN language l ON l.id = dcv.language_id WHERE dcv.node_id = ? AND dcv.published = ?',
|
|
2243
|
+
[nodeId, this.#db.dialect.boolValue(true)],
|
|
2244
|
+
)
|
|
2245
|
+
return rows.map((row) => String(row.iso_code))
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
/** Publishes a culture at `eventId` with its current name, or unpublishes it (`null`). */
|
|
2249
|
+
async #setCulturePublished(nodeId: number, iso: string, eventId: number | null): Promise<void> {
|
|
2250
|
+
const languages = await this.#languages()
|
|
2251
|
+
const languageId = languages.find((l) => l.isoCode.toLowerCase() === iso.toLowerCase())?.id
|
|
2252
|
+
if (!languageId) return
|
|
2253
|
+
if (eventId === null)
|
|
2254
|
+
await this.#db.exec(
|
|
2255
|
+
'UPDATE document_culture_variation SET published = ?, edited = ? WHERE node_id = ? AND language_id = ?',
|
|
2256
|
+
[this.#db.dialect.boolValue(false), this.#db.dialect.boolValue(true), nodeId, languageId],
|
|
2257
|
+
)
|
|
2258
|
+
else
|
|
2259
|
+
await this.#db.exec(
|
|
2260
|
+
`UPDATE document_culture_variation SET published = ?, edited = ?, published_event_id = ?,
|
|
2261
|
+
published_name = name WHERE node_id = ? AND language_id = ?`,
|
|
2262
|
+
[
|
|
2263
|
+
this.#db.dialect.boolValue(true),
|
|
2264
|
+
this.#db.dialect.boolValue(false),
|
|
2265
|
+
eventId,
|
|
2266
|
+
nodeId,
|
|
2267
|
+
languageId,
|
|
2268
|
+
],
|
|
2269
|
+
)
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
/** Each published culture: its language, the event it was published at, and its published name. */
|
|
2273
|
+
async #publishedCultureEvents(
|
|
2274
|
+
nodeId: number,
|
|
2275
|
+
): Promise<Array<{ languageId: number; isoCode: string; eventId: number; name: string }>> {
|
|
2276
|
+
const rows = await this.#db.query(
|
|
2277
|
+
`SELECT dcv.language_id, l.iso_code, dcv.published_event_id, dcv.published_name, dcv.name
|
|
2278
|
+
FROM document_culture_variation dcv JOIN language l ON l.id = dcv.language_id
|
|
2279
|
+
WHERE dcv.node_id = ? AND dcv.published = ?`,
|
|
2280
|
+
[nodeId, this.#db.dialect.boolValue(true)],
|
|
2281
|
+
)
|
|
2282
|
+
return rows.map((row) => ({
|
|
2283
|
+
languageId: Number(row.language_id),
|
|
2284
|
+
isoCode: String(row.iso_code),
|
|
2285
|
+
eventId: Number(row.published_event_id ?? 0),
|
|
2286
|
+
name: String(row.published_name ?? row.name ?? ''),
|
|
2287
|
+
}))
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
async #variesByCulture(nodeId: number): Promise<boolean> {
|
|
2291
|
+
const rows = await this.#db.query<{ variations: number }>(
|
|
2292
|
+
'SELECT ct.variations FROM content c JOIN content_type ct ON ct.node_id = c.content_type_id WHERE c.node_id = ?',
|
|
2293
|
+
[nodeId],
|
|
2294
|
+
)
|
|
2295
|
+
return flagVariesByCulture(Number(rows[0]?.variations ?? 0))
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
/**
|
|
2299
|
+
* Published values: invariant ones as of the latest publish, each culture's
|
|
2300
|
+
* as of its own. A culture that is not published contributes nothing.
|
|
2301
|
+
*/
|
|
2302
|
+
async #readPublishedValues(
|
|
2303
|
+
nodeId: number,
|
|
2304
|
+
propertyTypes: readonly PropertyTypeRow[],
|
|
2305
|
+
languages: readonly LanguageRow[],
|
|
2306
|
+
invariantEventId: number,
|
|
2307
|
+
cultures: ReadonlyArray<{ languageId: number; eventId: number }>,
|
|
2308
|
+
schemaStateId?: number,
|
|
2309
|
+
): Promise<DocumentValue[]> {
|
|
2310
|
+
const cutoff = new Map(cultures.map((c) => [c.languageId, c.eventId]))
|
|
2311
|
+
const latest = Math.max(invariantEventId, ...cultures.map((c) => c.eventId))
|
|
2312
|
+
const conditions = ['node_id = ?', 'event_id <= ?']
|
|
2313
|
+
const params: unknown[] = [nodeId, latest]
|
|
2314
|
+
if (schemaStateId !== undefined) {
|
|
2315
|
+
conditions.push('schema_state_id <= ?')
|
|
2316
|
+
params.push(schemaStateId)
|
|
2317
|
+
}
|
|
2318
|
+
const all = (
|
|
2319
|
+
await this.#db.query(
|
|
2320
|
+
`SELECT id, event_id, property_type_id, language_id, segment,
|
|
2321
|
+
int_value, decimal_value, date_value, varchar_value, text_value
|
|
2322
|
+
FROM property_value WHERE ${conditions.join(' AND ')} ORDER BY id`,
|
|
2323
|
+
params,
|
|
2324
|
+
)
|
|
2325
|
+
).map((row) => this.#toValueRow(row))
|
|
2326
|
+
const chosen = new Map<string, ValueRow>()
|
|
2327
|
+
for (const row of all) {
|
|
2328
|
+
const limit = row.languageId === null ? invariantEventId : cutoff.get(row.languageId)
|
|
2329
|
+
if (limit === undefined || row.eventId > limit) continue
|
|
2330
|
+
chosen.set(keyOf(row.propertyTypeId, row.languageId, row.segment), row)
|
|
2331
|
+
}
|
|
2332
|
+
return this.#valuesFromRows([...chosen.values()], propertyTypes, languages)
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
async #anyCulturePublished(nodeId: number): Promise<boolean> {
|
|
2336
|
+
const rows = await this.#db.query<{ n: number }>(
|
|
2337
|
+
'SELECT COUNT(*) AS n FROM document_culture_variation WHERE node_id = ? AND published = ?',
|
|
2338
|
+
[nodeId, this.#db.dialect.boolValue(true)],
|
|
2339
|
+
)
|
|
2340
|
+
return Number(rows[0]?.n ?? 0) > 0
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
async #ancestorsPublished(
|
|
2344
|
+
node: NodeRow,
|
|
2345
|
+
alsoPublishing: ReadonlySet<number> = new Set(),
|
|
2346
|
+
): Promise<boolean> {
|
|
2347
|
+
const ids = node.path
|
|
2348
|
+
.split(',')
|
|
2349
|
+
.map(Number)
|
|
2350
|
+
.filter((id) => id > 0 && id !== node.id && !alsoPublishing.has(id))
|
|
2351
|
+
if (ids.length === 0) return true
|
|
2352
|
+
const rows = await this.#db.query<{ n: number }>(
|
|
2353
|
+
`SELECT COUNT(*) AS n FROM document WHERE node_id IN (${ids.map(() => '?').join(', ')}) AND published = ?`,
|
|
2354
|
+
[...ids, this.#db.dialect.boolValue(false)],
|
|
2355
|
+
)
|
|
2356
|
+
return Number(rows[0]?.n ?? 0) === 0
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
function stateFor(
|
|
2361
|
+
published: boolean,
|
|
2362
|
+
edited: boolean,
|
|
2363
|
+
trashed: boolean,
|
|
2364
|
+
available = true,
|
|
2365
|
+
): VariantState {
|
|
2366
|
+
if (trashed) return 'Trashed'
|
|
2367
|
+
if (!available) return 'NotCreated'
|
|
2368
|
+
if (published) return edited ? 'PublishedPendingChanges' : 'Published'
|
|
2369
|
+
return 'Draft'
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
/** `name`, or the first of `name (1)`, `name (2)`… no sibling already has. */
|
|
2373
|
+
export function uniqueName(name: string, taken: ReadonlySet<string>): string {
|
|
2374
|
+
if (!taken.has(name.toLowerCase())) return name
|
|
2375
|
+
for (let n = 1; ; n++) {
|
|
2376
|
+
const candidate = `${name} (${n})`
|
|
2377
|
+
if (!taken.has(candidate.toLowerCase())) return candidate
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
function parseConfig(raw: unknown): Record<string, unknown> {
|
|
2382
|
+
if (typeof raw !== 'string' || !raw) return {}
|
|
2383
|
+
try {
|
|
2384
|
+
const parsed = JSON.parse(raw)
|
|
2385
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
|
2386
|
+
} catch {
|
|
2387
|
+
return {}
|
|
2388
|
+
}
|
|
2389
|
+
}
|