@crombolo/import-umbraco 0.0.0-stage → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +35 -4
- package/src/content.ts +435 -0
- package/src/findings.ts +61 -0
- package/src/index.ts +328 -0
- package/src/inventory.ts +427 -0
- package/src/report.ts +107 -0
- package/src/schema.ts +641 -0
- package/src/site-files.ts +118 -0
- package/src/source.ts +211 -0
- package/src/version.ts +155 -0
- package/src/views.ts +123 -0
- package/README.md +0 -3
package/src/schema.ts
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Umbraco's definitions → the schema-as-code model: document, media and member
|
|
3
|
+
* types, data types and languages, as `@crombolo/schema` writes them to TOML.
|
|
4
|
+
*
|
|
5
|
+
* Keys are carried over unchanged, which is what lets the content bundle name a
|
|
6
|
+
* content type by key and find it after the schema has been synced.
|
|
7
|
+
*/
|
|
8
|
+
import { ObjectTypes } from '@crombolo/core'
|
|
9
|
+
import { BUILT_IN_MEDIA_TYPES, DEFAULT_DATA_TYPES, SYSTEM_MEMBER_TYPE } from '@crombolo/data'
|
|
10
|
+
import {
|
|
11
|
+
aliasFromName,
|
|
12
|
+
BUILTIN_DATA_TYPES,
|
|
13
|
+
builtInMediaTypeSchema,
|
|
14
|
+
type SchemaContentType,
|
|
15
|
+
type SchemaDataType,
|
|
16
|
+
type SchemaGroup,
|
|
17
|
+
type SchemaLanguage,
|
|
18
|
+
type SchemaProperty,
|
|
19
|
+
type SchemaSet,
|
|
20
|
+
type SchemaTypeKind,
|
|
21
|
+
validateSchemaSet,
|
|
22
|
+
writeMediaType,
|
|
23
|
+
} from '@crombolo/schema'
|
|
24
|
+
import type { Findings } from './findings.ts'
|
|
25
|
+
import { key, type Source } from './source.ts'
|
|
26
|
+
|
|
27
|
+
/** The property editors crombolo has an editor and a value converter for. */
|
|
28
|
+
export const SUPPORTED_EDITORS: ReadonlySet<string> = new Set([
|
|
29
|
+
...DEFAULT_DATA_TYPES.map((dataType) => dataType.editorAlias),
|
|
30
|
+
'Umbraco.BlockList',
|
|
31
|
+
'Umbraco.BlockGrid',
|
|
32
|
+
'Umbraco.Decimal',
|
|
33
|
+
'Umbraco.ElementPicker',
|
|
34
|
+
'Umbraco.EmailAddress',
|
|
35
|
+
'Umbraco.MultiNodeTreePicker',
|
|
36
|
+
'Umbraco.MultipleTextstring',
|
|
37
|
+
'Umbraco.Slider',
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
const TINY_MCE_UI = 'Umb.PropertyEditorUi.TinyMCE'
|
|
41
|
+
const TIPTAP_UI = 'Umb.PropertyEditorUi.Tiptap'
|
|
42
|
+
|
|
43
|
+
export interface SourceDataType {
|
|
44
|
+
id: number
|
|
45
|
+
key: string
|
|
46
|
+
name: string
|
|
47
|
+
alias: string
|
|
48
|
+
editor: string
|
|
49
|
+
dbType: string
|
|
50
|
+
supported: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SourceContentType {
|
|
54
|
+
id: number
|
|
55
|
+
key: string
|
|
56
|
+
alias: string
|
|
57
|
+
name: string
|
|
58
|
+
kind: SchemaTypeKind
|
|
59
|
+
isElement: boolean
|
|
60
|
+
variesByCulture: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SourcePropertyType {
|
|
64
|
+
id: number
|
|
65
|
+
alias: string
|
|
66
|
+
contentTypeId: number
|
|
67
|
+
dataType: SourceDataType
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SourceTemplate {
|
|
71
|
+
id: number
|
|
72
|
+
key: string
|
|
73
|
+
alias: string
|
|
74
|
+
name: string
|
|
75
|
+
/** The layout it renders inside, by alias. */
|
|
76
|
+
layout?: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface SourceLanguage {
|
|
80
|
+
id: number
|
|
81
|
+
iso: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ConvertedSchema {
|
|
85
|
+
set: SchemaSet
|
|
86
|
+
dataTypes: Map<number, SourceDataType>
|
|
87
|
+
contentTypes: Map<number, SourceContentType>
|
|
88
|
+
propertyTypes: Map<number, SourcePropertyType>
|
|
89
|
+
templates: Map<number, SourceTemplate>
|
|
90
|
+
languages: Map<number, SourceLanguage>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const KIND_OF: Record<string, SchemaTypeKind> = {
|
|
94
|
+
[ObjectTypes.DocumentType]: 'document',
|
|
95
|
+
[ObjectTypes.MediaType]: 'media',
|
|
96
|
+
[ObjectTypes.MemberType]: 'member',
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const CONTAINERS: ReadonlySet<string> = new Set([
|
|
100
|
+
ObjectTypes.DocumentTypeContainer,
|
|
101
|
+
ObjectTypes.MediaTypeContainer,
|
|
102
|
+
ObjectTypes.MemberTypeContainer,
|
|
103
|
+
ObjectTypes.DataTypeContainer,
|
|
104
|
+
])
|
|
105
|
+
|
|
106
|
+
const truthy = (value: unknown): boolean => value === 1 || value === true || value === '1'
|
|
107
|
+
const text = (value: unknown): string | undefined =>
|
|
108
|
+
value === null || value === undefined || value === '' ? undefined : String(value)
|
|
109
|
+
|
|
110
|
+
function parseConfig(raw: unknown): Record<string, unknown> {
|
|
111
|
+
if (typeof raw !== 'string' || !raw) return {}
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(raw)
|
|
114
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
|
115
|
+
} catch {
|
|
116
|
+
return {}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const sortedJson = (config: Record<string, unknown>): string =>
|
|
121
|
+
JSON.stringify(Object.fromEntries(Object.entries(config).sort(([a], [b]) => a.localeCompare(b))))
|
|
122
|
+
|
|
123
|
+
/** `A/B` for a node inside folders, from the container nodes above it. */
|
|
124
|
+
function folderResolver(source: Source): (parentId: number) => string | undefined {
|
|
125
|
+
const containers = new Map(
|
|
126
|
+
source
|
|
127
|
+
.all<{ id: number; parentId: number; name: string | null; objectType: string | null }>(
|
|
128
|
+
'SELECT id, parentId, "text" AS name, nodeObjectType AS objectType FROM umbracoNode',
|
|
129
|
+
)
|
|
130
|
+
.filter((row) => CONTAINERS.has(key(row.objectType)))
|
|
131
|
+
.map((row) => [Number(row.id), row]),
|
|
132
|
+
)
|
|
133
|
+
return (parentId) => {
|
|
134
|
+
const path: string[] = []
|
|
135
|
+
for (let at = containers.get(parentId), depth = 0; at && depth < 32; depth++) {
|
|
136
|
+
path.unshift(at.name ?? '')
|
|
137
|
+
at = containers.get(Number(at.parentId))
|
|
138
|
+
}
|
|
139
|
+
return path.length > 0 ? path.join('/') : undefined
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readDataTypes(
|
|
144
|
+
source: Source,
|
|
145
|
+
findings: Findings,
|
|
146
|
+
folderOf: (parentId: number) => string | undefined,
|
|
147
|
+
): { byId: Map<number, SourceDataType>; files: SchemaDataType[] } {
|
|
148
|
+
const builtinAliasByKey = new Map(DEFAULT_DATA_TYPES.map((d) => [d.key.toLowerCase(), d.alias]))
|
|
149
|
+
const taken = new Set(BUILTIN_DATA_TYPES.map((d) => d.alias))
|
|
150
|
+
const byId = new Map<number, SourceDataType>()
|
|
151
|
+
const files: SchemaDataType[] = []
|
|
152
|
+
const tinyMce: string[] = []
|
|
153
|
+
|
|
154
|
+
const rows = source.all<{
|
|
155
|
+
id: number
|
|
156
|
+
editor: string
|
|
157
|
+
editorUi: string | null
|
|
158
|
+
dbType: string
|
|
159
|
+
config: string | null
|
|
160
|
+
key: string
|
|
161
|
+
name: string | null
|
|
162
|
+
parentId: number
|
|
163
|
+
}>(
|
|
164
|
+
`SELECT d.nodeId AS id, d.propertyEditorAlias AS editor, d.propertyEditorUiAlias AS editorUi,
|
|
165
|
+
d.dbType AS dbType, d.config AS config, n.uniqueId AS key, n."text" AS name, n.parentId AS parentId
|
|
166
|
+
FROM umbracoDataType d JOIN umbracoNode n ON n.id = d.nodeId ORDER BY d.nodeId`,
|
|
167
|
+
)
|
|
168
|
+
for (const row of rows) {
|
|
169
|
+
const name = row.name ?? row.editor
|
|
170
|
+
let alias = builtinAliasByKey.get(key(row.key))
|
|
171
|
+
if (!alias) {
|
|
172
|
+
alias = aliasFromName(name)
|
|
173
|
+
for (let n = 2, base = alias; taken.has(alias); n++) alias = `${base}${n}`
|
|
174
|
+
taken.add(alias)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let editorUi = text(row.editorUi)
|
|
178
|
+
if (editorUi === TINY_MCE_UI) {
|
|
179
|
+
// Umbraco 16 replaced TinyMCE with Tiptap; the stored markup is the same.
|
|
180
|
+
editorUi = TIPTAP_UI
|
|
181
|
+
tinyMce.push(name)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const dataType: SchemaDataType = {
|
|
185
|
+
key: key(row.key),
|
|
186
|
+
alias,
|
|
187
|
+
name,
|
|
188
|
+
editor: row.editor,
|
|
189
|
+
config: parseConfig(row.config),
|
|
190
|
+
}
|
|
191
|
+
if (editorUi) dataType.editorUi = editorUi
|
|
192
|
+
const folder = folderOf(Number(row.parentId))
|
|
193
|
+
if (folder) dataType.folder = folder
|
|
194
|
+
|
|
195
|
+
byId.set(Number(row.id), {
|
|
196
|
+
id: Number(row.id),
|
|
197
|
+
key: key(row.key),
|
|
198
|
+
name,
|
|
199
|
+
alias,
|
|
200
|
+
editor: row.editor,
|
|
201
|
+
dbType: row.dbType,
|
|
202
|
+
supported: SUPPORTED_EDITORS.has(row.editor),
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// A built-in needs no file unless the site changed its editor or configuration.
|
|
206
|
+
const builtin = BUILTIN_DATA_TYPES.find((b) => b.alias === alias)
|
|
207
|
+
const unchanged =
|
|
208
|
+
builtin !== undefined &&
|
|
209
|
+
builtin.editor === dataType.editor &&
|
|
210
|
+
sortedJson(builtin.config) === sortedJson(dataType.config)
|
|
211
|
+
if (!unchanged) files.push(dataType)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
findings.some({
|
|
215
|
+
class: 'needs-a-person',
|
|
216
|
+
code: 'tinymce-data-types',
|
|
217
|
+
title: 'Rich text editors configured for TinyMCE',
|
|
218
|
+
detail:
|
|
219
|
+
'These now use the Tiptap editor. Stored content is unchanged, but each toolbar is TinyMCE’s and needs setting up again.',
|
|
220
|
+
count: tinyMce.length,
|
|
221
|
+
items: tinyMce,
|
|
222
|
+
})
|
|
223
|
+
return { byId, files }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A media type as text, with the property keys taken out, to compare two definitions by meaning. */
|
|
227
|
+
function shapeOf(type: SchemaContentType): string {
|
|
228
|
+
const strip = (properties: SchemaProperty[]) => properties.map(({ key: _, ...rest }) => rest)
|
|
229
|
+
return writeMediaType({
|
|
230
|
+
...type,
|
|
231
|
+
properties: strip(type.properties),
|
|
232
|
+
groups: type.groups?.map((g) => ({ ...g, properties: strip(g.properties) })),
|
|
233
|
+
tabs: type.tabs.map((tab) => ({
|
|
234
|
+
...tab,
|
|
235
|
+
properties: strip(tab.properties),
|
|
236
|
+
groups: tab.groups.map((g) => ({ ...g, properties: strip(g.properties) })),
|
|
237
|
+
})),
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
interface ContentTypeRow {
|
|
242
|
+
id: number
|
|
243
|
+
alias: string
|
|
244
|
+
icon: string | null
|
|
245
|
+
description: string | null
|
|
246
|
+
isElement: unknown
|
|
247
|
+
allowAtRoot: unknown
|
|
248
|
+
variations: number
|
|
249
|
+
listView: string | null
|
|
250
|
+
key: string
|
|
251
|
+
name: string | null
|
|
252
|
+
parentId: number
|
|
253
|
+
objectType: string
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function convertSchema(source: Source, findings: Findings): ConvertedSchema {
|
|
257
|
+
const folderOf = folderResolver(source)
|
|
258
|
+
const { byId: dataTypes, files: dataTypeFiles } = readDataTypes(source, findings, folderOf)
|
|
259
|
+
const dataTypeAliasByKey = new Map([...dataTypes.values()].map((d) => [d.key, d.alias]))
|
|
260
|
+
|
|
261
|
+
// Languages
|
|
262
|
+
const languageRows = source.all<{
|
|
263
|
+
id: number
|
|
264
|
+
iso: string
|
|
265
|
+
name: string | null
|
|
266
|
+
isDefault: unknown
|
|
267
|
+
mandatory: unknown
|
|
268
|
+
fallbackId: number | null
|
|
269
|
+
}>(
|
|
270
|
+
`SELECT id, languageISOCode AS iso, languageCultureName AS name, isDefaultVariantLang AS isDefault,
|
|
271
|
+
mandatory, fallbackLanguageId AS fallbackId FROM umbracoLanguage ORDER BY id`,
|
|
272
|
+
)
|
|
273
|
+
const languages = new Map(
|
|
274
|
+
languageRows.map((l) => [Number(l.id), { id: Number(l.id), iso: l.iso }]),
|
|
275
|
+
)
|
|
276
|
+
const schemaLanguages: SchemaLanguage[] = languageRows.map((l) => {
|
|
277
|
+
const language: SchemaLanguage = {
|
|
278
|
+
iso: l.iso,
|
|
279
|
+
name: l.name ?? l.iso,
|
|
280
|
+
default: truthy(l.isDefault),
|
|
281
|
+
mandatory: truthy(l.mandatory),
|
|
282
|
+
}
|
|
283
|
+
const fallback = l.fallbackId === null ? undefined : languages.get(Number(l.fallbackId))?.iso
|
|
284
|
+
if (fallback) language.fallback = fallback
|
|
285
|
+
return language
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
// Templates, with the layout each renders inside
|
|
289
|
+
const templateRows = source.all<{
|
|
290
|
+
id: number
|
|
291
|
+
alias: string
|
|
292
|
+
key: string
|
|
293
|
+
name: string | null
|
|
294
|
+
parentId: number
|
|
295
|
+
}>(
|
|
296
|
+
`SELECT t.nodeId AS id, t.alias AS alias, n.uniqueId AS key, n."text" AS name, n.parentId AS parentId
|
|
297
|
+
FROM cmsTemplate t JOIN umbracoNode n ON n.id = t.nodeId ORDER BY t.nodeId`,
|
|
298
|
+
)
|
|
299
|
+
const templates = new Map<number, SourceTemplate>(
|
|
300
|
+
templateRows.map((t) => [
|
|
301
|
+
Number(t.id),
|
|
302
|
+
{ id: Number(t.id), key: key(t.key), alias: t.alias, name: t.name ?? t.alias },
|
|
303
|
+
]),
|
|
304
|
+
)
|
|
305
|
+
for (const row of templateRows) {
|
|
306
|
+
const layout = templates.get(Number(row.parentId))
|
|
307
|
+
const template = templates.get(Number(row.id))
|
|
308
|
+
if (layout && template) template.layout = layout.alias
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Content types
|
|
312
|
+
const typeRows = source
|
|
313
|
+
.all<ContentTypeRow>(
|
|
314
|
+
`SELECT c.nodeId AS id, c.alias AS alias, c.icon AS icon, c.description AS description,
|
|
315
|
+
c.isElement AS isElement, c.allowAtRoot AS allowAtRoot, c.variations AS variations,
|
|
316
|
+
c.listView AS listView, n.uniqueId AS key, n."text" AS name, n.parentId AS parentId,
|
|
317
|
+
n.nodeObjectType AS objectType
|
|
318
|
+
FROM cmsContentType c JOIN umbracoNode n ON n.id = c.nodeId ORDER BY c.nodeId`,
|
|
319
|
+
)
|
|
320
|
+
.filter((row) => KIND_OF[key(row.objectType)] !== undefined)
|
|
321
|
+
|
|
322
|
+
const contentTypes = new Map<number, SourceContentType>(
|
|
323
|
+
typeRows.map((row) => [
|
|
324
|
+
Number(row.id),
|
|
325
|
+
{
|
|
326
|
+
id: Number(row.id),
|
|
327
|
+
key: key(row.key),
|
|
328
|
+
alias: row.alias,
|
|
329
|
+
name: row.name ?? row.alias,
|
|
330
|
+
kind: KIND_OF[key(row.objectType)] as SchemaTypeKind,
|
|
331
|
+
isElement: truthy(row.isElement),
|
|
332
|
+
variesByCulture: (Number(row.variations) & 1) === 1,
|
|
333
|
+
},
|
|
334
|
+
]),
|
|
335
|
+
)
|
|
336
|
+
const aliasOf = (id: number, kind: SchemaTypeKind) => {
|
|
337
|
+
const type = contentTypes.get(id)
|
|
338
|
+
return type && type.kind === kind ? type.alias : undefined
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const groups = source.all<{
|
|
342
|
+
id: number
|
|
343
|
+
typeId: number
|
|
344
|
+
type: number
|
|
345
|
+
name: string | null
|
|
346
|
+
alias: string | null
|
|
347
|
+
sortOrder: number
|
|
348
|
+
}>(
|
|
349
|
+
`SELECT id, contenttypeNodeId AS typeId, "type" AS type, "text" AS name, alias, sortorder AS sortOrder
|
|
350
|
+
FROM cmsPropertyTypeGroup ORDER BY sortorder, id`,
|
|
351
|
+
)
|
|
352
|
+
const memberFlags = source.has('cmsMemberType')
|
|
353
|
+
? new Map(
|
|
354
|
+
source
|
|
355
|
+
.all<{ propertyTypeId: number; canEdit: unknown; canView: unknown; sensitive: unknown }>(
|
|
356
|
+
'SELECT propertytypeId AS propertyTypeId, memberCanEdit AS canEdit, viewOnProfile AS canView, isSensitive AS sensitive FROM cmsMemberType',
|
|
357
|
+
)
|
|
358
|
+
.map((row) => [Number(row.propertyTypeId), row]),
|
|
359
|
+
)
|
|
360
|
+
: new Map()
|
|
361
|
+
|
|
362
|
+
const propertyRows = source.all<{
|
|
363
|
+
id: number
|
|
364
|
+
dataTypeId: number
|
|
365
|
+
typeId: number
|
|
366
|
+
groupId: number | null
|
|
367
|
+
alias: string
|
|
368
|
+
name: string | null
|
|
369
|
+
sortOrder: number
|
|
370
|
+
mandatory: unknown
|
|
371
|
+
mandatoryMessage: string | null
|
|
372
|
+
regex: string | null
|
|
373
|
+
regexMessage: string | null
|
|
374
|
+
description: string | null
|
|
375
|
+
labelOnTop: unknown
|
|
376
|
+
variations: number
|
|
377
|
+
key: string
|
|
378
|
+
}>(
|
|
379
|
+
`SELECT id, dataTypeId, contentTypeId AS typeId, propertyTypeGroupId AS groupId, Alias AS alias,
|
|
380
|
+
Name AS name, sortOrder, mandatory, mandatoryMessage, validationRegExp AS regex,
|
|
381
|
+
validationRegExpMessage AS regexMessage, Description AS description, labelOnTop,
|
|
382
|
+
variations, UniqueID AS key
|
|
383
|
+
FROM cmsPropertyType ORDER BY sortOrder, id`,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
const propertyTypes = new Map<number, SourcePropertyType>()
|
|
387
|
+
const propertiesByGroup = new Map<string, SchemaProperty[]>()
|
|
388
|
+
const unsupported = new Map<string, string[]>()
|
|
389
|
+
const orphaned: string[] = []
|
|
390
|
+
for (const row of propertyRows) {
|
|
391
|
+
const type = contentTypes.get(Number(row.typeId))
|
|
392
|
+
if (!type) continue
|
|
393
|
+
const dataType = dataTypes.get(Number(row.dataTypeId))
|
|
394
|
+
if (!dataType) {
|
|
395
|
+
orphaned.push(`${type.alias}.${row.alias}`)
|
|
396
|
+
continue
|
|
397
|
+
}
|
|
398
|
+
propertyTypes.set(Number(row.id), {
|
|
399
|
+
id: Number(row.id),
|
|
400
|
+
alias: row.alias,
|
|
401
|
+
contentTypeId: type.id,
|
|
402
|
+
dataType,
|
|
403
|
+
})
|
|
404
|
+
if (!dataType.supported) {
|
|
405
|
+
const users = unsupported.get(dataType.editor) ?? []
|
|
406
|
+
users.push(`${type.alias}.${row.alias}`)
|
|
407
|
+
unsupported.set(dataType.editor, users)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const property: SchemaProperty = {
|
|
411
|
+
key: key(row.key),
|
|
412
|
+
alias: row.alias,
|
|
413
|
+
name: row.name ?? row.alias,
|
|
414
|
+
type: dataType.alias,
|
|
415
|
+
mandatory: truthy(row.mandatory),
|
|
416
|
+
// The validator refuses a varying property on a type that does not vary.
|
|
417
|
+
variesByCulture: type.variesByCulture && (Number(row.variations) & 1) === 1,
|
|
418
|
+
variesBySegment: (Number(row.variations) & 2) === 2,
|
|
419
|
+
labelOnTop: truthy(row.labelOnTop),
|
|
420
|
+
}
|
|
421
|
+
const description = text(row.description)
|
|
422
|
+
if (description) property.description = description
|
|
423
|
+
const mandatoryMessage = text(row.mandatoryMessage)
|
|
424
|
+
if (mandatoryMessage) property.mandatoryMessage = mandatoryMessage
|
|
425
|
+
const regex = text(row.regex)
|
|
426
|
+
if (regex) property.regex = regex
|
|
427
|
+
const regexMessage = text(row.regexMessage)
|
|
428
|
+
if (regexMessage) property.regexMessage = regexMessage
|
|
429
|
+
if (type.kind === 'member') {
|
|
430
|
+
const flags = memberFlags.get(Number(row.id))
|
|
431
|
+
if (truthy(flags?.canView)) property.memberCanView = true
|
|
432
|
+
if (truthy(flags?.canEdit)) property.memberCanEdit = true
|
|
433
|
+
if (truthy(flags?.sensitive)) property.sensitive = true
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const slot = `${type.id}:${row.groupId ?? ''}`
|
|
437
|
+
const list = propertiesByGroup.get(slot) ?? []
|
|
438
|
+
list.push(property)
|
|
439
|
+
propertiesByGroup.set(slot, list)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const compositions = source.all<{ parent: number; child: number }>(
|
|
443
|
+
'SELECT parentContentTypeId AS parent, childContentTypeId AS child FROM cmsContentType2ContentType',
|
|
444
|
+
)
|
|
445
|
+
const allowed = source.all<{ id: number; allowed: number; sortOrder: number }>(
|
|
446
|
+
'SELECT Id AS id, AllowedId AS allowed, SortOrder AS sortOrder FROM cmsContentTypeAllowedContentType ORDER BY SortOrder',
|
|
447
|
+
)
|
|
448
|
+
const typeTemplates = source.all<{ typeId: number; templateId: number; isDefault: unknown }>(
|
|
449
|
+
'SELECT contentTypeNodeId AS typeId, templateNodeId AS templateId, IsDefault AS isDefault FROM cmsDocumentType',
|
|
450
|
+
)
|
|
451
|
+
const cleanup = source.has('umbracoContentVersionCleanupPolicy')
|
|
452
|
+
? new Map(
|
|
453
|
+
source
|
|
454
|
+
.all<{ typeId: number; prevent: unknown; newer: number | null; perDay: number | null }>(
|
|
455
|
+
`SELECT contentTypeId AS typeId, preventCleanup AS prevent,
|
|
456
|
+
keepAllVersionsNewerThanDays AS newer, keepLatestVersionPerDayForDays AS perDay
|
|
457
|
+
FROM umbracoContentVersionCleanupPolicy`,
|
|
458
|
+
)
|
|
459
|
+
.map((row) => [Number(row.typeId), row]),
|
|
460
|
+
)
|
|
461
|
+
: new Map()
|
|
462
|
+
|
|
463
|
+
const schemaContentTypes: SchemaContentType[] = []
|
|
464
|
+
const mediaTypes: SchemaContentType[] = []
|
|
465
|
+
const memberTypes: SchemaContentType[] = []
|
|
466
|
+
const strippedElements: string[] = []
|
|
467
|
+
|
|
468
|
+
for (const row of typeRows) {
|
|
469
|
+
const meta = contentTypes.get(Number(row.id)) as SourceContentType
|
|
470
|
+
const kind = meta.kind
|
|
471
|
+
const type: SchemaContentType = {
|
|
472
|
+
key: meta.key,
|
|
473
|
+
alias: meta.alias,
|
|
474
|
+
name: meta.name,
|
|
475
|
+
icon: text(row.icon) ?? 'icon-document',
|
|
476
|
+
allowAtRoot: truthy(row.allowAtRoot),
|
|
477
|
+
isElement: meta.isElement,
|
|
478
|
+
allowInLibrary: false,
|
|
479
|
+
variesByCulture: meta.variesByCulture,
|
|
480
|
+
variesBySegment: (Number(row.variations) & 2) === 2,
|
|
481
|
+
compositions: compositions
|
|
482
|
+
.filter((c) => Number(c.child) === meta.id)
|
|
483
|
+
.flatMap((c) => aliasOf(Number(c.parent), kind) ?? []),
|
|
484
|
+
allowChildren: allowed
|
|
485
|
+
.filter((a) => Number(a.id) === meta.id)
|
|
486
|
+
.flatMap((a) => aliasOf(Number(a.allowed), kind) ?? []),
|
|
487
|
+
components: [],
|
|
488
|
+
cleanup: { prevent: false },
|
|
489
|
+
properties: propertiesByGroup.get(`${meta.id}:`) ?? [],
|
|
490
|
+
tabs: [],
|
|
491
|
+
}
|
|
492
|
+
const description = text(row.description)
|
|
493
|
+
if (description) type.description = description
|
|
494
|
+
const folder = folderOf(Number(row.parentId))
|
|
495
|
+
if (folder) type.folder = folder
|
|
496
|
+
const collection = row.listView ? dataTypeAliasByKey.get(key(row.listView)) : undefined
|
|
497
|
+
if (collection) type.collection = collection
|
|
498
|
+
|
|
499
|
+
if (kind === 'document') {
|
|
500
|
+
for (const link of typeTemplates.filter((t) => Number(t.typeId) === meta.id)) {
|
|
501
|
+
const template = templates.get(Number(link.templateId))
|
|
502
|
+
if (!template) continue
|
|
503
|
+
type.components.push(template.alias)
|
|
504
|
+
if (truthy(link.isDefault)) type.defaultComponent = template.alias
|
|
505
|
+
}
|
|
506
|
+
const policy = cleanup.get(meta.id)
|
|
507
|
+
if (policy) {
|
|
508
|
+
type.cleanup.prevent = truthy(policy.prevent)
|
|
509
|
+
if (policy.newer !== null) type.cleanup.keepAllNewerThanDays = Number(policy.newer)
|
|
510
|
+
if (policy.perDay !== null) type.cleanup.keepLatestPerDayForDays = Number(policy.perDay)
|
|
511
|
+
}
|
|
512
|
+
// An element type is a property bag with no URL; Umbraco tolerates route
|
|
513
|
+
// settings left on one, and the schema validator here does not.
|
|
514
|
+
if (type.isElement) {
|
|
515
|
+
if (type.allowAtRoot || type.components.length > 0 || type.allowChildren.length > 0)
|
|
516
|
+
strippedElements.push(type.alias)
|
|
517
|
+
type.allowAtRoot = false
|
|
518
|
+
type.components = []
|
|
519
|
+
type.defaultComponent = undefined
|
|
520
|
+
type.allowChildren = []
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Tabs and groups. A group inside a tab carries the tab's alias as a prefix.
|
|
525
|
+
const own = groups.filter((g) => Number(g.typeId) === meta.id)
|
|
526
|
+
const tabs = own.filter((g) => Number(g.type) === 1)
|
|
527
|
+
const tabAliases = new Set(tabs.map((tab) => tab.alias ?? ''))
|
|
528
|
+
const toGroup = (g: (typeof own)[number]): SchemaGroup => {
|
|
529
|
+
const group: SchemaGroup = {
|
|
530
|
+
name: g.name ?? '',
|
|
531
|
+
properties: propertiesByGroup.get(`${meta.id}:${g.id}`) ?? [],
|
|
532
|
+
}
|
|
533
|
+
const alias = g.alias?.split('/').at(-1)
|
|
534
|
+
if (alias) group.alias = alias
|
|
535
|
+
return group
|
|
536
|
+
}
|
|
537
|
+
const parentTab = (g: (typeof own)[number]) => {
|
|
538
|
+
const prefix = g.alias?.includes('/') ? g.alias.slice(0, g.alias.lastIndexOf('/')) : undefined
|
|
539
|
+
return prefix !== undefined && tabAliases.has(prefix) ? prefix : undefined
|
|
540
|
+
}
|
|
541
|
+
for (const tab of tabs) {
|
|
542
|
+
const entry: SchemaContentType['tabs'][number] = {
|
|
543
|
+
name: tab.name ?? '',
|
|
544
|
+
properties: propertiesByGroup.get(`${meta.id}:${tab.id}`) ?? [],
|
|
545
|
+
groups: own
|
|
546
|
+
.filter((g) => Number(g.type) === 0 && parentTab(g) === (tab.alias ?? ''))
|
|
547
|
+
.map(toGroup),
|
|
548
|
+
}
|
|
549
|
+
if (tab.alias) entry.alias = tab.alias
|
|
550
|
+
type.tabs.push(entry)
|
|
551
|
+
}
|
|
552
|
+
const orphanGroups = own.filter((g) => Number(g.type) === 0 && parentTab(g) === undefined)
|
|
553
|
+
if (orphanGroups.length > 0) type.groups = orphanGroups.map(toGroup)
|
|
554
|
+
|
|
555
|
+
if (kind === 'document') schemaContentTypes.push(type)
|
|
556
|
+
else if (kind === 'media') {
|
|
557
|
+
// A media type Umbraco ships is written only where the site changed it.
|
|
558
|
+
// Property keys are left out of the comparison: crombolo seeds its own.
|
|
559
|
+
const shipped = BUILT_IN_MEDIA_TYPES.find((t) => t.key === meta.key)
|
|
560
|
+
const present = new Set(
|
|
561
|
+
typeRows.filter((r) => KIND_OF[key(r.objectType)] === 'media').map((r) => r.alias),
|
|
562
|
+
)
|
|
563
|
+
if (!shipped || shapeOf(type) !== shapeOf(builtInMediaTypeSchema(shipped, present)))
|
|
564
|
+
mediaTypes.push(type)
|
|
565
|
+
} else {
|
|
566
|
+
type.components = []
|
|
567
|
+
type.allowChildren = []
|
|
568
|
+
const hasProperties =
|
|
569
|
+
type.properties.length > 0 || type.tabs.length > 0 || (type.groups ?? []).length > 0
|
|
570
|
+
if (meta.key !== SYSTEM_MEMBER_TYPE.key || hasProperties) memberTypes.push(type)
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const set: SchemaSet = {
|
|
575
|
+
version: '1.0.0',
|
|
576
|
+
contentTypes: schemaContentTypes,
|
|
577
|
+
mediaTypes,
|
|
578
|
+
memberTypes,
|
|
579
|
+
dataTypes: dataTypeFiles,
|
|
580
|
+
languages: schemaLanguages,
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
findings.add({
|
|
584
|
+
class: 'migrates',
|
|
585
|
+
code: 'content-types',
|
|
586
|
+
title: 'Document, media and member types',
|
|
587
|
+
count: typeRows.length,
|
|
588
|
+
detail: `${schemaContentTypes.length} content types, ${mediaTypes.length} media types and ${memberTypes.length} member types are written as files; built-in types the site left alone need none.`,
|
|
589
|
+
})
|
|
590
|
+
findings.add({
|
|
591
|
+
class: 'migrates',
|
|
592
|
+
code: 'data-types',
|
|
593
|
+
title: 'Data types',
|
|
594
|
+
count: dataTypes.size,
|
|
595
|
+
detail: `${dataTypeFiles.length} are written as files; the rest are built in and unchanged.`,
|
|
596
|
+
})
|
|
597
|
+
findings.some({
|
|
598
|
+
class: 'migrates',
|
|
599
|
+
code: 'languages',
|
|
600
|
+
title: 'Languages',
|
|
601
|
+
count: schemaLanguages.length,
|
|
602
|
+
items: schemaLanguages.map((l) => l.iso),
|
|
603
|
+
})
|
|
604
|
+
for (const [editor, users] of unsupported) {
|
|
605
|
+
const names = [...dataTypes.values()].filter((d) => d.editor === editor).map((d) => d.name)
|
|
606
|
+
findings.add({
|
|
607
|
+
class: 'needs-a-person',
|
|
608
|
+
code: 'unsupported-editor',
|
|
609
|
+
title: `Property editor ${editor} has no equivalent here`,
|
|
610
|
+
detail: `Data types: ${names.join(', ')}. The stored values are carried across untouched, but the backoffice has no editor for them and templates receive the raw value.`,
|
|
611
|
+
count: users.length,
|
|
612
|
+
items: users,
|
|
613
|
+
})
|
|
614
|
+
}
|
|
615
|
+
findings.some({
|
|
616
|
+
class: 'needs-a-person',
|
|
617
|
+
code: 'element-route-settings',
|
|
618
|
+
title: 'Element types with templates, children or allow-at-root',
|
|
619
|
+
detail: 'An element type is never routed, so these settings were dropped from it.',
|
|
620
|
+
count: strippedElements.length,
|
|
621
|
+
items: strippedElements,
|
|
622
|
+
})
|
|
623
|
+
findings.some({
|
|
624
|
+
class: 'dropped',
|
|
625
|
+
code: 'orphaned-properties',
|
|
626
|
+
title: 'Properties whose data type no longer exists',
|
|
627
|
+
count: orphaned.length,
|
|
628
|
+
items: orphaned,
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
const componentAliases = new Set([...templates.values()].map((t) => t.alias))
|
|
632
|
+
for (const problem of validateSchemaSet(set, { componentAliases }))
|
|
633
|
+
findings.add({
|
|
634
|
+
class: 'blocking',
|
|
635
|
+
code: 'schema-invalid',
|
|
636
|
+
title: `The converted schema is not valid: ${problem.file}`,
|
|
637
|
+
detail: `${problem.path ? `${problem.path}: ` : ''}${problem.message}`,
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
return { set, dataTypes, contentTypes, propertyTypes, templates, languages }
|
|
641
|
+
}
|