@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/inventory.ts
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything in the source that is not schema or content: what the importer
|
|
3
|
+
* carries as files, what it does not import yet, and what has no equivalent.
|
|
4
|
+
*/
|
|
5
|
+
import type { Findings } from './findings.ts'
|
|
6
|
+
import type { ConvertedSchema } from './schema.ts'
|
|
7
|
+
import type { SiteFiles } from './site-files.ts'
|
|
8
|
+
import { key, type Source } from './source.ts'
|
|
9
|
+
import { UPGRADE_STATE_KEY } from './version.ts'
|
|
10
|
+
|
|
11
|
+
/** A dictionary item, in the shape the `.udt` writer takes. */
|
|
12
|
+
export interface DictionaryItem {
|
|
13
|
+
key: string
|
|
14
|
+
name: string
|
|
15
|
+
translations: Array<{ isoCode: string; translation: string }>
|
|
16
|
+
children: DictionaryItem[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A hostname binding, in the shape `domains.toml` holds. */
|
|
20
|
+
export interface DomainEntry {
|
|
21
|
+
/** The page's uuid. */
|
|
22
|
+
node: string
|
|
23
|
+
host: string
|
|
24
|
+
culture?: string
|
|
25
|
+
defaultCulture?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Tables Umbraco itself creates, 15 to 18. Anything else belongs to a package or to the site. */
|
|
29
|
+
const CORE_TABLES = new Set(
|
|
30
|
+
[
|
|
31
|
+
'__EFMigrationsHistory',
|
|
32
|
+
'cmsContentNu',
|
|
33
|
+
'cmsContentType',
|
|
34
|
+
'cmsContentType2ContentType',
|
|
35
|
+
'cmsContentTypeAllowedContentType',
|
|
36
|
+
'cmsDictionary',
|
|
37
|
+
'cmsDocumentType',
|
|
38
|
+
'cmsLanguageText',
|
|
39
|
+
'cmsMember',
|
|
40
|
+
'cmsMember2MemberGroup',
|
|
41
|
+
'cmsMemberType',
|
|
42
|
+
'cmsPropertyType',
|
|
43
|
+
'cmsPropertyTypeGroup',
|
|
44
|
+
'cmsTagRelationship',
|
|
45
|
+
'cmsTags',
|
|
46
|
+
'cmsTemplate',
|
|
47
|
+
...[
|
|
48
|
+
'Access',
|
|
49
|
+
'AccessRule',
|
|
50
|
+
'Audit',
|
|
51
|
+
'CacheInstruction',
|
|
52
|
+
'Consent',
|
|
53
|
+
'Content',
|
|
54
|
+
'ContentSchedule',
|
|
55
|
+
'ContentVersion',
|
|
56
|
+
'ContentVersionCleanupPolicy',
|
|
57
|
+
'ContentVersionCultureVariation',
|
|
58
|
+
'CreatedPackageSchema',
|
|
59
|
+
'DataType',
|
|
60
|
+
'DistributedJob',
|
|
61
|
+
'Document',
|
|
62
|
+
'DocumentCultureVariation',
|
|
63
|
+
'DocumentUrl',
|
|
64
|
+
'DocumentUrlAlias',
|
|
65
|
+
'DocumentVersion',
|
|
66
|
+
'Domain',
|
|
67
|
+
'Element',
|
|
68
|
+
'ElementCultureVariation',
|
|
69
|
+
'ElementVersion',
|
|
70
|
+
'ExternalLogin',
|
|
71
|
+
'ExternalLoginToken',
|
|
72
|
+
'ExternalMember',
|
|
73
|
+
'ExternalMember2MemberGroup',
|
|
74
|
+
'KeyValue',
|
|
75
|
+
'Language',
|
|
76
|
+
'LastSynced',
|
|
77
|
+
'Lock',
|
|
78
|
+
'Log',
|
|
79
|
+
'LogViewerQuery',
|
|
80
|
+
'LongRunningOperation',
|
|
81
|
+
'MediaVersion',
|
|
82
|
+
'Node',
|
|
83
|
+
'OpenIddictApplications',
|
|
84
|
+
'OpenIddictAuthorizations',
|
|
85
|
+
'OpenIddictScopes',
|
|
86
|
+
'OpenIddictTokens',
|
|
87
|
+
'PropertyData',
|
|
88
|
+
'RedirectUrl',
|
|
89
|
+
'Relation',
|
|
90
|
+
'RelationType',
|
|
91
|
+
'RepositoryCacheVersion',
|
|
92
|
+
'Server',
|
|
93
|
+
'TwoFactorLogin',
|
|
94
|
+
'User',
|
|
95
|
+
'User2ClientId',
|
|
96
|
+
'User2NodeNotify',
|
|
97
|
+
'User2UserGroup',
|
|
98
|
+
'UserData',
|
|
99
|
+
'UserGroup',
|
|
100
|
+
'UserGroup2App',
|
|
101
|
+
'UserGroup2GranularPermission',
|
|
102
|
+
'UserGroup2Language',
|
|
103
|
+
'UserGroup2Node',
|
|
104
|
+
'UserGroup2NodePermission',
|
|
105
|
+
'UserGroup2Permission',
|
|
106
|
+
'UserLogin',
|
|
107
|
+
'UserStartNode',
|
|
108
|
+
'Webhook',
|
|
109
|
+
'Webhook2ContentTypeKeys',
|
|
110
|
+
'Webhook2Events',
|
|
111
|
+
'Webhook2Headers',
|
|
112
|
+
'WebhookLog',
|
|
113
|
+
'WebhookRequest',
|
|
114
|
+
].map((name) => `umbraco${name}`),
|
|
115
|
+
].map((name) => name.toLowerCase()),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
/** Packages recognisable from the tables they create. */
|
|
119
|
+
const KNOWN_PACKAGES: Array<[prefix: string, name: string, loss: string]> = [
|
|
120
|
+
[
|
|
121
|
+
'umbracocommerce',
|
|
122
|
+
'Umbraco Commerce',
|
|
123
|
+
'the shop: products’ prices and stock, orders, carts, payment and shipping',
|
|
124
|
+
],
|
|
125
|
+
['vendr', 'Vendr', 'the shop: orders, carts, payment and shipping'],
|
|
126
|
+
['ufforms', 'Umbraco Forms', 'forms and their recorded entries'],
|
|
127
|
+
['ufrecord', 'Umbraco Forms', 'forms and their recorded entries'],
|
|
128
|
+
['ufworkflow', 'Umbraco Forms', 'forms and their recorded entries'],
|
|
129
|
+
['ufuser', 'Umbraco Forms', 'forms and their recorded entries'],
|
|
130
|
+
[
|
|
131
|
+
'umbracodeploy',
|
|
132
|
+
'Umbraco Deploy',
|
|
133
|
+
'deployment between environments; content transfer here is `crombolo content export`',
|
|
134
|
+
],
|
|
135
|
+
['umbracoworkflow', 'Umbraco Workflow', 'approval workflows'],
|
|
136
|
+
['skybrudredirects', 'Skybrud Redirects', 'its redirect rules'],
|
|
137
|
+
['umbracoproductlicense', 'Umbraco product licences', 'nothing; licences do not apply here'],
|
|
138
|
+
['seochecker', 'SEO Checker', 'its redirects and validation data'],
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
const NUGET_NOT_PACKAGES = /^(Umbraco\.Cms(\.|\s|$)|Microsoft\.|System\.)/i
|
|
142
|
+
|
|
143
|
+
export interface Inventory {
|
|
144
|
+
dictionary: DictionaryItem[]
|
|
145
|
+
domains: DomainEntry[]
|
|
146
|
+
/** The paths the source site served, in its default language. */
|
|
147
|
+
urls: string[]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Every published page's path, from the URL segments Umbraco recorded.
|
|
152
|
+
*
|
|
153
|
+
* Covers the first site in the tree, in the default language, with the root
|
|
154
|
+
* page at `/` — Umbraco's default, and how pages are routed here. A page with
|
|
155
|
+
* no template is left out, as Umbraco does not serve one either. It is the
|
|
156
|
+
* list the imported site is checked against, so parity is measured.
|
|
157
|
+
*/
|
|
158
|
+
export function sourceUrls(source: Source): string[] {
|
|
159
|
+
if (!source.hasColumn('umbracoDocumentUrl', 'urlSegment')) return []
|
|
160
|
+
const language = source.one<{ id: number }>(
|
|
161
|
+
'SELECT id FROM umbracoLanguage WHERE isDefaultVariantLang = 1',
|
|
162
|
+
)
|
|
163
|
+
if (!language) return []
|
|
164
|
+
const primary = source.hasColumn('umbracoDocumentUrl', 'isPrimary') ? 'AND u.isPrimary = 1' : ''
|
|
165
|
+
// A page with no template has a URL segment and no URL: Umbraco answers 404 for it.
|
|
166
|
+
const rows = source.all<{
|
|
167
|
+
id: number
|
|
168
|
+
parentId: number
|
|
169
|
+
segment: string
|
|
170
|
+
templateId: number | null
|
|
171
|
+
}>(
|
|
172
|
+
`SELECT n.id AS id, n.parentId AS parentId, u.urlSegment AS segment, dv.templateId AS templateId
|
|
173
|
+
FROM umbracoDocumentUrl u
|
|
174
|
+
JOIN umbracoNode n ON lower(n.uniqueId) = lower(u.uniqueId)
|
|
175
|
+
JOIN umbracoDocument d ON d.nodeId = n.id
|
|
176
|
+
JOIN umbracoContentVersion cv ON cv.nodeId = n.id
|
|
177
|
+
JOIN umbracoDocumentVersion dv ON dv.id = cv.id AND dv.published = 1
|
|
178
|
+
WHERE u.isDraft = 0 AND u.languageId = ? AND d.published = 1 AND n.trashed = 0 ${primary}
|
|
179
|
+
ORDER BY n.level, n.sortOrder, n.id`,
|
|
180
|
+
language.id,
|
|
181
|
+
)
|
|
182
|
+
const byId = new Map(rows.map((row) => [Number(row.id), row]))
|
|
183
|
+
const root = rows.find((row) => Number(row.parentId) === -1)
|
|
184
|
+
if (!root) return []
|
|
185
|
+
const urls: string[] = []
|
|
186
|
+
for (const row of rows) {
|
|
187
|
+
const segments: string[] = []
|
|
188
|
+
let at: (typeof rows)[number] | undefined = row
|
|
189
|
+
for (let depth = 0; at && Number(at.id) !== Number(root.id) && depth < 64; depth++) {
|
|
190
|
+
segments.unshift(at.segment)
|
|
191
|
+
at = byId.get(Number(at.parentId))
|
|
192
|
+
}
|
|
193
|
+
// Reached the root page, so it is part of this site and every ancestor is published.
|
|
194
|
+
if (at && row.templateId !== null) urls.push(`/${segments.join('/')}`)
|
|
195
|
+
}
|
|
196
|
+
return urls
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function takeInventory(
|
|
200
|
+
source: Source,
|
|
201
|
+
schema: ConvertedSchema,
|
|
202
|
+
files: SiteFiles | undefined,
|
|
203
|
+
findings: Findings,
|
|
204
|
+
): Inventory {
|
|
205
|
+
// Dictionary
|
|
206
|
+
const dictionary: DictionaryItem[] = []
|
|
207
|
+
let dictionaryCount = 0
|
|
208
|
+
if (source.has('cmsDictionary')) {
|
|
209
|
+
const texts = source.has('cmsLanguageText')
|
|
210
|
+
? source.all<{ languageId: number; id: string; value: string | null }>(
|
|
211
|
+
'SELECT languageId, UniqueId AS id, value FROM cmsLanguageText',
|
|
212
|
+
)
|
|
213
|
+
: []
|
|
214
|
+
const rows = source.all<{ id: string; parent: string | null; name: string }>(
|
|
215
|
+
'SELECT id, parent, "key" AS name FROM cmsDictionary ORDER BY "key"',
|
|
216
|
+
)
|
|
217
|
+
dictionaryCount = rows.length
|
|
218
|
+
const items = new Map<string, DictionaryItem>()
|
|
219
|
+
for (const row of rows)
|
|
220
|
+
items.set(key(row.id), {
|
|
221
|
+
key: key(row.id),
|
|
222
|
+
name: row.name,
|
|
223
|
+
translations: texts
|
|
224
|
+
.filter((t) => key(t.id) === key(row.id) && t.value !== null)
|
|
225
|
+
.flatMap((t) => {
|
|
226
|
+
const iso = schema.languages.get(Number(t.languageId))?.iso
|
|
227
|
+
return iso ? [{ isoCode: iso, translation: t.value as string }] : []
|
|
228
|
+
}),
|
|
229
|
+
children: [],
|
|
230
|
+
})
|
|
231
|
+
for (const row of rows) {
|
|
232
|
+
const item = items.get(key(row.id)) as DictionaryItem
|
|
233
|
+
const parent = row.parent ? items.get(key(row.parent)) : undefined
|
|
234
|
+
if (parent) parent.children.push(item)
|
|
235
|
+
else dictionary.push(item)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
findings.some({
|
|
239
|
+
class: 'migrates',
|
|
240
|
+
code: 'dictionary',
|
|
241
|
+
title: 'Dictionary items',
|
|
242
|
+
detail: 'Written to import/dictionary.udt; `crombolo dictionary import` applies it.',
|
|
243
|
+
count: dictionaryCount,
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
// Hostnames
|
|
247
|
+
const domains: DomainEntry[] = []
|
|
248
|
+
if (source.has('umbracoDomain')) {
|
|
249
|
+
const nodeKeys = new Map(
|
|
250
|
+
source
|
|
251
|
+
.all<{ id: number; key: string }>('SELECT id, uniqueId AS key FROM umbracoNode')
|
|
252
|
+
.map((row) => [Number(row.id), key(row.key)]),
|
|
253
|
+
)
|
|
254
|
+
for (const row of source.all<{
|
|
255
|
+
languageId: number | null
|
|
256
|
+
nodeId: number | null
|
|
257
|
+
name: string
|
|
258
|
+
}>(
|
|
259
|
+
'SELECT domainDefaultLanguage AS languageId, domainRootStructureID AS nodeId, domainName AS name FROM umbracoDomain ORDER BY sortOrder, id',
|
|
260
|
+
)) {
|
|
261
|
+
const node = row.nodeId === null ? undefined : nodeKeys.get(Number(row.nodeId))
|
|
262
|
+
if (!node) continue
|
|
263
|
+
const culture =
|
|
264
|
+
row.languageId === null ? undefined : schema.languages.get(Number(row.languageId))?.iso
|
|
265
|
+
// A name starting `*` is Umbraco's way of setting a node's culture without a hostname.
|
|
266
|
+
if (row.name.startsWith('*')) domains.push({ node, host: '', defaultCulture: culture })
|
|
267
|
+
else domains.push({ node, host: row.name, culture })
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
findings.some({
|
|
271
|
+
class: 'needs-a-person',
|
|
272
|
+
code: 'domains',
|
|
273
|
+
title: 'Hostnames and cultures',
|
|
274
|
+
detail:
|
|
275
|
+
'Written to import/domains.toml, not applied: they are the source site’s hostnames. Review them, then move the file to the site root.',
|
|
276
|
+
count: domains.length,
|
|
277
|
+
items: domains.map((d) => d.host || `(culture ${d.defaultCulture ?? 'default'})`),
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// What a later version of the importer could carry, and this one does not.
|
|
281
|
+
const notYet = (code: string, title: string, count: number, detail: string) =>
|
|
282
|
+
findings.some({ class: 'not-yet', code, title, count, detail })
|
|
283
|
+
notYet(
|
|
284
|
+
'members',
|
|
285
|
+
'Members',
|
|
286
|
+
source.count('cmsMember'),
|
|
287
|
+
'Members, their groups and their passwords are not imported. Member types are.',
|
|
288
|
+
)
|
|
289
|
+
notYet(
|
|
290
|
+
'users',
|
|
291
|
+
'Backoffice users',
|
|
292
|
+
source.count('umbracoUser', 'id > 0'),
|
|
293
|
+
'Users and user groups are not imported. `crombolo start` creates an administrator.',
|
|
294
|
+
)
|
|
295
|
+
notYet(
|
|
296
|
+
'redirects',
|
|
297
|
+
'Redirects',
|
|
298
|
+
source.count('umbracoRedirectUrl'),
|
|
299
|
+
'Redirects Umbraco recorded when pages moved are not imported.',
|
|
300
|
+
)
|
|
301
|
+
notYet(
|
|
302
|
+
'public-access',
|
|
303
|
+
'Protected pages',
|
|
304
|
+
source.count('umbracoAccess'),
|
|
305
|
+
'Public access rules are not imported: these pages will be public until the rules are set again.',
|
|
306
|
+
)
|
|
307
|
+
notYet(
|
|
308
|
+
'schedules',
|
|
309
|
+
'Scheduled publishes',
|
|
310
|
+
source.count('umbracoContentSchedule'),
|
|
311
|
+
'Pending publish and unpublish dates are not imported.',
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
// What has no equivalent.
|
|
315
|
+
findings.some({
|
|
316
|
+
class: 'cannot-migrate',
|
|
317
|
+
code: 'webhooks',
|
|
318
|
+
title: 'Webhooks',
|
|
319
|
+
count: source.count('umbracoWebhook'),
|
|
320
|
+
})
|
|
321
|
+
findings.some({
|
|
322
|
+
class: 'cannot-migrate',
|
|
323
|
+
code: 'external-logins',
|
|
324
|
+
title: 'External and two-factor logins',
|
|
325
|
+
count: source.count('umbracoExternalLogin') + source.count('umbracoTwoFactorLogin'),
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// Packages, from the tables they made and the migration plans they recorded.
|
|
329
|
+
const packages = new Map<string, { loss?: string; tables: string[] }>()
|
|
330
|
+
const other: string[] = []
|
|
331
|
+
for (const table of source.tables) {
|
|
332
|
+
const lower = table.toLowerCase()
|
|
333
|
+
if (CORE_TABLES.has(lower)) continue
|
|
334
|
+
const known = KNOWN_PACKAGES.find(([prefix]) => lower.startsWith(prefix))
|
|
335
|
+
if (!known) {
|
|
336
|
+
other.push(table)
|
|
337
|
+
continue
|
|
338
|
+
}
|
|
339
|
+
const entry = packages.get(known[1]) ?? { loss: known[2], tables: [] }
|
|
340
|
+
entry.tables.push(table)
|
|
341
|
+
packages.set(known[1], entry)
|
|
342
|
+
}
|
|
343
|
+
for (const [name, entry] of packages)
|
|
344
|
+
findings.add({
|
|
345
|
+
class: 'cannot-migrate',
|
|
346
|
+
code: 'package',
|
|
347
|
+
title: `Package: ${name}`,
|
|
348
|
+
detail: `Packages do not run here. What goes with it: ${entry.loss}. Its ${entry.tables.length} table(s) are not imported.`,
|
|
349
|
+
count: entry.tables.length,
|
|
350
|
+
items: entry.tables,
|
|
351
|
+
})
|
|
352
|
+
findings.some({
|
|
353
|
+
class: 'cannot-migrate',
|
|
354
|
+
code: 'other-tables',
|
|
355
|
+
title: 'Tables that are not Umbraco’s own',
|
|
356
|
+
detail: 'Created by a package or by the site’s own code. They are not imported.',
|
|
357
|
+
count: other.length,
|
|
358
|
+
items: other,
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
if (source.has('umbracoKeyValue')) {
|
|
362
|
+
const plans = source
|
|
363
|
+
.all<{ key: string }>(
|
|
364
|
+
`SELECT "key" AS key FROM umbracoKeyValue WHERE "key" LIKE 'Umbraco.Core.Upgrader.State+%'`,
|
|
365
|
+
)
|
|
366
|
+
.map((row) => row.key.slice('Umbraco.Core.Upgrader.State+'.length))
|
|
367
|
+
.filter(
|
|
368
|
+
(name) =>
|
|
369
|
+
`Umbraco.Core.Upgrader.State+${name}` !== UPGRADE_STATE_KEY &&
|
|
370
|
+
name !== 'Umbraco.Core.Premigrations',
|
|
371
|
+
)
|
|
372
|
+
findings.some({
|
|
373
|
+
class: 'cannot-migrate',
|
|
374
|
+
code: 'package-migrations',
|
|
375
|
+
title: 'Packages that have run migrations on this database',
|
|
376
|
+
detail: 'Each has installed something server-side. None of it runs here.',
|
|
377
|
+
count: plans.length,
|
|
378
|
+
items: plans,
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
findings.some({
|
|
382
|
+
class: 'cannot-migrate',
|
|
383
|
+
code: 'created-packages',
|
|
384
|
+
title: 'Packages created in the backoffice',
|
|
385
|
+
count: source.count('umbracoCreatedPackageSchema'),
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
// What only the site's files can say.
|
|
389
|
+
if (files) {
|
|
390
|
+
const nuget = files.packages.filter((name) => !NUGET_NOT_PACKAGES.test(name))
|
|
391
|
+
findings.some({
|
|
392
|
+
class: 'cannot-migrate',
|
|
393
|
+
code: 'nuget-packages',
|
|
394
|
+
title: 'NuGet packages the project references',
|
|
395
|
+
detail:
|
|
396
|
+
'.NET packages do not run here; each one’s behaviour has to be replaced or done without.',
|
|
397
|
+
count: nuget.length,
|
|
398
|
+
items: nuget,
|
|
399
|
+
})
|
|
400
|
+
findings.some({
|
|
401
|
+
class: 'cannot-migrate',
|
|
402
|
+
code: 'app-plugins',
|
|
403
|
+
title: 'Backoffice plugins in App_Plugins',
|
|
404
|
+
detail:
|
|
405
|
+
'A plugin that is client-side only may work if copied into the new site’s App_Plugins; one with a server side will not.',
|
|
406
|
+
count: files.plugins.length,
|
|
407
|
+
items: files.plugins,
|
|
408
|
+
})
|
|
409
|
+
findings.some({
|
|
410
|
+
class: 'cannot-migrate',
|
|
411
|
+
code: 'custom-code',
|
|
412
|
+
title: 'C# source files',
|
|
413
|
+
detail: 'Controllers, composers, notification handlers and view models have to be rewritten.',
|
|
414
|
+
count: files.csharp,
|
|
415
|
+
})
|
|
416
|
+
} else {
|
|
417
|
+
findings.add({
|
|
418
|
+
class: 'needs-a-person',
|
|
419
|
+
code: 'no-site-files',
|
|
420
|
+
title: 'The site’s files were not given',
|
|
421
|
+
detail:
|
|
422
|
+
'Without --site the Razor views, media files, plugins and custom code could not be inspected. Views are stubbed from the database, and media has no files behind it.',
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return { dictionary, domains, urls: sourceUrls(source) }
|
|
427
|
+
}
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** The compatibility report: what will come across, and what will not, before anything is written. */
|
|
2
|
+
import { CLASS_ORDER, CLASS_TITLES, type Finding, type FindingClass } from './findings.ts'
|
|
3
|
+
import type { SourceKind } from './source.ts'
|
|
4
|
+
import type { DetectedVersion } from './version.ts'
|
|
5
|
+
|
|
6
|
+
export interface ImportReport {
|
|
7
|
+
generatedAt: string
|
|
8
|
+
source: {
|
|
9
|
+
path: string
|
|
10
|
+
kind: SourceKind
|
|
11
|
+
exportedAt?: string
|
|
12
|
+
serverVersion?: string
|
|
13
|
+
/** Whether the site's files were inspected as well as its database. */
|
|
14
|
+
siteFiles: boolean
|
|
15
|
+
}
|
|
16
|
+
umbraco: DetectedVersion
|
|
17
|
+
/** Whether `apply` would run: no blocking finding. */
|
|
18
|
+
ready: boolean
|
|
19
|
+
counts: Record<FindingClass, number>
|
|
20
|
+
findings: Finding[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ITEM_LIMIT = 25
|
|
24
|
+
|
|
25
|
+
export function buildReport(
|
|
26
|
+
source: ImportReport['source'],
|
|
27
|
+
umbraco: DetectedVersion,
|
|
28
|
+
findings: readonly Finding[],
|
|
29
|
+
): ImportReport {
|
|
30
|
+
const counts = Object.fromEntries(
|
|
31
|
+
CLASS_ORDER.map((name) => [name, findings.filter((f) => f.class === name).length]),
|
|
32
|
+
) as Record<FindingClass, number>
|
|
33
|
+
const ordered = [...findings].sort(
|
|
34
|
+
(a, b) => CLASS_ORDER.indexOf(a.class) - CLASS_ORDER.indexOf(b.class),
|
|
35
|
+
)
|
|
36
|
+
return {
|
|
37
|
+
generatedAt: new Date().toISOString(),
|
|
38
|
+
source,
|
|
39
|
+
umbraco,
|
|
40
|
+
ready: counts.blocking === 0,
|
|
41
|
+
counts,
|
|
42
|
+
findings: ordered,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function reportJson(report: ImportReport): string {
|
|
47
|
+
return `${JSON.stringify(report, null, 2)}\n`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const INTRO: Record<FindingClass, string> = {
|
|
51
|
+
blocking: 'The import will not run until these are dealt with.',
|
|
52
|
+
'needs-a-person':
|
|
53
|
+
'These come across, but somebody has work to do before they behave as they did.',
|
|
54
|
+
'cannot-migrate': 'These have no equivalent in Crombolo. Nothing is imported for them.',
|
|
55
|
+
'not-yet': 'These could be imported, but this version of the importer does not do it.',
|
|
56
|
+
dropped: 'These are left behind on purpose.',
|
|
57
|
+
migrates: 'These are converted, with nothing to do.',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function reportMarkdown(report: ImportReport): string {
|
|
61
|
+
const { umbraco, source } = report
|
|
62
|
+
const lines: string[] = ['# Umbraco import report', '']
|
|
63
|
+
lines.push(
|
|
64
|
+
`- **Source:** \`${source.path}\` (${source.kind === 'bacpac' ? '.bacpac' : 'SQLite'})`,
|
|
65
|
+
)
|
|
66
|
+
lines.push(
|
|
67
|
+
`- **Umbraco version:** ${umbraco.version ?? 'not recognised'}${umbraco.state ? ` (upgrade state \`${umbraco.state}\`)` : ''}`,
|
|
68
|
+
)
|
|
69
|
+
if (source.exportedAt) lines.push(`- **Exported:** ${source.exportedAt}`)
|
|
70
|
+
if (source.serverVersion) lines.push(`- **Database server:** ${source.serverVersion}`)
|
|
71
|
+
lines.push(`- **Site files inspected:** ${source.siteFiles ? 'yes' : 'no'}`)
|
|
72
|
+
lines.push(
|
|
73
|
+
`- **Verdict:** ${report.ready ? 'ready to import' : `blocked by ${report.counts.blocking} finding(s)`}`,
|
|
74
|
+
'',
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
for (const name of CLASS_ORDER) {
|
|
78
|
+
const findings = report.findings.filter((finding) => finding.class === name)
|
|
79
|
+
if (findings.length === 0) continue
|
|
80
|
+
lines.push(`## ${CLASS_TITLES[name]}`, '', INTRO[name], '')
|
|
81
|
+
for (const finding of findings) {
|
|
82
|
+
const count = finding.count === undefined ? '' : ` (${finding.count})`
|
|
83
|
+
lines.push(`### ${finding.title}${count}`, '')
|
|
84
|
+
if (finding.detail) lines.push(finding.detail, '')
|
|
85
|
+
const items = finding.items ?? []
|
|
86
|
+
for (const item of items.slice(0, ITEM_LIMIT)) lines.push(`- ${item}`)
|
|
87
|
+
if (items.length > ITEM_LIMIT) lines.push(`- … and ${items.length - ITEM_LIMIT} more`)
|
|
88
|
+
if (items.length > 0) lines.push('')
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return `${lines.join('\n').trimEnd()}\n`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The report as a few lines for a terminal. */
|
|
95
|
+
export function reportSummary(report: ImportReport): string[] {
|
|
96
|
+
const lines = [
|
|
97
|
+
`Umbraco ${report.umbraco.version ?? '(version not recognised)'}, from a ${report.source.kind === 'bacpac' ? '.bacpac' : 'SQLite database'}`,
|
|
98
|
+
]
|
|
99
|
+
for (const name of CLASS_ORDER) {
|
|
100
|
+
const findings = report.findings.filter((finding) => finding.class === name)
|
|
101
|
+
if (findings.length === 0) continue
|
|
102
|
+
lines.push('', `${CLASS_TITLES[name]}:`)
|
|
103
|
+
for (const finding of findings)
|
|
104
|
+
lines.push(` ${finding.title}${finding.count === undefined ? '' : ` (${finding.count})`}`)
|
|
105
|
+
}
|
|
106
|
+
return lines
|
|
107
|
+
}
|