@crombolo/transfer 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 +32 -4
- package/src/check.ts +552 -0
- package/src/export.ts +316 -0
- package/src/import.ts +331 -0
- package/src/index.ts +10 -0
- package/src/load.ts +360 -0
- package/src/model.ts +194 -0
- package/src/package.ts +84 -0
- package/src/references.ts +71 -0
- package/src/resolutions.ts +89 -0
- package/src/revert.ts +387 -0
- package/src/write.ts +220 -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,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/transfer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Content transfer for crombolo: bundles of content moved between environments.",
|
|
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/transfer"
|
|
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
|
+
}
|
|
34
|
+
}
|
package/src/check.ts
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dry run: what importing this bundle would do here, and what it needs
|
|
3
|
+
* from someone first.
|
|
4
|
+
*
|
|
5
|
+
* Read-only, and it runs before every import rather than only when asked
|
|
6
|
+
* (`content-transfer.md`). The point is that nothing is written until the
|
|
7
|
+
* conflicts are named and answered, so a transfer is never a surprise.
|
|
8
|
+
*
|
|
9
|
+
* Findings share the shape, table, endpoint and dashboard of the pre-upgrade
|
|
10
|
+
* check, in the same three kinds:
|
|
11
|
+
*
|
|
12
|
+
* - **blocking** — the destination cannot honour the bundle. Deploy something,
|
|
13
|
+
* or re-root it. No resolution makes it importable as it stands.
|
|
14
|
+
* - **person** — importable, but somebody has to choose. A resolution answers it.
|
|
15
|
+
* - **auto** — what the import will do, reported so it is not a surprise.
|
|
16
|
+
*/
|
|
17
|
+
import { ObjectTypes } from '@crombolo/core'
|
|
18
|
+
import {
|
|
19
|
+
ContentTypeRepository,
|
|
20
|
+
currentSchemaState,
|
|
21
|
+
DataTypeRepository,
|
|
22
|
+
type Db,
|
|
23
|
+
DocumentRepository,
|
|
24
|
+
type Finding,
|
|
25
|
+
LanguageRepository,
|
|
26
|
+
type NodeRow,
|
|
27
|
+
} from '@crombolo/data'
|
|
28
|
+
import type { BundleNode, ContentSet } from './model.ts'
|
|
29
|
+
import { referenceCandidates } from './references.ts'
|
|
30
|
+
|
|
31
|
+
/** What a person can decide about a node the destination already has. */
|
|
32
|
+
export type Resolution = 'take-bundle' | 'keep-local' | 'skip'
|
|
33
|
+
|
|
34
|
+
export const RESOLUTIONS: readonly Resolution[] = ['take-bundle', 'keep-local', 'skip']
|
|
35
|
+
|
|
36
|
+
export const isResolution = (value: string): value is Resolution =>
|
|
37
|
+
(RESOLUTIONS as readonly string[]).includes(value)
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What to suggest for a finding, as a function of its code — so the table keeps
|
|
41
|
+
* its stable shape and both the CLI and the dashboard render the same advice.
|
|
42
|
+
*/
|
|
43
|
+
export function resolutionsFor(code: string): readonly string[] {
|
|
44
|
+
switch (code) {
|
|
45
|
+
case 'local-edit':
|
|
46
|
+
return [
|
|
47
|
+
'--resolve <key>=take-bundle to let the bundle win',
|
|
48
|
+
'--resolve <key>=keep-local to keep what is here',
|
|
49
|
+
'--resolve <key>=skip to leave the node alone entirely',
|
|
50
|
+
'--resolve-all=<choice> to answer every one the same way',
|
|
51
|
+
]
|
|
52
|
+
case 'missing-parent':
|
|
53
|
+
return [
|
|
54
|
+
'deploy or transfer the parent first',
|
|
55
|
+
'--under <path|uuid> to place it somewhere else',
|
|
56
|
+
]
|
|
57
|
+
case 'missing-dependency':
|
|
58
|
+
return ['transfer the missing node first', 'or remove the value that refers to it']
|
|
59
|
+
case 'missing-content-type':
|
|
60
|
+
return ['deploy schema/ to this environment first']
|
|
61
|
+
case 'language-missing':
|
|
62
|
+
return ['add the language in Settings, or deploy schema/languages.toml']
|
|
63
|
+
case 'editor-mismatch':
|
|
64
|
+
return ['deploy the schema change that altered the property editor']
|
|
65
|
+
case 'local-trashed':
|
|
66
|
+
return ['restore the node from the recycle bin, or --resolve <key>=skip']
|
|
67
|
+
case 'missing-blob':
|
|
68
|
+
return [
|
|
69
|
+
'crombolo media sync, once it lands',
|
|
70
|
+
'--allow-missing-blobs to import the metadata anyway',
|
|
71
|
+
]
|
|
72
|
+
case 'unresolved-reference':
|
|
73
|
+
return ['transfer the node it refers to', 'or accept a value that points at nothing']
|
|
74
|
+
case 'name-collision':
|
|
75
|
+
return ['rename one of them, or --resolve <key>=skip']
|
|
76
|
+
case 'missing-template':
|
|
77
|
+
return ['deploy the view, and the template attaches on the next save']
|
|
78
|
+
default:
|
|
79
|
+
return []
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface CheckOptions {
|
|
84
|
+
/** Re-root the bundle here instead of at the parents it names. */
|
|
85
|
+
under?: NodeRow | undefined
|
|
86
|
+
/** Per-node decisions, by node key. */
|
|
87
|
+
resolutions?: Readonly<Record<string, Resolution>>
|
|
88
|
+
/** One decision for every node that needs one. */
|
|
89
|
+
resolveAll?: Resolution | undefined
|
|
90
|
+
/** Template aliases with a view on disk; absent means templates are not checked. */
|
|
91
|
+
componentAliases?: ReadonlySet<string> | undefined
|
|
92
|
+
/** Whether a media blob is present in this environment's store. */
|
|
93
|
+
hasBlob?: ((key: string) => Promise<boolean>) | undefined
|
|
94
|
+
allowMissingBlobs?: boolean
|
|
95
|
+
/** For linking a finding to the document it is about. */
|
|
96
|
+
backOfficePath?: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** What the import would do to one node. */
|
|
100
|
+
export interface NodePlan {
|
|
101
|
+
key: string
|
|
102
|
+
name: string
|
|
103
|
+
kind: BundleNode['kind']
|
|
104
|
+
action: 'create' | 'update' | 'unchanged' | 'skip'
|
|
105
|
+
/** Property aliases whose value would change. */
|
|
106
|
+
changing: string[]
|
|
107
|
+
resolution: Resolution | undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface TransferCheck {
|
|
111
|
+
bundleId: string
|
|
112
|
+
findings: Finding[]
|
|
113
|
+
/** What stops an import: every blocking finding, plus the unanswered ones. */
|
|
114
|
+
outstanding: Finding[]
|
|
115
|
+
plan: NodePlan[]
|
|
116
|
+
counts: { create: number; update: number; unchanged: number; skip: number }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const documentLink = (backOfficePath: string | undefined, key: string): string | null =>
|
|
120
|
+
backOfficePath ? `${backOfficePath}/section/content/workspace/document/edit/${key}` : null
|
|
121
|
+
|
|
122
|
+
/** Storage-insensitive equality, so a value that round-tripped is not "changed". */
|
|
123
|
+
function sameValue(a: unknown, b: unknown): boolean {
|
|
124
|
+
if (a === b) return true
|
|
125
|
+
// Absent and empty are the same thing to an editor, and the two spellings
|
|
126
|
+
// travel differently: a bundle omits nothing, but the database stores null.
|
|
127
|
+
const empty = (v: unknown) => v === null || v === undefined || v === ''
|
|
128
|
+
if (empty(a) && empty(b)) return true
|
|
129
|
+
if (typeof a === 'object' || typeof b === 'object') {
|
|
130
|
+
try {
|
|
131
|
+
return JSON.stringify(a) === JSON.stringify(b)
|
|
132
|
+
} catch {
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return String(a) === String(b)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function checkBundle(
|
|
140
|
+
db: Db,
|
|
141
|
+
set: ContentSet,
|
|
142
|
+
options: CheckOptions = {},
|
|
143
|
+
): Promise<TransferCheck> {
|
|
144
|
+
const findings: Finding[] = []
|
|
145
|
+
const plan: NodePlan[] = []
|
|
146
|
+
const resolutions = options.resolutions ?? {}
|
|
147
|
+
const link = (key: string) => documentLink(options.backOfficePath, key)
|
|
148
|
+
|
|
149
|
+
// As with the exporter: read at the database's own state, or every as-of read
|
|
150
|
+
// comes back empty and the check reports a clean import of nothing.
|
|
151
|
+
const state = await currentSchemaState(db)
|
|
152
|
+
const nodeState = { version: state.version, revision: state.revision }
|
|
153
|
+
const docs = new DocumentRepository(db, { nodeState })
|
|
154
|
+
const nodes = docs.nodes
|
|
155
|
+
const types = new ContentTypeRepository(db)
|
|
156
|
+
// Media types are a separate object type, so a repository reading document
|
|
157
|
+
// types cannot see one. A bundle names its types in one flat list, without
|
|
158
|
+
// saying which kind each is, so both are asked — and a media bundle stopped
|
|
159
|
+
// being refused for a type the destination had all along.
|
|
160
|
+
const mediaTypes = new ContentTypeRepository(db, { kind: 'media' })
|
|
161
|
+
const dataTypes = new DataTypeRepository(db)
|
|
162
|
+
|
|
163
|
+
const languages = new Set(
|
|
164
|
+
(await new LanguageRepository(db).all()).map((l) => l.isoCode.toLowerCase()),
|
|
165
|
+
)
|
|
166
|
+
const carried = new Set(set.nodes.map((n) => n.key))
|
|
167
|
+
|
|
168
|
+
const bundleFinding = (
|
|
169
|
+
kind: Finding['kind'],
|
|
170
|
+
code: string,
|
|
171
|
+
message: string,
|
|
172
|
+
subjectName = set.manifest.id,
|
|
173
|
+
): Finding => ({
|
|
174
|
+
kind,
|
|
175
|
+
code,
|
|
176
|
+
subjectType: 'bundle',
|
|
177
|
+
subjectKey: set.manifest.id,
|
|
178
|
+
subjectName,
|
|
179
|
+
propertyAlias: null,
|
|
180
|
+
culture: null,
|
|
181
|
+
message,
|
|
182
|
+
link: null,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
// Every content type the bundle names, matched by key first and alias second:
|
|
186
|
+
// keys are stable across environments because tooling writes them into the
|
|
187
|
+
// schema files, and the alias is what a person reads in the message.
|
|
188
|
+
type Resolved = { type: Awaited<ReturnType<typeof types.byKey>>; repo: ContentTypeRepository }
|
|
189
|
+
const typeByAlias = new Map<string, Resolved>()
|
|
190
|
+
const declared = new Set(set.manifest.dependencies.schema.contentTypes.map((t) => t.alias))
|
|
191
|
+
const typeFor = async (ct: {
|
|
192
|
+
key: string
|
|
193
|
+
alias: string
|
|
194
|
+
}): Promise<Awaited<ReturnType<typeof types.byKey>>> => {
|
|
195
|
+
const cached = typeByAlias.get(ct.alias)
|
|
196
|
+
if (cached) return cached.type
|
|
197
|
+
let found: Resolved = { type: undefined, repo: types }
|
|
198
|
+
for (const repo of [types, mediaTypes]) {
|
|
199
|
+
const type = (ct.key ? await repo.byKey(ct.key) : undefined) ?? (await repo.byAlias(ct.alias))
|
|
200
|
+
if (type) {
|
|
201
|
+
found = { type, repo }
|
|
202
|
+
break
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
typeByAlias.set(ct.alias, found)
|
|
206
|
+
return found.type
|
|
207
|
+
}
|
|
208
|
+
for (const wanted of set.manifest.dependencies.schema.contentTypes) {
|
|
209
|
+
if (await typeFor(wanted)) continue
|
|
210
|
+
findings.push(
|
|
211
|
+
bundleFinding(
|
|
212
|
+
'blocking',
|
|
213
|
+
'missing-content-type',
|
|
214
|
+
`no content type "${wanted.alias}" here — deploy schema/ before importing`,
|
|
215
|
+
wanted.alias,
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
for (const iso of set.manifest.dependencies.schema.languages) {
|
|
221
|
+
if (!languages.has(iso.toLowerCase()))
|
|
222
|
+
findings.push(
|
|
223
|
+
bundleFinding(
|
|
224
|
+
'blocking',
|
|
225
|
+
'language-missing',
|
|
226
|
+
`the bundle has ${iso} content but this environment has no ${iso} language`,
|
|
227
|
+
iso,
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Everything the bundle expects to find already here.
|
|
233
|
+
const expectedRows = new Map(
|
|
234
|
+
(await nodes.byKeys(set.manifest.dependencies.expected.map((e) => e.key))).map((r) => [
|
|
235
|
+
r.key,
|
|
236
|
+
r,
|
|
237
|
+
]),
|
|
238
|
+
)
|
|
239
|
+
for (const expected of set.manifest.dependencies.expected) {
|
|
240
|
+
// A re-root answers the parent case: nothing else has to exist for it.
|
|
241
|
+
const isParentOnly = expected.why.startsWith('parent of')
|
|
242
|
+
if (isParentOnly && options.under) continue
|
|
243
|
+
const row = expectedRows.get(expected.key)
|
|
244
|
+
if (row && !row.trashed) continue
|
|
245
|
+
findings.push({
|
|
246
|
+
kind: 'blocking',
|
|
247
|
+
code: isParentOnly ? 'missing-parent' : 'missing-dependency',
|
|
248
|
+
subjectType: 'bundle',
|
|
249
|
+
subjectKey: expected.key,
|
|
250
|
+
subjectName: expected.name || expected.key,
|
|
251
|
+
propertyAlias: null,
|
|
252
|
+
culture: null,
|
|
253
|
+
message: row?.trashed
|
|
254
|
+
? `"${expected.name}" is in the recycle bin here, and the bundle needs it (${expected.why})`
|
|
255
|
+
: `nothing here with the key ${expected.key} — the bundle needs it (${expected.why})`,
|
|
256
|
+
link: link(expected.key),
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// The property editors each destination type actually uses, for the mismatch check.
|
|
261
|
+
const editorsOf = new Map<string, Map<string, string>>()
|
|
262
|
+
const editorsFor = async (alias: string): Promise<Map<string, string>> => {
|
|
263
|
+
const cached = editorsOf.get(alias)
|
|
264
|
+
if (cached) return cached
|
|
265
|
+
const resolved = typeByAlias.get(alias)
|
|
266
|
+
const editors = new Map<string, string>()
|
|
267
|
+
if (resolved?.type) {
|
|
268
|
+
const properties = await resolved.repo.resolveProperties(resolved.type.key)
|
|
269
|
+
const byKey = new Map(
|
|
270
|
+
(await dataTypes.byKeys(properties.map((p) => p.dataTypeKey))).map((d) => [
|
|
271
|
+
d.key,
|
|
272
|
+
d.editorAlias,
|
|
273
|
+
]),
|
|
274
|
+
)
|
|
275
|
+
for (const property of properties) {
|
|
276
|
+
const editor = byKey.get(property.dataTypeKey)
|
|
277
|
+
if (editor) editors.set(property.alias, editor)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
editorsOf.set(alias, editors)
|
|
281
|
+
return editors
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const siblingNames = new Map<string, Set<string>>()
|
|
285
|
+
|
|
286
|
+
for (const node of set.nodes) {
|
|
287
|
+
const name = node.variants[0]?.name ?? node.key
|
|
288
|
+
const resolution = resolutions[node.key] ?? options.resolveAll
|
|
289
|
+
const existing = await nodes.byKey(node.key)
|
|
290
|
+
const editors = await editorsFor(node.contentType.alias)
|
|
291
|
+
const typeMissing = !(await typeFor(node.contentType))
|
|
292
|
+
|
|
293
|
+
if (resolution === 'skip') {
|
|
294
|
+
plan.push({ key: node.key, name, kind: node.kind, action: 'skip', changing: [], resolution })
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// A node whose type the manifest never declared. The loop above only
|
|
299
|
+
// validates what the manifest lists, so without this a bundle whose
|
|
300
|
+
// manifest and nodes disagree would import part of itself and say nothing.
|
|
301
|
+
if (typeMissing && !declared.has(node.contentType.alias))
|
|
302
|
+
findings.push({
|
|
303
|
+
kind: 'blocking',
|
|
304
|
+
code: 'missing-content-type',
|
|
305
|
+
subjectType: node.kind === 'media' ? 'media' : 'document',
|
|
306
|
+
subjectKey: node.key,
|
|
307
|
+
subjectName: name,
|
|
308
|
+
propertyAlias: null,
|
|
309
|
+
culture: null,
|
|
310
|
+
message: `"${name}" is a "${node.contentType.alias}", which does not exist here and the bundle never declared`,
|
|
311
|
+
link: null,
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
for (const variant of node.variants) {
|
|
315
|
+
if (variant.culture && !languages.has(variant.culture.toLowerCase()))
|
|
316
|
+
findings.push({
|
|
317
|
+
kind: 'blocking',
|
|
318
|
+
code: 'language-missing',
|
|
319
|
+
subjectType: node.kind === 'media' ? 'media' : 'document',
|
|
320
|
+
subjectKey: node.key,
|
|
321
|
+
subjectName: name,
|
|
322
|
+
propertyAlias: null,
|
|
323
|
+
culture: variant.culture,
|
|
324
|
+
message: `"${name}" has ${variant.culture} content and this environment has no ${variant.culture} language`,
|
|
325
|
+
link: link(node.key),
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// A template's definition is its file, so the alias travels and the view
|
|
330
|
+
// has to be there. Missing one is not fatal — the page imports without a
|
|
331
|
+
// template and renders nothing until the view is deployed.
|
|
332
|
+
if (node.template && options.componentAliases && !options.componentAliases.has(node.template))
|
|
333
|
+
findings.push({
|
|
334
|
+
kind: 'auto',
|
|
335
|
+
code: 'missing-template',
|
|
336
|
+
subjectType: 'document',
|
|
337
|
+
subjectKey: node.key,
|
|
338
|
+
subjectName: name,
|
|
339
|
+
propertyAlias: null,
|
|
340
|
+
culture: null,
|
|
341
|
+
message: `no view components/${node.template}.tsx here, so "${name}" arrives without a template`,
|
|
342
|
+
link: link(node.key),
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
// Properties the destination type no longer has, and editors that changed
|
|
346
|
+
// under them. A retired property is a value the import drops; a changed
|
|
347
|
+
// editor is a value it would store in the wrong shape, so that one blocks.
|
|
348
|
+
if (!typeMissing) {
|
|
349
|
+
for (const value of node.values) {
|
|
350
|
+
const editor = editors.get(value.property)
|
|
351
|
+
if (editor === undefined) {
|
|
352
|
+
findings.push({
|
|
353
|
+
kind: 'auto',
|
|
354
|
+
code: 'missing-property',
|
|
355
|
+
subjectType: node.kind === 'media' ? 'media' : 'document',
|
|
356
|
+
subjectKey: node.key,
|
|
357
|
+
subjectName: name,
|
|
358
|
+
propertyAlias: value.property,
|
|
359
|
+
culture: value.culture,
|
|
360
|
+
message: `"${node.contentType.alias}" has no property "${value.property}" here, so its value is not imported`,
|
|
361
|
+
link: link(node.key),
|
|
362
|
+
})
|
|
363
|
+
continue
|
|
364
|
+
}
|
|
365
|
+
if (value.editor && editor !== value.editor)
|
|
366
|
+
findings.push({
|
|
367
|
+
kind: 'blocking',
|
|
368
|
+
code: 'editor-mismatch',
|
|
369
|
+
subjectType: node.kind === 'media' ? 'media' : 'document',
|
|
370
|
+
subjectKey: node.key,
|
|
371
|
+
subjectName: name,
|
|
372
|
+
propertyAlias: value.property,
|
|
373
|
+
culture: value.culture,
|
|
374
|
+
message: `"${value.property}" was ${value.editor} where the bundle was made and is ${editor} here`,
|
|
375
|
+
link: link(node.key),
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// References inside values that resolve to nothing here and are not carried.
|
|
381
|
+
const referenced = new Set<string>()
|
|
382
|
+
for (const value of node.values)
|
|
383
|
+
for (const key of value.references ?? referenceCandidates(value.value)) referenced.add(key)
|
|
384
|
+
const outside = [...referenced].filter((key) => !carried.has(key))
|
|
385
|
+
const present = new Set((await nodes.byKeys(outside)).map((r) => r.key))
|
|
386
|
+
for (const key of outside) {
|
|
387
|
+
if (present.has(key)) continue
|
|
388
|
+
// Only report what the bundle itself said was a reference. A candidate
|
|
389
|
+
// the exporter never resolved was never a reference.
|
|
390
|
+
if (!set.manifest.dependencies.expected.some((e) => e.key === key)) continue
|
|
391
|
+
findings.push({
|
|
392
|
+
kind: 'person',
|
|
393
|
+
code: 'unresolved-reference',
|
|
394
|
+
subjectType: node.kind === 'media' ? 'media' : 'document',
|
|
395
|
+
subjectKey: node.key,
|
|
396
|
+
subjectName: name,
|
|
397
|
+
propertyAlias: null,
|
|
398
|
+
culture: null,
|
|
399
|
+
message: `a value on "${name}" refers to ${key}, which is not here and not in the bundle`,
|
|
400
|
+
link: link(node.key),
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (node.kind === 'media' && options.hasBlob && !options.allowMissingBlobs) {
|
|
405
|
+
for (const blob of set.manifest.blobs.filter((b) => b.node === node.key)) {
|
|
406
|
+
if (blob.included || (await options.hasBlob(blob.key))) continue
|
|
407
|
+
findings.push({
|
|
408
|
+
kind: 'person',
|
|
409
|
+
code: 'missing-blob',
|
|
410
|
+
subjectType: 'media',
|
|
411
|
+
subjectKey: node.key,
|
|
412
|
+
subjectName: name,
|
|
413
|
+
propertyAlias: null,
|
|
414
|
+
culture: null,
|
|
415
|
+
message: `the file "${blob.key}" for "${name}" is not in this environment's media store`,
|
|
416
|
+
link: null,
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (!existing) {
|
|
422
|
+
// A sibling of the same name is a URL clash, which is a person's call.
|
|
423
|
+
const parentKey = options.under?.key ?? node.parent
|
|
424
|
+
const siblings =
|
|
425
|
+
siblingNames.get(parentKey ?? '') ??
|
|
426
|
+
new Set(
|
|
427
|
+
(
|
|
428
|
+
await nodes.childrenNamed(
|
|
429
|
+
parentKey ? ((await nodes.byKey(parentKey))?.id ?? 0) : -1,
|
|
430
|
+
[ObjectTypes.Document, ObjectTypes.Media, ObjectTypes.Element],
|
|
431
|
+
name,
|
|
432
|
+
)
|
|
433
|
+
).map((r) => r.text ?? ''),
|
|
434
|
+
)
|
|
435
|
+
siblingNames.set(parentKey ?? '', siblings)
|
|
436
|
+
if (siblings.has(name))
|
|
437
|
+
findings.push({
|
|
438
|
+
kind: 'person',
|
|
439
|
+
code: 'name-collision',
|
|
440
|
+
subjectType: node.kind === 'media' ? 'media' : 'document',
|
|
441
|
+
subjectKey: node.key,
|
|
442
|
+
subjectName: name,
|
|
443
|
+
propertyAlias: null,
|
|
444
|
+
culture: null,
|
|
445
|
+
message: `something called "${name}" is already here, so the two would share a URL`,
|
|
446
|
+
link: null,
|
|
447
|
+
})
|
|
448
|
+
plan.push({
|
|
449
|
+
key: node.key,
|
|
450
|
+
name,
|
|
451
|
+
kind: node.kind,
|
|
452
|
+
action: 'create',
|
|
453
|
+
changing: node.values.map((v) => v.property),
|
|
454
|
+
resolution,
|
|
455
|
+
})
|
|
456
|
+
findings.push({
|
|
457
|
+
kind: 'auto',
|
|
458
|
+
code: 'new-node',
|
|
459
|
+
subjectType: node.kind === 'media' ? 'media' : 'document',
|
|
460
|
+
subjectKey: node.key,
|
|
461
|
+
subjectName: name,
|
|
462
|
+
propertyAlias: null,
|
|
463
|
+
culture: null,
|
|
464
|
+
message:
|
|
465
|
+
node.kind === 'media'
|
|
466
|
+
? `"${name}" is new here and media is live as soon as it lands`
|
|
467
|
+
: `"${name}" is new here and arrives as a draft`,
|
|
468
|
+
link: null,
|
|
469
|
+
})
|
|
470
|
+
continue
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (existing.trashed) {
|
|
474
|
+
findings.push({
|
|
475
|
+
kind: 'blocking',
|
|
476
|
+
code: 'local-trashed',
|
|
477
|
+
subjectType: node.kind === 'media' ? 'media' : 'document',
|
|
478
|
+
subjectKey: node.key,
|
|
479
|
+
subjectName: name,
|
|
480
|
+
propertyAlias: null,
|
|
481
|
+
culture: null,
|
|
482
|
+
message: `"${name}" is in the recycle bin here; importing over it would revive it silently`,
|
|
483
|
+
link: link(node.key),
|
|
484
|
+
})
|
|
485
|
+
plan.push({ key: node.key, name, kind: node.kind, action: 'skip', changing: [], resolution })
|
|
486
|
+
continue
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// What the bundle would actually change, property by property.
|
|
490
|
+
const current = await docs.byKey(node.key)
|
|
491
|
+
const currentByKey = new Map(
|
|
492
|
+
(current?.values ?? []).map((v) => [`${v.alias}|${v.culture ?? ''}|${v.segment ?? ''}`, v]),
|
|
493
|
+
)
|
|
494
|
+
const changing = node.values
|
|
495
|
+
.filter((value) => {
|
|
496
|
+
if (!editors.has(value.property)) return false
|
|
497
|
+
const mine = currentByKey.get(
|
|
498
|
+
`${value.property}|${value.culture ?? ''}|${value.segment ?? ''}`,
|
|
499
|
+
)
|
|
500
|
+
return !sameValue(mine?.value, value.value)
|
|
501
|
+
})
|
|
502
|
+
.map((value) => value.property)
|
|
503
|
+
|
|
504
|
+
if (changing.length === 0) {
|
|
505
|
+
plan.push({
|
|
506
|
+
key: node.key,
|
|
507
|
+
name,
|
|
508
|
+
kind: node.kind,
|
|
509
|
+
action: 'unchanged',
|
|
510
|
+
changing: [],
|
|
511
|
+
resolution,
|
|
512
|
+
})
|
|
513
|
+
continue
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// It exists, and the bundle disagrees with it. We cannot know whether
|
|
517
|
+
// somebody edited it here or the bundle is simply newer, so this is a
|
|
518
|
+
// decision rather than a defect — and it is the one an import waits on.
|
|
519
|
+
if (resolution === undefined)
|
|
520
|
+
findings.push({
|
|
521
|
+
kind: 'person',
|
|
522
|
+
code: 'local-edit',
|
|
523
|
+
subjectType: node.kind === 'media' ? 'media' : 'document',
|
|
524
|
+
subjectKey: node.key,
|
|
525
|
+
subjectName: name,
|
|
526
|
+
propertyAlias: null,
|
|
527
|
+
culture: null,
|
|
528
|
+
message: `"${name}" is already here and differs: the bundle would replace ${changing.join(', ')}`,
|
|
529
|
+
link: link(node.key),
|
|
530
|
+
})
|
|
531
|
+
plan.push({
|
|
532
|
+
key: node.key,
|
|
533
|
+
name,
|
|
534
|
+
kind: node.kind,
|
|
535
|
+
action: resolution === 'keep-local' ? 'unchanged' : 'update',
|
|
536
|
+
changing,
|
|
537
|
+
resolution,
|
|
538
|
+
})
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const counts = { create: 0, update: 0, unchanged: 0, skip: 0 }
|
|
542
|
+
for (const entry of plan) counts[entry.action] += 1
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
bundleId: set.manifest.id,
|
|
546
|
+
findings,
|
|
547
|
+
// `auto` is what the import will do; it never holds one up.
|
|
548
|
+
outstanding: findings.filter((f) => f.kind === 'blocking' || f.kind === 'person'),
|
|
549
|
+
plan,
|
|
550
|
+
counts,
|
|
551
|
+
}
|
|
552
|
+
}
|