@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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the site's files say, when the backup has them: the Razor views, the
|
|
3
|
+
* media, and the evidence of packages and custom code that a database cannot
|
|
4
|
+
* give.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
7
|
+
import { join, relative, sep } from 'node:path'
|
|
8
|
+
|
|
9
|
+
export interface SiteFiles {
|
|
10
|
+
root: string
|
|
11
|
+
/** `Views/**` Razor files, relative to the directory that holds `Views`. */
|
|
12
|
+
razor: Array<{ path: string; file: string; lines: number; uses: string[] }>
|
|
13
|
+
/** The directory media keys resolve under, when one was found. */
|
|
14
|
+
mediaDir?: string
|
|
15
|
+
/** NuGet packages the project references, as `Name Version`. */
|
|
16
|
+
packages: string[]
|
|
17
|
+
/** Directories under `App_Plugins`. */
|
|
18
|
+
plugins: string[]
|
|
19
|
+
/** `.cs` files outside build output. */
|
|
20
|
+
csharp: number
|
|
21
|
+
/** Stylesheets and scripts under the web root, relative to it. */
|
|
22
|
+
assets: Array<{ path: string; file: string }>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SKIP = new Set(['bin', 'obj', 'node_modules', '.git', '.vs', 'umbraco'])
|
|
26
|
+
|
|
27
|
+
function walk(dir: string, visit: (file: string) => void, depth = 0): void {
|
|
28
|
+
if (depth > 12 || !existsSync(dir)) return
|
|
29
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
30
|
+
const path = join(dir, entry.name)
|
|
31
|
+
if (entry.isDirectory()) {
|
|
32
|
+
if (!SKIP.has(entry.name)) walk(path, visit, depth + 1)
|
|
33
|
+
} else if (entry.isFile()) visit(path)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The directory, at or below `root`, that directly contains `name`. */
|
|
38
|
+
function findParentOf(root: string, name: string, depth = 0): string | undefined {
|
|
39
|
+
if (existsSync(join(root, name)) && statSync(join(root, name)).isDirectory()) return root
|
|
40
|
+
if (depth > 3) return undefined
|
|
41
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
42
|
+
if (!entry.isDirectory() || SKIP.has(entry.name) || entry.name.startsWith('.')) continue
|
|
43
|
+
const found = findParentOf(join(root, entry.name), name, depth + 1)
|
|
44
|
+
if (found) return found
|
|
45
|
+
}
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const slashes = (path: string): string => path.split(sep).join('/')
|
|
50
|
+
|
|
51
|
+
/** Razor features that mean more than markup has to be rewritten. */
|
|
52
|
+
const RAZOR_USES: Array<[label: string, pattern: RegExp]> = [
|
|
53
|
+
['partials', /Html\.(Partial|RenderPartial|CachedPartial)|<partial\b/],
|
|
54
|
+
['view components', /Component\.InvokeAsync|<vc:/],
|
|
55
|
+
['forms', /Html\.BeginUmbracoForm|BeginForm|asp-action/],
|
|
56
|
+
['macros', /RenderMacro|Umbraco\.RenderMacro/],
|
|
57
|
+
['block rendering', /GetBlockListHtml|GetBlockGridHtml/],
|
|
58
|
+
['injected services', /@inject\b/],
|
|
59
|
+
['dictionary', /GetDictionaryValue/],
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
export function readSiteFiles(root: string, mediaOverride?: string): SiteFiles {
|
|
63
|
+
if (!existsSync(root)) throw new Error(`No site directory at ${root}.`)
|
|
64
|
+
|
|
65
|
+
const webRoot = findParentOf(root, 'Views') ?? root
|
|
66
|
+
const razor: SiteFiles['razor'] = []
|
|
67
|
+
walk(join(webRoot, 'Views'), (file) => {
|
|
68
|
+
if (!file.endsWith('.cshtml')) return
|
|
69
|
+
const text = readFileSync(file, 'utf8')
|
|
70
|
+
razor.push({
|
|
71
|
+
path: slashes(relative(webRoot, file)),
|
|
72
|
+
file,
|
|
73
|
+
lines: text.split('\n').length,
|
|
74
|
+
uses: RAZOR_USES.filter(([, pattern]) => pattern.test(text)).map(([label]) => label),
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const wwwroot = findParentOf(root, 'wwwroot')
|
|
79
|
+
const publicDir = wwwroot ? join(wwwroot, 'wwwroot') : webRoot
|
|
80
|
+
const mediaDir =
|
|
81
|
+
mediaOverride ?? (existsSync(join(publicDir, 'media')) ? join(publicDir, 'media') : undefined)
|
|
82
|
+
|
|
83
|
+
const assets: SiteFiles['assets'] = []
|
|
84
|
+
for (const folder of ['css', 'scripts', 'js']) {
|
|
85
|
+
walk(join(publicDir, folder), (file) => {
|
|
86
|
+
if (/\.(css|js)$/.test(file)) assets.push({ path: slashes(relative(publicDir, file)), file })
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const packages = new Set<string>()
|
|
91
|
+
let csharp = 0
|
|
92
|
+
walk(root, (file) => {
|
|
93
|
+
if (file.endsWith('.cs')) csharp++
|
|
94
|
+
if (!file.endsWith('.csproj')) return
|
|
95
|
+
const xml = readFileSync(file, 'utf8')
|
|
96
|
+
for (const match of xml.matchAll(
|
|
97
|
+
/<PackageReference\s+Include="([^"]+)"(?:\s+Version="([^"]+)")?/g,
|
|
98
|
+
))
|
|
99
|
+
packages.add(match[2] ? `${match[1]} ${match[2]}` : (match[1] as string))
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const pluginsParent = findParentOf(root, 'App_Plugins')
|
|
103
|
+
const plugins = pluginsParent
|
|
104
|
+
? readdirSync(join(pluginsParent, 'App_Plugins'), { withFileTypes: true })
|
|
105
|
+
.filter((entry) => entry.isDirectory())
|
|
106
|
+
.map((entry) => entry.name)
|
|
107
|
+
: []
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
root,
|
|
111
|
+
razor: razor.sort((a, b) => a.path.localeCompare(b.path)),
|
|
112
|
+
mediaDir: mediaDir && existsSync(mediaDir) ? mediaDir : undefined,
|
|
113
|
+
packages: [...packages].sort(),
|
|
114
|
+
plugins: plugins.sort(),
|
|
115
|
+
csharp,
|
|
116
|
+
assets: assets.sort((a, b) => a.path.localeCompare(b.path)),
|
|
117
|
+
}
|
|
118
|
+
}
|
package/src/source.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The staging database: a faithful SQLite copy of the Umbraco database, which
|
|
3
|
+
* everything else reads with plain SQL and never writes.
|
|
4
|
+
*
|
|
5
|
+
* A `.bacpac` is converted into one by `@kineco-au/bacpac-importer`, which
|
|
6
|
+
* knows nothing about Umbraco. A native Umbraco SQLite file already is one.
|
|
7
|
+
*/
|
|
8
|
+
import { Database } from 'bun:sqlite'
|
|
9
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
|
10
|
+
import { tmpdir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { convert, openBacpac, SqliteWriter } from '@kineco-au/bacpac-importer'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Tables the importer reads. Only these are converted from a `.bacpac`: the
|
|
16
|
+
* rest are logs, caches and tokens that cost time and hold nothing to migrate,
|
|
17
|
+
* and a plugin's table may use a column type the converter cannot decode.
|
|
18
|
+
*/
|
|
19
|
+
const WANTED_TABLES = [
|
|
20
|
+
'umbracoNode',
|
|
21
|
+
'umbracoContent',
|
|
22
|
+
'umbracoContentVersion',
|
|
23
|
+
'umbracoContentVersionCultureVariation',
|
|
24
|
+
'umbracoContentVersionCleanupPolicy',
|
|
25
|
+
'umbracoContentSchedule',
|
|
26
|
+
'umbracoDocument',
|
|
27
|
+
'umbracoDocumentVersion',
|
|
28
|
+
'umbracoDocumentCultureVariation',
|
|
29
|
+
'umbracoDocumentUrl',
|
|
30
|
+
'umbracoElement',
|
|
31
|
+
'umbracoElementVersion',
|
|
32
|
+
'umbracoElementCultureVariation',
|
|
33
|
+
'umbracoMediaVersion',
|
|
34
|
+
'umbracoPropertyData',
|
|
35
|
+
'umbracoDataType',
|
|
36
|
+
'umbracoLanguage',
|
|
37
|
+
'umbracoDomain',
|
|
38
|
+
'umbracoKeyValue',
|
|
39
|
+
'umbracoRedirectUrl',
|
|
40
|
+
'umbracoAccess',
|
|
41
|
+
'umbracoAccessRule',
|
|
42
|
+
'umbracoUser',
|
|
43
|
+
'umbracoUserGroup',
|
|
44
|
+
'umbracoExternalLogin',
|
|
45
|
+
'umbracoTwoFactorLogin',
|
|
46
|
+
'umbracoWebhook',
|
|
47
|
+
'umbracoCreatedPackageSchema',
|
|
48
|
+
'cmsContentType',
|
|
49
|
+
'cmsContentType2ContentType',
|
|
50
|
+
'cmsContentTypeAllowedContentType',
|
|
51
|
+
'cmsDocumentType',
|
|
52
|
+
'cmsPropertyType',
|
|
53
|
+
'cmsPropertyTypeGroup',
|
|
54
|
+
'cmsTemplate',
|
|
55
|
+
'cmsDictionary',
|
|
56
|
+
'cmsLanguageText',
|
|
57
|
+
'cmsMember',
|
|
58
|
+
'cmsMemberType',
|
|
59
|
+
'cmsMember2MemberGroup',
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
export type SourceKind = 'bacpac' | 'sqlite'
|
|
63
|
+
|
|
64
|
+
export interface Source {
|
|
65
|
+
readonly kind: SourceKind
|
|
66
|
+
readonly path: string
|
|
67
|
+
/** Every table in the source database, not only the ones that were staged. */
|
|
68
|
+
readonly tables: readonly string[]
|
|
69
|
+
/** What the export says about itself, when it says. */
|
|
70
|
+
readonly exportedAt?: string
|
|
71
|
+
readonly serverVersion?: string
|
|
72
|
+
has(table: string): boolean
|
|
73
|
+
hasColumn(table: string, column: string): boolean
|
|
74
|
+
all<T = Record<string, unknown>>(sql: string, ...params: unknown[]): T[]
|
|
75
|
+
one<T = Record<string, unknown>>(sql: string, ...params: unknown[]): T | undefined
|
|
76
|
+
count(table: string, where?: string): number
|
|
77
|
+
close(): void
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface OpenSourceOptions {
|
|
81
|
+
/** Keep the staging database at this path instead of a temporary one that is removed. */
|
|
82
|
+
staging?: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A uuid as the rest of crombolo spells it. Native Umbraco SQLite stores them upper case. */
|
|
86
|
+
export const key = (value: unknown): string => String(value ?? '').toLowerCase()
|
|
87
|
+
|
|
88
|
+
/** A stored date as `YYYY-MM-DD HH:MM:SS`, whichever of the two source spellings it arrived in. */
|
|
89
|
+
export function wallClock(value: unknown): string | null {
|
|
90
|
+
if (value === null || value === undefined || value === '') return null
|
|
91
|
+
const text = String(value).replace('T', ' ')
|
|
92
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? `${text} 00:00:00` : text.slice(0, 19)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
class SqliteSource implements Source {
|
|
96
|
+
readonly #db: Database
|
|
97
|
+
readonly #staged: Set<string>
|
|
98
|
+
readonly #columns = new Map<string, Set<string>>()
|
|
99
|
+
readonly #cleanup: (() => void) | undefined
|
|
100
|
+
|
|
101
|
+
constructor(
|
|
102
|
+
readonly kind: SourceKind,
|
|
103
|
+
readonly path: string,
|
|
104
|
+
db: Database,
|
|
105
|
+
readonly tables: readonly string[],
|
|
106
|
+
extra: { exportedAt?: string; serverVersion?: string; cleanup?: () => void } = {},
|
|
107
|
+
) {
|
|
108
|
+
this.#db = db
|
|
109
|
+
this.exportedAt = extra.exportedAt
|
|
110
|
+
this.serverVersion = extra.serverVersion
|
|
111
|
+
this.#cleanup = extra.cleanup
|
|
112
|
+
this.#staged = new Set(
|
|
113
|
+
db
|
|
114
|
+
.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'")
|
|
115
|
+
.all()
|
|
116
|
+
.map((row) => row.name.toLowerCase()),
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
readonly exportedAt?: string
|
|
121
|
+
readonly serverVersion?: string
|
|
122
|
+
|
|
123
|
+
has(table: string): boolean {
|
|
124
|
+
return this.#staged.has(table.toLowerCase())
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
hasColumn(table: string, column: string): boolean {
|
|
128
|
+
if (!this.has(table)) return false
|
|
129
|
+
let columns = this.#columns.get(table)
|
|
130
|
+
if (!columns) {
|
|
131
|
+
columns = new Set(
|
|
132
|
+
this.#db
|
|
133
|
+
.query<{ name: string }, []>(`SELECT name FROM pragma_table_info('${table}')`)
|
|
134
|
+
.all()
|
|
135
|
+
.map((row) => row.name.toLowerCase()),
|
|
136
|
+
)
|
|
137
|
+
this.#columns.set(table, columns)
|
|
138
|
+
}
|
|
139
|
+
return columns.has(column.toLowerCase())
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
all<T>(sql: string, ...params: unknown[]): T[] {
|
|
143
|
+
return this.#db.query(sql).all(...(params as never[])) as T[]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
one<T>(sql: string, ...params: unknown[]): T | undefined {
|
|
147
|
+
return (this.#db.query(sql).get(...(params as never[])) as T | null) ?? undefined
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
count(table: string, where?: string): number {
|
|
151
|
+
if (!this.has(table)) return 0
|
|
152
|
+
const row = this.one<{ n: number }>(
|
|
153
|
+
`SELECT COUNT(*) AS n FROM "${table}"${where ? ` WHERE ${where}` : ''}`,
|
|
154
|
+
)
|
|
155
|
+
return Number(row?.n ?? 0)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
close(): void {
|
|
159
|
+
this.#db.close()
|
|
160
|
+
this.#cleanup?.()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function isSqliteFile(path: string): Promise<boolean> {
|
|
165
|
+
const head = await Bun.file(path).slice(0, 16).text()
|
|
166
|
+
return head.startsWith('SQLite format 3')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Opens an Umbraco database backup: a `.bacpac`, or Umbraco's own SQLite file. */
|
|
170
|
+
export async function openSource(path: string, options: OpenSourceOptions = {}): Promise<Source> {
|
|
171
|
+
if (!existsSync(path)) throw new Error(`No file at ${path}.`)
|
|
172
|
+
|
|
173
|
+
if (await isSqliteFile(path)) {
|
|
174
|
+
const db = new Database(path, { readonly: true })
|
|
175
|
+
const tables = db
|
|
176
|
+
.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'")
|
|
177
|
+
.all()
|
|
178
|
+
.map((row) => row.name)
|
|
179
|
+
return new SqliteSource('sqlite', path, db, tables)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let bacpac: Awaited<ReturnType<typeof openBacpac>>
|
|
183
|
+
try {
|
|
184
|
+
bacpac = await openBacpac(path)
|
|
185
|
+
} catch (error) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`${path} is neither a .bacpac nor a SQLite database (${error instanceof Error ? error.message : error}). ` +
|
|
188
|
+
'A SQL Server .bak or a live database is not supported: export a .bacpac from it.',
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const temporary = options.staging ? undefined : mkdtempSync(join(tmpdir(), 'crombolo-import-'))
|
|
193
|
+
const staging = options.staging ?? join(temporary as string, 'staging.sqlite')
|
|
194
|
+
try {
|
|
195
|
+
const tables = bacpac.tables.map((table) => table.name)
|
|
196
|
+
const wanted = new Set(WANTED_TABLES.map((name) => name.toLowerCase()))
|
|
197
|
+
await convert(bacpac, new SqliteWriter(staging, { overwrite: true }), {
|
|
198
|
+
include: tables.filter((name) => wanted.has(name.toLowerCase())),
|
|
199
|
+
})
|
|
200
|
+
return new SqliteSource('bacpac', path, new Database(staging, { readonly: true }), tables, {
|
|
201
|
+
exportedAt: bacpac.origin.exportedAt,
|
|
202
|
+
serverVersion: bacpac.origin.serverVersion,
|
|
203
|
+
cleanup: temporary ? () => rmSync(temporary, { recursive: true, force: true }) : undefined,
|
|
204
|
+
})
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (temporary) rmSync(temporary, { recursive: true, force: true })
|
|
207
|
+
throw error
|
|
208
|
+
} finally {
|
|
209
|
+
await bacpac.close()
|
|
210
|
+
}
|
|
211
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which Umbraco version a database is at.
|
|
3
|
+
*
|
|
4
|
+
* Umbraco records its upgrade plan's state in `umbracoKeyValue`, as the GUID of
|
|
5
|
+
* the last migration that ran. The table below maps every state from 10.0 to
|
|
6
|
+
* 18.1 to the version whose migration reached it, and was extracted from
|
|
7
|
+
* `UmbracoPlan.cs` at the last release of each major (13.16, 14.3, 15.4, 16.5,
|
|
8
|
+
* 17.7, 18.2). A patch release adds no migrations of its own, so a database
|
|
9
|
+
* reports the last release that changed the schema: a 17.7 site reads as 17.6.
|
|
10
|
+
*/
|
|
11
|
+
import type { Source } from './source.ts'
|
|
12
|
+
|
|
13
|
+
/** Upgrade state → the version that reached it, oldest first. */
|
|
14
|
+
export const UPGRADE_STATES: ReadonlyArray<readonly [state: string, version: string]> = [
|
|
15
|
+
['b7e0d53c-2b0e-418b-ab07-2dde486e225f', '10.0.0'],
|
|
16
|
+
['d0b3d29d-f4d5-43e3-ba67-9d49256f3266', '10.2.0'],
|
|
17
|
+
['79d8217b-5920-4c0e-8e9a-3cf8fa021882', '10.2.0'],
|
|
18
|
+
['56833770-3b7e-4fd5-a3b6-3416a26a7a3f', '10.3.0'],
|
|
19
|
+
['3f5d492a-a3db-43f9-a73e-9fee3b180e6c', '10.4.0'],
|
|
20
|
+
['83af7945-dade-4a02-9041-f3f6ebfac319', '10.5.0'],
|
|
21
|
+
['bb3889ed-e2de-49f2-8f71-5fd8616a2661', '11.3.0'],
|
|
22
|
+
['ffb6b9b0-f1a8-45e9-9cd7-25700577d1ca', '11.4.0'],
|
|
23
|
+
['888a0d5d-51e4-4c7e-aa0a-01306523c7fb', '12.0.0'],
|
|
24
|
+
['539f2f83-fba7-4c48-81a3-75081a56bb9d', '12.0.0'],
|
|
25
|
+
['1187192d-edb5-4619-955d-91d48d738871', '12.1.0'],
|
|
26
|
+
['47de85ce-1e16-42a0-8af6-3ec3bcef5471', '12.1.0'],
|
|
27
|
+
['c76d9c9a-635b-4d2c-a301-05642a523e9d', '13.0.0'],
|
|
28
|
+
['d5139400-e507-4259-a542-c67358f7e329', '13.0.0'],
|
|
29
|
+
['4e652f18-9a29-4656-a899-e3f39069c47e', '13.0.0'],
|
|
30
|
+
['148714c8-fe0d-4553-b034-439d91468761', '13.0.0'],
|
|
31
|
+
['23ba95a4-fcce-49b0-8aa1-45312b103a9b', '13.0.0'],
|
|
32
|
+
['7ddce198-9ca4-430c-8bbc-a66d80ca209f', '13.0.0'],
|
|
33
|
+
['f74cda0c-7aaa-48c8-94c6-c6ec3c06f599', '13.0.0'],
|
|
34
|
+
['21c42760-5109-4c03-ab4f-7ea53577d1f5', '13.0.0'],
|
|
35
|
+
['6158f3a3-4902-4201-835e-1ed7f810b2d8', '13.0.0'],
|
|
36
|
+
['985af2ba-69d3-4dba-95e0-ad3fa7459fa7', '13.3.0'],
|
|
37
|
+
['cc47c751-a81b-489a-a2bc-0240245db687', '13.5.0'],
|
|
38
|
+
['eef792fc-318c-4921-9859-51ebf07a53a3', '13.5.0'],
|
|
39
|
+
['419827a0-4fce-464b-a8f3-247c6092af55', '14.0.0'],
|
|
40
|
+
['e073dbc0-9e8e-4c92-8210-9cb18364f46e', '14.0.0'],
|
|
41
|
+
['80d282a4-5497-47ff-991f-bc0bce603121', '14.0.0'],
|
|
42
|
+
['96525697-e9dc-4198-b136-25ad033442b8', '14.0.0'],
|
|
43
|
+
['7fc5ac9b-6f56-415b-913e-4a900629b853', '14.0.0'],
|
|
44
|
+
['1539a010-2eb5-4163-8518-4ae2aa98afc6', '14.0.0'],
|
|
45
|
+
['0d82c836-96dd-480d-a924-7964e458bd34', '14.0.0'],
|
|
46
|
+
['1a0fbc8a-6fc6-456c-805c-b94816b2e570', '14.0.0'],
|
|
47
|
+
['302de171-6d83-4b6b-b3c0-ac8808a16ca1', '14.0.0'],
|
|
48
|
+
['8184e61d-ecba-4aaa-b61b-d7a82eb82eb7', '14.0.0'],
|
|
49
|
+
['e261bf01-2c7f-4544-bae7-49d545b21d68', '14.0.0'],
|
|
50
|
+
['5a2ef07d-37b4-49d5-8e9b-3ed01877263b', '14.0.0'],
|
|
51
|
+
['6fb5ca9e-c823-473b-a14c-fe760d75943c', '14.0.0'],
|
|
52
|
+
['827360ca-0855-42a5-8f86-a51f168cb559', '14.0.0'],
|
|
53
|
+
['fef2daf4-5408-4636-bb0e-b8798df8f095', '14.1.0'],
|
|
54
|
+
['a385c5df-48dc-46b4-a742-d5bb846483bc', '14.1.0'],
|
|
55
|
+
['20ed404c-6ff9-4f91-8ac9-2b298e0002eb', '14.2.0'],
|
|
56
|
+
['7f4f31d8-dd71-4f0d-93fc-2690a924d84b', '15.0.0'],
|
|
57
|
+
['3fe0fa2d-cf4f-4892-ba8d-e97d06e028dc', '15.0.0'],
|
|
58
|
+
['6c04b137-0097-4938-8c6a-276df1a0eca8', '15.0.0'],
|
|
59
|
+
['9d3ce7d4-4884-41d4-98e8-302eb6cb0cf6', '15.0.0'],
|
|
60
|
+
['37875e80-5cdd-42ff-a21a-7d4e3e23e0ed', '15.0.0'],
|
|
61
|
+
['42e44f9e-7262-4269-922d-7310cb48e724', '15.0.0'],
|
|
62
|
+
['7b51b4de-5574-4484-993e-05d12d9ed703', '15.1.0'],
|
|
63
|
+
['f3d3ef46-1b1f-47db-b437-7d573eedeb98', '15.1.0'],
|
|
64
|
+
['7b11f01e-ee33-4b0b-81a1-f78f834ca45b', '15.3.0'],
|
|
65
|
+
['a9e72794-4036-4563-b543-1717c73b8879', '15.4.0'],
|
|
66
|
+
['33d62294-d0de-4a86-a830-991eb36b96da', '15.4.0'],
|
|
67
|
+
['c6681435-584f-4bc8-bb8d-bc853966af0b', '16.0.0'],
|
|
68
|
+
['d1568c33-a697-455f-8d16-48060cb954a1', '16.0.0'],
|
|
69
|
+
['741c22cf-5fb8-4343-bf79-b97a58c2ccba', '16.2.0'],
|
|
70
|
+
['a917fcbc-c378-4a08-a36c-220c581a6581', '16.3.0'],
|
|
71
|
+
['fb7073af-dfaf-4ac1-800d-91f9bd5b5238', '16.3.0'],
|
|
72
|
+
['6a7d3b80-8b64-4e41-a7c0-02ec39336e97', '16.4.0'],
|
|
73
|
+
['17d5f6ca-ceb8-462a-af86-4b9c3bf91cf1', '17.0.0'],
|
|
74
|
+
['eb1e50b7-cd5e-4b6b-b307-36237dd2c506', '17.0.0'],
|
|
75
|
+
['1847c7ff-b021-44eb-beb0-a77a4376a6f2', '17.0.0'],
|
|
76
|
+
['7208b20d-6bfc-472e-9374-85eea817b27d', '17.0.0'],
|
|
77
|
+
['263075bf-f18a-480d-92b4-4947d2eab772', '17.0.0'],
|
|
78
|
+
['26179d88-58ce-4c92-b4a4-3cba6e7188ac', '17.0.0'],
|
|
79
|
+
['8b2c830a-4ffb-4433-8337-8649b0bf52c8', '17.0.0'],
|
|
80
|
+
['1c38d589-26bb-4a46-9abe-e4a0df548a87', '17.0.0'],
|
|
81
|
+
['be5ca411-e12d-4455-a59e-f12a669e5363', '17.0.1'],
|
|
82
|
+
['1ce2e78b-e736-45d8-97a2-ce3ef2f31bcd', '17.1.0'],
|
|
83
|
+
['f1a2b3c4-d5e6-4789-abcd-1234567890ab', '17.2.0'],
|
|
84
|
+
['a7b8c9d0-e1f2-4a5b-8c7d-9e0f1a2b3c4d', '17.2.0'],
|
|
85
|
+
['a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', '17.3.0'],
|
|
86
|
+
['b2f4a1c3-8d5e-4f6a-9b7c-3e1d2a4f5b6c', '17.3.0'],
|
|
87
|
+
['0638e0e0-d914-4aca-8a4b-9551a3aab91f', '17.3.0'],
|
|
88
|
+
['e4a7c2d1-5f38-4b96-a1d3-8e2f6c9b0a74', '17.3.0'],
|
|
89
|
+
['b8c9d0e1-f2a3-4b5c-8d7e-9f0a1b2c3d4e', '17.3.0'],
|
|
90
|
+
['9dbdb5cd-8679-4bb0-bf83-e8d508073ce0', '17.3.0'],
|
|
91
|
+
['6748cb56-cc16-49f0-ba91-b8ece31bf456', '17.3.0'],
|
|
92
|
+
['d4e5f6a7-b8c9-4d0e-a1f2-3b4c5d6e7f80', '17.4.0'],
|
|
93
|
+
['72970b86-59d8-403c-b322-fff43f9db199', '17.4.0'],
|
|
94
|
+
['d7e8f9a0-b1c2-4d3e-a5f6-7890abcdef12', '17.4.0'],
|
|
95
|
+
['3f9b6a1c-7d84-4e2b-9c15-6a2e8f3d5b47', '17.4.0'],
|
|
96
|
+
['3a1a8047-74ae-491a-b2c4-0bae4a1289ec', '17.6.0'],
|
|
97
|
+
['74332c49-b279-4945-8943-f8f00b1f5949', '18.0.0'],
|
|
98
|
+
['d00bb11a-ddf8-47c4-b58e-150c123bb3bb', '18.0.0'],
|
|
99
|
+
['6fe4656e-8b8d-452f-ae2a-438a615b61bc', '18.0.0'],
|
|
100
|
+
['ae533af6-4611-4e25-aa4d-89aefa468e79', '18.1.0'],
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
const VERSION_OF = new Map(UPGRADE_STATES)
|
|
104
|
+
|
|
105
|
+
/** The oldest major the importer converts; anything older is told to upgrade first. */
|
|
106
|
+
export const MINIMUM_MAJOR = 15
|
|
107
|
+
/** The newest major whose shape this importer has been written against. */
|
|
108
|
+
export const NEWEST_MAJOR = 18
|
|
109
|
+
|
|
110
|
+
export const UPGRADE_STATE_KEY = 'Umbraco.Core.Upgrader.State+Umbraco.Core'
|
|
111
|
+
|
|
112
|
+
export type VersionStatus =
|
|
113
|
+
/** A version the importer converts. */
|
|
114
|
+
| 'supported'
|
|
115
|
+
/** Older than the minimum: upgrade with Umbraco first. */
|
|
116
|
+
| 'too-old'
|
|
117
|
+
/** Newer than anything this importer knows; it may still work. */
|
|
118
|
+
| 'newer'
|
|
119
|
+
/** Not recognisably an Umbraco database, or one from before version 10. */
|
|
120
|
+
| 'unknown'
|
|
121
|
+
|
|
122
|
+
export interface DetectedVersion {
|
|
123
|
+
/** `17.0.0`, or undefined when the state is not one this importer knows. */
|
|
124
|
+
version?: string
|
|
125
|
+
major?: number
|
|
126
|
+
state?: string
|
|
127
|
+
status: VersionStatus
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function versionForState(state: string): string | undefined {
|
|
131
|
+
return VERSION_OF.get(state.replace(/[{}]/g, '').toLowerCase())
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function detectVersion(source: Source): DetectedVersion {
|
|
135
|
+
if (!source.has('umbracoKeyValue') || !source.has('umbracoNode')) return { status: 'unknown' }
|
|
136
|
+
const row = source.one<{ value: string | null }>(
|
|
137
|
+
'SELECT value FROM umbracoKeyValue WHERE "key" = ?',
|
|
138
|
+
UPGRADE_STATE_KEY,
|
|
139
|
+
)
|
|
140
|
+
const state = row?.value?.replace(/[{}]/g, '').toLowerCase()
|
|
141
|
+
if (!state) return { status: 'unknown' }
|
|
142
|
+
const version = versionForState(state)
|
|
143
|
+
if (!version) {
|
|
144
|
+
// A state this table has never seen, on a database with the current shape,
|
|
145
|
+
// is a release newer than the table.
|
|
146
|
+
return { state, status: source.has('umbracoPropertyData') ? 'newer' : 'unknown' }
|
|
147
|
+
}
|
|
148
|
+
const major = Number(version.split('.')[0])
|
|
149
|
+
return {
|
|
150
|
+
version,
|
|
151
|
+
major,
|
|
152
|
+
state,
|
|
153
|
+
status: major < MINIMUM_MAJOR ? 'too-old' : major > NEWEST_MAJOR ? 'newer' : 'supported',
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/views.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A TSX stub for each Umbraco template.
|
|
3
|
+
*
|
|
4
|
+
* Razor cannot be translated mechanically, so nothing here tries. Each template
|
|
5
|
+
* gets a view that compiles, sits in the same layout chain and renders the
|
|
6
|
+
* page's name — enough for the imported site to boot and every page to answer
|
|
7
|
+
* — and the original is kept beside the report for whoever rewrites it.
|
|
8
|
+
*/
|
|
9
|
+
import type { SourceTemplate } from './schema.ts'
|
|
10
|
+
|
|
11
|
+
/** `checkout-step_page` → `CheckoutStepPage`; a name TSX accepts for a component. */
|
|
12
|
+
export function componentName(alias: string): string {
|
|
13
|
+
const name = alias
|
|
14
|
+
.split(/[^A-Za-z0-9]+/)
|
|
15
|
+
.filter(Boolean)
|
|
16
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
17
|
+
.join('')
|
|
18
|
+
return /^[A-Za-z]/.test(name) ? name : `View${name}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ViewStubOptions {
|
|
22
|
+
/** Whether another template names this one as its layout. */
|
|
23
|
+
isLayout: boolean
|
|
24
|
+
/** Where the original Razor was put, when the backup had it. */
|
|
25
|
+
original?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function viewStub(template: SourceTemplate, options: ViewStubOptions): string {
|
|
29
|
+
const name = componentName(template.alias)
|
|
30
|
+
const header = [
|
|
31
|
+
`// Imported from Umbraco: a stub for the Razor template "${template.name}".`,
|
|
32
|
+
options.original
|
|
33
|
+
? `// The original is in ${options.original}; this renders nothing of it yet.`
|
|
34
|
+
: '// The original Razor was not in the backup; this renders nothing of it yet.',
|
|
35
|
+
].join('\n')
|
|
36
|
+
const layout = template.layout ? `\nexport const layout = '${template.layout}'\n` : ''
|
|
37
|
+
|
|
38
|
+
if (options.isLayout) {
|
|
39
|
+
const body = template.layout
|
|
40
|
+
? ' return <div>{children}</div>'
|
|
41
|
+
: ` return (
|
|
42
|
+
<html lang="en">
|
|
43
|
+
<head>
|
|
44
|
+
<meta charset="utf-8" />
|
|
45
|
+
<title>{model.name}</title>
|
|
46
|
+
</head>
|
|
47
|
+
<body>{children}</body>
|
|
48
|
+
</html>
|
|
49
|
+
)`
|
|
50
|
+
return `import type { Child, PageProps } from '@crombolo/crombolo'
|
|
51
|
+
|
|
52
|
+
${header}
|
|
53
|
+
${layout}
|
|
54
|
+
export default function ${name}({ ${template.layout ? '' : 'model, '}children }: PageProps & { children?: Child }) {
|
|
55
|
+
${body}
|
|
56
|
+
}
|
|
57
|
+
`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const page = `<main>
|
|
61
|
+
<h1>{model.name}</h1>
|
|
62
|
+
</main>`
|
|
63
|
+
const body = template.layout
|
|
64
|
+
? ` return (
|
|
65
|
+
${page}
|
|
66
|
+
)`
|
|
67
|
+
: ` return (
|
|
68
|
+
<html lang="en">
|
|
69
|
+
<head>
|
|
70
|
+
<meta charset="utf-8" />
|
|
71
|
+
<title>{model.name}</title>
|
|
72
|
+
</head>
|
|
73
|
+
<body>
|
|
74
|
+
<main>
|
|
75
|
+
<h1>{model.name}</h1>
|
|
76
|
+
</main>
|
|
77
|
+
</body>
|
|
78
|
+
</html>
|
|
79
|
+
)`
|
|
80
|
+
return `import type { PageProps } from '@crombolo/crombolo'
|
|
81
|
+
|
|
82
|
+
${header}
|
|
83
|
+
${layout}
|
|
84
|
+
export default function ${name}({ model }: PageProps) {
|
|
85
|
+
${body}
|
|
86
|
+
}
|
|
87
|
+
`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Where a Razor view that is not a template lands under `components/`.
|
|
92
|
+
*
|
|
93
|
+
* The source site's own arrangement is kept, minus the `Views/` prefix it all
|
|
94
|
+
* sat under: `Views/Partials/Hero.cshtml` becomes `components/Partials/Hero.tsx`.
|
|
95
|
+
* Nothing here treats `Partials` as special — it is a folder the author chose,
|
|
96
|
+
* and since a component may sit at any depth it can simply stay where it is.
|
|
97
|
+
*/
|
|
98
|
+
export function componentPathFor(razorPath: string): string {
|
|
99
|
+
const withoutRoot = razorPath.replace(/^Views\//i, '')
|
|
100
|
+
return `components/${withoutRoot.replace(/\.cshtml$/i, '.tsx')}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A stub for a Razor view that no content type names.
|
|
105
|
+
*
|
|
106
|
+
* It is a plain function rather than a default export taking `PageProps`: a
|
|
107
|
+
* template is routed to, and this is imported, so it takes whatever the thing
|
|
108
|
+
* importing it passes.
|
|
109
|
+
*/
|
|
110
|
+
export function componentStub(razorPath: string, original?: string): string {
|
|
111
|
+
const base = razorPath.replace(/^.*\//, '').replace(/\.cshtml$/i, '')
|
|
112
|
+
const name = componentName(base)
|
|
113
|
+
return `${
|
|
114
|
+
original
|
|
115
|
+
? `// Imported from Umbraco: a stub for the Razor view "${razorPath}".\n// The original is in ${original}; this renders nothing of it yet.`
|
|
116
|
+
: `// Imported from Umbraco: a stub for the Razor view "${razorPath}".`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function ${name}() {
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
`
|
|
123
|
+
}
|
package/README.md
DELETED