@crombolo/import-umbraco 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 +35 -4
- package/src/content.ts +435 -0
- package/src/findings.ts +61 -0
- package/src/index.ts +341 -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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kineco
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
crombolo
|
|
2
|
+
Copyright (c) 2026 Kineco
|
|
3
|
+
|
|
4
|
+
This product includes third-party software. crombolo is an independent project.
|
|
5
|
+
It is not affiliated with, endorsed by, or sponsored by Umbraco A/S, which owns
|
|
6
|
+
the Umbraco trademark.
|
|
7
|
+
|
|
8
|
+
There are three places third-party material is redistributed, rather than merely
|
|
9
|
+
depended on:
|
|
10
|
+
|
|
11
|
+
@crombolo/backoffice-dist the built Umbraco backoffice and the browser
|
|
12
|
+
packages bundled into it, produced by
|
|
13
|
+
`bun run vendor:backoffice`
|
|
14
|
+
@crombolo/backoffice-dist three CSS files copied unmodified from the Umbraco
|
|
15
|
+
client source tree
|
|
16
|
+
@crombolo/contracts the Umbraco Management API OpenAPI document,
|
|
17
|
+
copied unmodified
|
|
18
|
+
|
|
19
|
+
No Umbraco trademark is redistributed. The Umbraco wordmark, the "U" roundel
|
|
20
|
+
favicon and the installer illustration are deliberately not carried in this
|
|
21
|
+
repository: MIT grants copyright permission and says nothing about trademarks, so
|
|
22
|
+
crombolo's own marks are served at those paths instead. See `BRANDED_ASSETS` in
|
|
23
|
+
`@crombolo/backoffice-host`.
|
|
24
|
+
|
|
25
|
+
The Umbraco backoffice
|
|
26
|
+
----------------------
|
|
27
|
+
@umbraco-cms/backoffice 18.2.0 — MIT — https://github.com/umbraco/Umbraco-CMS
|
|
28
|
+
@umbraco-ui/uui 2.0.2 — MIT — https://github.com/umbraco/Umbraco.UI
|
|
29
|
+
|
|
30
|
+
Static files from the Umbraco client source tree
|
|
31
|
+
------------------------------------------------
|
|
32
|
+
css/umb-css.css, css/rte-content.css, css/umbraco-blockgridlayout.css
|
|
33
|
+
— copied unmodified from Umbraco-CMS release-18.2.0 — MIT
|
|
34
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
35
|
+
Shipped in @crombolo/backoffice-dist under `upstream-static/`.
|
|
36
|
+
|
|
37
|
+
`rte-content.css` itself carries Tiptap's default editor styles, copied from
|
|
38
|
+
tiptap v2.11.7 (MIT); the file records this in its own header.
|
|
39
|
+
|
|
40
|
+
The Umbraco Management API contract
|
|
41
|
+
-----------------------------------
|
|
42
|
+
OpenApi.json — copied unmodified from Umbraco-CMS release-18.2.0
|
|
43
|
+
(src/Umbraco.Cms.Api.Management/OpenApi.json) — MIT
|
|
44
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
45
|
+
Shipped in @crombolo/contracts, which also ships the TypeScript types generated
|
|
46
|
+
from it.
|
|
47
|
+
|
|
48
|
+
Browser runtime dependencies bundled into the backoffice build
|
|
49
|
+
--------------------------------------------------------------
|
|
50
|
+
@heximal/expressions 0.1.5 — BSD-3-Clause
|
|
51
|
+
@microsoft/signalr 10.0.11 — MIT
|
|
52
|
+
@tiptap/* 3.31.3 — MIT
|
|
53
|
+
diff 9.0.0 — BSD-3-Clause
|
|
54
|
+
dompurify 3.4.16 — Apache-2.0, elected from "MPL-2.0 OR Apache-2.0"
|
|
55
|
+
element-internals-polyfill 3.0.2 — MIT
|
|
56
|
+
lit 3.3.3 — BSD-3-Clause
|
|
57
|
+
luxon 3.7.2 — MIT
|
|
58
|
+
marked 18.0.14 — MIT
|
|
59
|
+
monaco-editor 0.57.0 — MIT
|
|
60
|
+
rxjs 7.8.2 — Apache-2.0
|
|
61
|
+
uuid 14.0.2 — MIT
|
|
62
|
+
|
|
63
|
+
dompurify is offered under either licence; Apache-2.0 is elected here, so no
|
|
64
|
+
MPL-2.0 obligation attaches to the copy bundled into the backoffice build. Of the
|
|
65
|
+
Apache-2.0 packages above, none ships a NOTICE file of its own, so Apache-2.0
|
|
66
|
+
section 4(d) adds nothing to this file.
|
|
67
|
+
|
|
68
|
+
Each package's own licence text ships inside its npm distribution and applies
|
|
69
|
+
unchanged to the copies bundled here.
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/import-umbraco",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Imports an existing Umbraco site into crombolo: a compatibility report, then schema files, a content bundle and view stubs.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Crombolo",
|
|
7
|
+
"homepage": "https://github.com/kineco-au/crombolo-cms#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kineco-au/crombolo-cms.git",
|
|
11
|
+
"directory": "packages/import-umbraco"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/kineco-au/crombolo-cms/issues"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"bun": ">=1.3.0"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./src/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"NOTICE"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@crombolo/core": "0.10.0",
|
|
32
|
+
"@crombolo/data": "0.10.0",
|
|
33
|
+
"@crombolo/schema": "0.10.0",
|
|
34
|
+
"@crombolo/transfer": "0.10.0",
|
|
35
|
+
"@kineco-au/bacpac-importer": "^0.1.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/content.ts
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Umbraco's content → a bundle, the artifact `content export` writes and
|
|
3
|
+
* `content import` already knows how to apply.
|
|
4
|
+
*
|
|
5
|
+
* The snapshot rule is the exporter's: a published page carries its published
|
|
6
|
+
* values, an unpublished one its draft, and `drafts` takes the draft throughout.
|
|
7
|
+
*/
|
|
8
|
+
import { ObjectTypes, toEditorValue } from '@crombolo/core'
|
|
9
|
+
import { hashSchemaSet } from '@crombolo/schema'
|
|
10
|
+
import {
|
|
11
|
+
BUNDLE_FORMAT_VERSION,
|
|
12
|
+
type BundleBlob,
|
|
13
|
+
type BundleKind,
|
|
14
|
+
type BundleNode,
|
|
15
|
+
type BundleSnapshot,
|
|
16
|
+
type BundleValue,
|
|
17
|
+
type BundleVariant,
|
|
18
|
+
type ContentSet,
|
|
19
|
+
referenceCandidates,
|
|
20
|
+
} from '@crombolo/transfer'
|
|
21
|
+
import type { Findings } from './findings.ts'
|
|
22
|
+
import type { ConvertedSchema } from './schema.ts'
|
|
23
|
+
import { key, type Source, wallClock } from './source.ts'
|
|
24
|
+
|
|
25
|
+
const KIND_OF: Record<string, BundleKind> = {
|
|
26
|
+
[ObjectTypes.Document]: 'document',
|
|
27
|
+
[ObjectTypes.Media]: 'media',
|
|
28
|
+
[ObjectTypes.Element]: 'element',
|
|
29
|
+
[ObjectTypes.DocumentBlueprint]: 'blueprint',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Editors that hold a file rather than a reference to one. */
|
|
33
|
+
const FILE_EDITORS = new Set(['Umbraco.ImageCropper', 'Umbraco.UploadField'])
|
|
34
|
+
|
|
35
|
+
const COLUMN_OF: Record<string, ValueColumn> = {
|
|
36
|
+
Integer: 'intValue',
|
|
37
|
+
Decimal: 'decimalValue',
|
|
38
|
+
Date: 'dateValue',
|
|
39
|
+
Nvarchar: 'varcharValue',
|
|
40
|
+
Ntext: 'textValue',
|
|
41
|
+
}
|
|
42
|
+
type ValueColumn = 'intValue' | 'decimalValue' | 'dateValue' | 'varcharValue' | 'textValue'
|
|
43
|
+
const COLUMNS: readonly ValueColumn[] = [
|
|
44
|
+
'textValue',
|
|
45
|
+
'varcharValue',
|
|
46
|
+
'intValue',
|
|
47
|
+
'decimalValue',
|
|
48
|
+
'dateValue',
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
export interface ConvertContentOptions {
|
|
52
|
+
snapshot: BundleSnapshot
|
|
53
|
+
siteName: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SourceBlob {
|
|
57
|
+
/** The media store key: `ab12cd34/photo.jpg`. */
|
|
58
|
+
key: string
|
|
59
|
+
/** The media node that names it. */
|
|
60
|
+
node: string
|
|
61
|
+
name: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ConvertedContent {
|
|
65
|
+
set: ContentSet
|
|
66
|
+
blobs: SourceBlob[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const truthy = (value: unknown): boolean => value === 1 || value === true || value === '1'
|
|
70
|
+
|
|
71
|
+
/** `/media/ab12cd34/photo.jpg` → `ab12cd34/photo.jpg`. */
|
|
72
|
+
export function storeKey(src: unknown): string | undefined {
|
|
73
|
+
if (typeof src !== 'string') return undefined
|
|
74
|
+
return /^\/?media\/(.+)$/.exec(src.split('?')[0] as string)?.[1]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function blobKeyOf(value: unknown): string | undefined {
|
|
78
|
+
if (typeof value === 'string') {
|
|
79
|
+
if (value.trimStart().startsWith('{')) {
|
|
80
|
+
try {
|
|
81
|
+
return blobKeyOf(JSON.parse(value))
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return storeKey(value)
|
|
87
|
+
}
|
|
88
|
+
if (typeof value === 'object' && value !== null)
|
|
89
|
+
return storeKey((value as Record<string, unknown>).src)
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type ValueRow = {
|
|
94
|
+
versionId: number
|
|
95
|
+
propertyTypeId: number
|
|
96
|
+
languageId: number | null
|
|
97
|
+
segment: string | null
|
|
98
|
+
} & Record<ValueColumn, unknown>
|
|
99
|
+
|
|
100
|
+
/** What the editor would be handed for a stored cell. */
|
|
101
|
+
function editorValue(editor: string, dbType: string, row: ValueRow): unknown {
|
|
102
|
+
// The declared column first; an older database may still hold the value in
|
|
103
|
+
// another, as checkbox lists did before 17 moved them.
|
|
104
|
+
const declared = COLUMN_OF[dbType]
|
|
105
|
+
const column =
|
|
106
|
+
declared && row[declared] !== null && row[declared] !== undefined
|
|
107
|
+
? declared
|
|
108
|
+
: COLUMNS.find((c) => row[c] !== null && row[c] !== undefined)
|
|
109
|
+
if (!column) return null
|
|
110
|
+
const raw = row[column]
|
|
111
|
+
switch (column) {
|
|
112
|
+
case 'dateValue':
|
|
113
|
+
// Wall-clock time, as the date pickers store and show it.
|
|
114
|
+
return wallClock(raw)
|
|
115
|
+
case 'decimalValue':
|
|
116
|
+
return Number(raw)
|
|
117
|
+
case 'intValue':
|
|
118
|
+
return editor === 'Umbraco.TrueFalse' ? truthy(raw) : Number(raw)
|
|
119
|
+
default:
|
|
120
|
+
return toEditorValue(editor, raw)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function convertContent(
|
|
125
|
+
source: Source,
|
|
126
|
+
schema: ConvertedSchema,
|
|
127
|
+
findings: Findings,
|
|
128
|
+
options: ConvertContentOptions,
|
|
129
|
+
): ConvertedContent {
|
|
130
|
+
const nodes = source
|
|
131
|
+
.all<{
|
|
132
|
+
id: number
|
|
133
|
+
key: string
|
|
134
|
+
parentId: number
|
|
135
|
+
level: number
|
|
136
|
+
sortOrder: number
|
|
137
|
+
trashed: unknown
|
|
138
|
+
name: string | null
|
|
139
|
+
objectType: string
|
|
140
|
+
contentTypeId: number
|
|
141
|
+
}>(
|
|
142
|
+
`SELECT n.id AS id, n.uniqueId AS key, n.parentId AS parentId, n.level AS level,
|
|
143
|
+
n.sortOrder AS sortOrder, n.trashed AS trashed, n."text" AS name,
|
|
144
|
+
n.nodeObjectType AS objectType, c.contentTypeId AS contentTypeId
|
|
145
|
+
FROM umbracoNode n JOIN umbracoContent c ON c.nodeId = n.id
|
|
146
|
+
ORDER BY n.level, n.sortOrder, n.id`,
|
|
147
|
+
)
|
|
148
|
+
.filter((row) => KIND_OF[key(row.objectType)] !== undefined)
|
|
149
|
+
|
|
150
|
+
const live = nodes.filter((row) => !truthy(row.trashed))
|
|
151
|
+
const carriedIds = new Map(live.map((row) => [Number(row.id), key(row.key)]))
|
|
152
|
+
const carriedKeys = new Set(carriedIds.values())
|
|
153
|
+
const everyKey = new Set(
|
|
154
|
+
source.all<{ key: string }>('SELECT uniqueId AS key FROM umbracoNode').map((r) => key(r.key)),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
// Published state, per kind that has one.
|
|
158
|
+
const state = new Map<number, { published: boolean; edited: boolean }>()
|
|
159
|
+
const readState = (table: string) => {
|
|
160
|
+
if (!source.has(table)) return
|
|
161
|
+
for (const row of source.all<{ nodeId: number; published: unknown; edited: unknown }>(
|
|
162
|
+
`SELECT nodeId, published, edited FROM ${table}`,
|
|
163
|
+
))
|
|
164
|
+
state.set(Number(row.nodeId), {
|
|
165
|
+
published: truthy(row.published),
|
|
166
|
+
edited: truthy(row.edited),
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
readState('umbracoDocument')
|
|
170
|
+
readState('umbracoElement')
|
|
171
|
+
|
|
172
|
+
// The current and the published version of every node.
|
|
173
|
+
const elementVersions = source.has('umbracoElementVersion')
|
|
174
|
+
const versions = source.all<{
|
|
175
|
+
id: number
|
|
176
|
+
nodeId: number
|
|
177
|
+
current: unknown
|
|
178
|
+
name: string | null
|
|
179
|
+
published: unknown
|
|
180
|
+
templateId: number | null
|
|
181
|
+
elementPublished?: unknown
|
|
182
|
+
}>(
|
|
183
|
+
`SELECT cv.id AS id, cv.nodeId AS nodeId, cv."current" AS current, cv."text" AS name,
|
|
184
|
+
dv.published AS published, dv.templateId AS templateId
|
|
185
|
+
${elementVersions ? ', ev.published AS elementPublished' : ''}
|
|
186
|
+
FROM umbracoContentVersion cv
|
|
187
|
+
LEFT JOIN umbracoDocumentVersion dv ON dv.id = cv.id
|
|
188
|
+
${elementVersions ? 'LEFT JOIN umbracoElementVersion ev ON ev.id = cv.id' : ''}
|
|
189
|
+
WHERE cv."current" = 1 OR dv.published = 1 ${elementVersions ? 'OR ev.published = 1' : ''}`,
|
|
190
|
+
)
|
|
191
|
+
const currentOf = new Map<number, (typeof versions)[number]>()
|
|
192
|
+
const publishedOf = new Map<number, (typeof versions)[number]>()
|
|
193
|
+
for (const version of versions) {
|
|
194
|
+
if (truthy(version.current)) currentOf.set(Number(version.nodeId), version)
|
|
195
|
+
if (truthy(version.published) || truthy(version.elementPublished))
|
|
196
|
+
publishedOf.set(Number(version.nodeId), version)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Which version each node travels as.
|
|
200
|
+
const chosen = new Map<number, (typeof versions)[number]>()
|
|
201
|
+
let pendingChanges = 0
|
|
202
|
+
for (const row of live) {
|
|
203
|
+
const id = Number(row.id)
|
|
204
|
+
const nodeState = state.get(id)
|
|
205
|
+
const published = nodeState?.published ? publishedOf.get(id) : undefined
|
|
206
|
+
if (published && nodeState?.edited && options.snapshot === 'published') pendingChanges++
|
|
207
|
+
const version =
|
|
208
|
+
options.snapshot === 'published' && published ? published : (currentOf.get(id) ?? published)
|
|
209
|
+
if (version) chosen.set(id, version)
|
|
210
|
+
}
|
|
211
|
+
const versionIds = new Set([...chosen.values()].map((v) => Number(v.id)))
|
|
212
|
+
|
|
213
|
+
const valuesByVersion = new Map<number, ValueRow[]>()
|
|
214
|
+
for (const row of source.all<ValueRow>(
|
|
215
|
+
`SELECT versionId, propertyTypeId, languageId, segment, intValue, decimalValue, dateValue,
|
|
216
|
+
varcharValue, textValue FROM umbracoPropertyData`,
|
|
217
|
+
)) {
|
|
218
|
+
const versionId = Number(row.versionId)
|
|
219
|
+
if (!versionIds.has(versionId)) continue
|
|
220
|
+
const list = valuesByVersion.get(versionId) ?? []
|
|
221
|
+
list.push(row)
|
|
222
|
+
valuesByVersion.set(versionId, list)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Culture variants.
|
|
226
|
+
const cultureRows = source.has('umbracoDocumentCultureVariation')
|
|
227
|
+
? source.all<{
|
|
228
|
+
nodeId: number
|
|
229
|
+
languageId: number
|
|
230
|
+
available: unknown
|
|
231
|
+
published: unknown
|
|
232
|
+
name: string | null
|
|
233
|
+
}>(
|
|
234
|
+
'SELECT nodeId, languageId, available, published, name FROM umbracoDocumentCultureVariation',
|
|
235
|
+
)
|
|
236
|
+
: []
|
|
237
|
+
const versionNames = new Map<string, string>()
|
|
238
|
+
if (source.has('umbracoContentVersionCultureVariation'))
|
|
239
|
+
for (const row of source.all<{ versionId: number; languageId: number; name: string | null }>(
|
|
240
|
+
'SELECT versionId, languageId, name FROM umbracoContentVersionCultureVariation',
|
|
241
|
+
))
|
|
242
|
+
if (row.name) versionNames.set(`${row.versionId}:${row.languageId}`, row.name)
|
|
243
|
+
|
|
244
|
+
const bundleNodes: BundleNode[] = []
|
|
245
|
+
const blobs: SourceBlob[] = []
|
|
246
|
+
const counts: Partial<Record<BundleKind, number>> = {}
|
|
247
|
+
const usedTypes = new Map<string, string>()
|
|
248
|
+
const usedComponents = new Set<string>()
|
|
249
|
+
const usedLanguages = new Set<string>()
|
|
250
|
+
const dangling = new Set<string>()
|
|
251
|
+
let unknownType = 0
|
|
252
|
+
|
|
253
|
+
for (const row of live) {
|
|
254
|
+
const id = Number(row.id)
|
|
255
|
+
const kind = KIND_OF[key(row.objectType)] as BundleKind
|
|
256
|
+
const contentType = schema.contentTypes.get(Number(row.contentTypeId))
|
|
257
|
+
const version = chosen.get(id)
|
|
258
|
+
if (!contentType || !version) {
|
|
259
|
+
unknownType++
|
|
260
|
+
carriedKeys.delete(key(row.key))
|
|
261
|
+
carriedIds.delete(id)
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
const nodeState = state.get(id)
|
|
265
|
+
const isPublished = kind !== 'media' && (nodeState?.published ?? false)
|
|
266
|
+
|
|
267
|
+
const variants: BundleVariant[] = []
|
|
268
|
+
const cultures = contentType.variesByCulture
|
|
269
|
+
? cultureRows.filter((c) => Number(c.nodeId) === id && truthy(c.available))
|
|
270
|
+
: []
|
|
271
|
+
for (const culture of cultures) {
|
|
272
|
+
const iso = schema.languages.get(Number(culture.languageId))?.iso
|
|
273
|
+
if (!iso) continue
|
|
274
|
+
usedLanguages.add(iso)
|
|
275
|
+
variants.push({
|
|
276
|
+
culture: iso,
|
|
277
|
+
segment: null,
|
|
278
|
+
name:
|
|
279
|
+
versionNames.get(`${version.id}:${culture.languageId}`) ?? culture.name ?? row.name ?? '',
|
|
280
|
+
published: isPublished && truthy(culture.published),
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
if (variants.length === 0)
|
|
284
|
+
variants.push({
|
|
285
|
+
culture: null,
|
|
286
|
+
segment: null,
|
|
287
|
+
name: version.name ?? row.name ?? '',
|
|
288
|
+
published: isPublished,
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
const values: BundleValue[] = []
|
|
292
|
+
for (const cell of valuesByVersion.get(Number(version.id)) ?? []) {
|
|
293
|
+
const property = schema.propertyTypes.get(Number(cell.propertyTypeId))
|
|
294
|
+
if (!property) continue
|
|
295
|
+
const { editor, dbType } = property.dataType
|
|
296
|
+
const value = editorValue(editor, dbType, cell)
|
|
297
|
+
if (value === null || value === undefined) continue
|
|
298
|
+
const culture =
|
|
299
|
+
cell.languageId === null
|
|
300
|
+
? null
|
|
301
|
+
: (schema.languages.get(Number(cell.languageId))?.iso ?? null)
|
|
302
|
+
if (culture) usedLanguages.add(culture)
|
|
303
|
+
|
|
304
|
+
// Only what the bundle carries is a reference the destination can honour.
|
|
305
|
+
const references: string[] = []
|
|
306
|
+
for (const candidate of referenceCandidates(value)) {
|
|
307
|
+
if (candidate === key(row.key)) continue
|
|
308
|
+
if (carriedKeys.has(candidate)) references.push(candidate)
|
|
309
|
+
else if (everyKey.has(candidate)) dangling.add(candidate)
|
|
310
|
+
}
|
|
311
|
+
values.push({
|
|
312
|
+
property: property.alias,
|
|
313
|
+
culture,
|
|
314
|
+
segment: cell.segment ?? null,
|
|
315
|
+
editor,
|
|
316
|
+
value,
|
|
317
|
+
references: references.length > 0 ? references : undefined,
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
if (kind === 'media' && FILE_EDITORS.has(editor)) {
|
|
321
|
+
const blob = blobKeyOf(value)
|
|
322
|
+
if (blob) blobs.push({ key: blob, node: key(row.key), name: row.name ?? blob })
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const template =
|
|
327
|
+
kind === 'document' && version.templateId !== null
|
|
328
|
+
? (schema.templates.get(Number(version.templateId))?.alias ?? null)
|
|
329
|
+
: null
|
|
330
|
+
if (template) usedComponents.add(template)
|
|
331
|
+
usedTypes.set(contentType.alias, contentType.key)
|
|
332
|
+
counts[kind] = (counts[kind] ?? 0) + 1
|
|
333
|
+
|
|
334
|
+
bundleNodes.push({
|
|
335
|
+
key: key(row.key),
|
|
336
|
+
kind,
|
|
337
|
+
contentType: { key: contentType.key, alias: contentType.alias },
|
|
338
|
+
parent: carriedIds.get(Number(row.parentId)) ?? null,
|
|
339
|
+
sortOrder: Number(row.sortOrder),
|
|
340
|
+
template,
|
|
341
|
+
variants,
|
|
342
|
+
values,
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const bundleBlobs: BundleBlob[] = blobs.map((blob) => ({
|
|
347
|
+
key: blob.key,
|
|
348
|
+
node: blob.node,
|
|
349
|
+
etag: null,
|
|
350
|
+
size: null,
|
|
351
|
+
included: false,
|
|
352
|
+
}))
|
|
353
|
+
|
|
354
|
+
const totalVersions = source.count('umbracoContentVersion')
|
|
355
|
+
findings.add({
|
|
356
|
+
class: 'migrates',
|
|
357
|
+
code: 'content',
|
|
358
|
+
title: 'Content',
|
|
359
|
+
count: bundleNodes.length,
|
|
360
|
+
detail: (['document', 'media', 'element', 'blueprint'] as const)
|
|
361
|
+
.filter((kind) => counts[kind])
|
|
362
|
+
.map((kind) => `${counts[kind]} ${kind}${kind === 'media' ? '' : 's'}`)
|
|
363
|
+
.join(', '),
|
|
364
|
+
})
|
|
365
|
+
findings.some({
|
|
366
|
+
class: 'dropped',
|
|
367
|
+
code: 'pending-changes',
|
|
368
|
+
title: 'Published pages with unpublished changes',
|
|
369
|
+
detail:
|
|
370
|
+
'The published version of each is imported, as that is what the site serves. Run with --drafts to take the working copy instead.',
|
|
371
|
+
count: pendingChanges,
|
|
372
|
+
})
|
|
373
|
+
findings.some({
|
|
374
|
+
class: 'dropped',
|
|
375
|
+
code: 'version-history',
|
|
376
|
+
title: 'Earlier versions',
|
|
377
|
+
detail: 'Only what is published and the current draft travel; version history does not.',
|
|
378
|
+
count: Math.max(0, totalVersions - versionIds.size),
|
|
379
|
+
})
|
|
380
|
+
findings.some({
|
|
381
|
+
class: 'dropped',
|
|
382
|
+
code: 'recycle-bin',
|
|
383
|
+
title: 'Items in the recycle bin',
|
|
384
|
+
count: nodes.length - live.length,
|
|
385
|
+
})
|
|
386
|
+
findings.some({
|
|
387
|
+
class: 'dropped',
|
|
388
|
+
code: 'unknown-content-type',
|
|
389
|
+
title: 'Content whose type could not be read',
|
|
390
|
+
count: unknownType,
|
|
391
|
+
})
|
|
392
|
+
findings.some({
|
|
393
|
+
class: 'needs-a-person',
|
|
394
|
+
code: 'dangling-references',
|
|
395
|
+
title: 'Pickers that point at something not being imported',
|
|
396
|
+
detail:
|
|
397
|
+
'A value refers to a member, a recycled item or another node that does not travel. The value is carried as it is, and the picker will show it as missing.',
|
|
398
|
+
count: dangling.size,
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
const roots = bundleNodes.filter((node) => node.parent === null).map((node) => node.key)
|
|
402
|
+
return {
|
|
403
|
+
blobs,
|
|
404
|
+
set: {
|
|
405
|
+
manifest: {
|
|
406
|
+
formatVersion: BUNDLE_FORMAT_VERSION,
|
|
407
|
+
id: crypto.randomUUID(),
|
|
408
|
+
createdAt: new Date().toISOString(),
|
|
409
|
+
createdBy: 'crombolo import umbraco',
|
|
410
|
+
// writeBundle recomputes this over the files it emits.
|
|
411
|
+
integrity: '',
|
|
412
|
+
snapshot: options.snapshot,
|
|
413
|
+
provenance: {
|
|
414
|
+
siteName: options.siteName,
|
|
415
|
+
schemaVersion: schema.set.version,
|
|
416
|
+
schemaRevision: '0',
|
|
417
|
+
schemaHash: hashSchemaSet(schema.set),
|
|
418
|
+
},
|
|
419
|
+
selector: { roots, descendants: true, asGiven: ['umbraco'] },
|
|
420
|
+
counts,
|
|
421
|
+
dependencies: {
|
|
422
|
+
carried: bundleNodes.map((node) => node.key),
|
|
423
|
+
expected: [],
|
|
424
|
+
schema: {
|
|
425
|
+
contentTypes: [...usedTypes].map(([alias, typeKey]) => ({ key: typeKey, alias })),
|
|
426
|
+
components: [...usedComponents],
|
|
427
|
+
languages: [...usedLanguages],
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
blobs: bundleBlobs,
|
|
431
|
+
},
|
|
432
|
+
nodes: bundleNodes,
|
|
433
|
+
},
|
|
434
|
+
}
|
|
435
|
+
}
|
package/src/findings.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** What the importer has to say about a source site, in the classes the report groups by. */
|
|
2
|
+
|
|
3
|
+
export type FindingClass =
|
|
4
|
+
/** Converted; nothing to do. */
|
|
5
|
+
| 'migrates'
|
|
6
|
+
/** Carried across, but somebody has work to do before it behaves as it did. */
|
|
7
|
+
| 'needs-a-person'
|
|
8
|
+
/** Left behind on purpose; the report says how much. */
|
|
9
|
+
| 'dropped'
|
|
10
|
+
/** Could be imported, but this version of the importer does not do it. */
|
|
11
|
+
| 'not-yet'
|
|
12
|
+
/** Has no equivalent here, and never will. */
|
|
13
|
+
| 'cannot-migrate'
|
|
14
|
+
/** Stops `apply` until it is dealt with. */
|
|
15
|
+
| 'blocking'
|
|
16
|
+
|
|
17
|
+
export interface Finding {
|
|
18
|
+
class: FindingClass
|
|
19
|
+
/** Stable, for a script to match on. */
|
|
20
|
+
code: string
|
|
21
|
+
title: string
|
|
22
|
+
detail?: string
|
|
23
|
+
count?: number
|
|
24
|
+
/** The things this is about: names, paths, aliases. */
|
|
25
|
+
items?: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const CLASS_ORDER: readonly FindingClass[] = [
|
|
29
|
+
'blocking',
|
|
30
|
+
'needs-a-person',
|
|
31
|
+
'cannot-migrate',
|
|
32
|
+
'not-yet',
|
|
33
|
+
'dropped',
|
|
34
|
+
'migrates',
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
export const CLASS_TITLES: Record<FindingClass, string> = {
|
|
38
|
+
blocking: 'Blocking',
|
|
39
|
+
'needs-a-person': 'Needs a person',
|
|
40
|
+
'cannot-migrate': 'Cannot be migrated',
|
|
41
|
+
'not-yet': 'Not imported by this version',
|
|
42
|
+
dropped: 'Left behind',
|
|
43
|
+
migrates: 'Migrates',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class Findings {
|
|
47
|
+
readonly list: Finding[] = []
|
|
48
|
+
|
|
49
|
+
add(finding: Finding): void {
|
|
50
|
+
this.list.push(finding)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Adds a finding only when it is about something. */
|
|
54
|
+
some(finding: Finding & { count: number }): void {
|
|
55
|
+
if (finding.count > 0) this.list.push(finding)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get blocking(): Finding[] {
|
|
59
|
+
return this.list.filter((finding) => finding.class === 'blocking')
|
|
60
|
+
}
|
|
61
|
+
}
|