feat: import 2.x settings subset + remap 2.x align-abstopright class to page icon

pull/8104/head
NGPixel 5 days ago
parent 7c6a3bb590
commit bc405991af
No known key found for this signature in database

@ -284,7 +284,7 @@ async function routes(app: FastifyInstance) {
bodyLimit: MAX_BATCH_BYTES, bodyLimit: MAX_BATCH_BYTES,
schema: { schema: {
summary: 'Write a batch of records belonging to one site', summary: 'Write a batch of records belonging to one site',
description: `The site is a target site on THIS instance, one the session was opened for — a package site the operator did not map is never read.\n\nAt most ${MAX_BATCH_RECORDS} records and ${MAX_BATCH_BYTES / 1024 / 1024} MB per request; a batch of pages or of page history is a batch of whole documents, so the byte ceiling is usually what a caller meets first.\n\nOrder matters between these streams and is the caller's to keep: folders, then blobs, then pages, then the history and comments that hang off them, then the assets those blobs belong to, then navigation. A record whose page was not imported is skipped rather than failing its batch.`, description: `The site is a target site on THIS instance, one the session was opened for — a package site the operator did not map is never read.\n\nAt most ${MAX_BATCH_RECORDS} records and ${MAX_BATCH_BYTES / 1024 / 1024} MB per request; a batch of pages or of page history is a batch of whole documents, so the byte ceiling is usually what a caller meets first.\n\nOrder matters between these streams and is the caller's to keep: folders and the site's own settings, then blobs, then pages, then the history and comments that hang off them, then the assets those blobs belong to, then navigation. A record whose page was not imported is skipped rather than failing its batch.`,
tags: ['Import'], tags: ['Import'],
params: { params: {
type: 'object', type: 'object',
@ -293,7 +293,7 @@ async function routes(app: FastifyInstance) {
siteId: { type: 'string', format: 'uuid' }, siteId: { type: 'string', format: 'uuid' },
stream: { stream: {
type: 'string', type: 'string',
enum: ['tree', 'pages', 'page-history', 'assets', 'comments', 'navigation'] enum: ['tree', 'site', 'pages', 'page-history', 'assets', 'comments', 'navigation']
} }
}, },
required: ['sessionId', 'siteId', 'stream'] required: ['sessionId', 'siteId', 'stream']

@ -1529,6 +1529,8 @@
"admin.utilities.wikijs2Import.progress": "Progress Log", "admin.utilities.wikijs2Import.progress": "Progress Log",
"admin.utilities.wikijs2Import.progressEmpty": "The import has not been started. Progress will be reported here.", "admin.utilities.wikijs2Import.progressEmpty": "The import has not been started. Progress will be reported here.",
"admin.utilities.wikijs2Import.selectArchive": "Select archive...", "admin.utilities.wikijs2Import.selectArchive": "Select archive...",
"admin.utilities.wikijs2Import.settings": "Settings",
"admin.utilities.wikijs2Import.settingsHint": "Only a subset of Wiki.js 2.x settings has an equivalent here — the site title, description, licence, page extensions, theme injections, comments and locales. Everything else is left as this site has it, so review the site configuration once the import has finished.",
"admin.utilities.wikijs2Import.source": "Destination", "admin.utilities.wikijs2Import.source": "Destination",
"admin.utilities.wikijs2Import.start": "Start Import", "admin.utilities.wikijs2Import.start": "Start Import",
"admin.utilities.wikijs2Import.subtitle": "Migrate content from a Wiki.js 2.x backup", "admin.utilities.wikijs2Import.subtitle": "Migrate content from a Wiki.js 2.x backup",
@ -1536,7 +1538,6 @@
"admin.utilities.wikijs2Import.targetSiteHint": "Everything in the archive is written to this site.", "admin.utilities.wikijs2Import.targetSiteHint": "Everything in the archive is written to this site.",
"admin.utilities.wikijs2Import.users": "Users", "admin.utilities.wikijs2Import.users": "Users",
"admin.utilities.wikijs2Import.whatToImport": "What to import", "admin.utilities.wikijs2Import.whatToImport": "What to import",
"admin.utilities.wikijs2Import.whatToImportHint": "Anything unticked is left in the archive.",
"admin.utitilies.purgeHistoryMonth": "1 Month | {count} Months", "admin.utitilies.purgeHistoryMonth": "1 Month | {count} Months",
"admin.utitilies.purgeHistoryToday": "Today", "admin.utitilies.purgeHistoryToday": "Today",
"admin.utitilies.purgeHistoryYear": "1 Year | {count} Years", "admin.utitilies.purgeHistoryYear": "1 Year | {count} Years",

@ -64,9 +64,12 @@ const SUPPORTED_SOURCE = 'wikijs2'
/** /**
* What the operator may tick, as the overlay names them. * What the operator may tick, as the overlay names them.
* *
* `settings` is deliberately absent — see `dev/specs/wkbackup.md` §11. A stream whose content kind is * A stream whose content kind is not in the session's `includes` is refused rather than ignored, so a
* not in the session's `includes` is refused rather than ignored, so a browser that batches something * browser that batches something nobody asked for is told, instead of quietly writing it.
* nobody asked for is told, instead of quietly writing it. *
* `settings` is the site's own settings, from `site.json` — the handful of 2.x keys that have a 3.x
* equivalent, not the instance-wide `streams/settings.json`, which is still only reported. See
* `#siteStream`.
*/ */
export const IMPORT_CONTENT_KINDS = [ export const IMPORT_CONTENT_KINDS = [
'assets', 'assets',
@ -75,7 +78,8 @@ export const IMPORT_CONTENT_KINDS = [
'history', 'history',
'groups', 'groups',
'users', 'users',
'navigation' 'navigation',
'settings'
] as const ] as const
export type ImportContentKind = (typeof IMPORT_CONTENT_KINDS)[number] export type ImportContentKind = (typeof IMPORT_CONTENT_KINDS)[number]
@ -85,6 +89,7 @@ const STREAM_REQUIRES: Record<string, ImportContentKind | null> = {
groups: 'groups', groups: 'groups',
users: 'users', users: 'users',
tree: null, tree: null,
site: 'settings',
pages: 'pages', pages: 'pages',
'page-history': 'history', 'page-history': 'history',
assets: 'assets', assets: 'assets',
@ -96,7 +101,15 @@ const STREAM_REQUIRES: Record<string, ImportContentKind | null> = {
const INSTANCE_STREAMS = new Set(['locales', 'groups', 'users']) const INSTANCE_STREAMS = new Set(['locales', 'groups', 'users'])
/** Streams that belong to a site, and are posted with the target site's id. */ /** Streams that belong to a site, and are posted with the target site's id. */
const SITE_STREAMS = new Set(['tree', 'pages', 'page-history', 'assets', 'comments', 'navigation']) const SITE_STREAMS = new Set([
'tree',
'site',
'pages',
'page-history',
'assets',
'comments',
'navigation'
])
/** Records per batch. The browser's figure too — `dev/specs/wkbackup.md` §7. */ /** Records per batch. The browser's figure too — `dev/specs/wkbackup.md` §7. */
export const MAX_BATCH_RECORDS = 500 export const MAX_BATCH_RECORDS = 500
@ -175,6 +188,31 @@ const SYSTEM_GROUP_SOURCE_IDS = { administrators: 1, guests: 2 }
*/ */
const BODYLESS_EDITORS = new Set(['redirect', 'blog']) const BODYLESS_EDITORS = new Set(['redirect', 'blog'])
/**
* 2.x's way of giving a page an emblem: an image anywhere in the body carrying this class.
*
* Its stylesheet pulled such an image out of the article and pinned it to the top right of the page
* header — `position: absolute; top: -90px; right: 1rem; height: 58px`. 3.x has a field for exactly
* that idea (`pages.icon`, drawn beside the title by `PageHeader.vue`), so the image becomes the
* icon and leaves the body.
*
* Left alone it would be worse than useless here: 3.x parses `{.align-abstopright}` too
* (`markdown-it-attrs`) and the sanitizer keeps `class`, but nothing styles it — so the image would
* render as an ordinary inline picture at full size, wherever in the page it happened to sit.
*/
const V2_ICON_CLASS = 'align-abstopright'
/**
* A markdown image carrying an attribute block: `![alt](/logo.png =60x){.align-abstopright}`.
*
* The size suffix is `markdown-it-imsize`, which both versions use, so the URL is only the first
* token inside the parentheses.
*/
const MD_IMAGE_WITH_ATTRS = /!\[[^\]]*\]\(\s*([^)\s]+)[^)]*\)\{([^}]*)\}/
/** The same thing written as HTML, which is what a 2.x ckeditor or code page holds. */
const HTML_IMAGE = /<img\b[^>]*>/i
/** What a page's `render` says until the render queue gets to it — `dev/specs/wkbackup.md` §8. */ /** What a page's `render` says until the render queue gets to it — `dev/specs/wkbackup.md` §8. */
const PENDING_RENDER_HTML = const PENDING_RENDER_HTML =
'<p class="is-pending-render">This page has been imported and is waiting to be rendered.</p>' '<p class="is-pending-render">This page has been imported and is waiting to be rendered.</p>'
@ -283,6 +321,121 @@ function missingOf(present: Record<string, unknown>): string[] {
.map(([name]) => name) .map(([name]) => name)
} }
/**
* A 2.x locale code matched onto one of this wiki's.
*
* Exact first, ignoring case, since that settles every code the two versions spell the same. Failing
* that, the language subtag alone — `pt-br` finds `pt-BR`, and `zh` finds `zh-CN` where that is the
* only Chinese published. The shortest candidate wins a language match, so a bare language is
* preferred over a regional variant nobody asked for.
*/
function matchLocale(code: string, available: string[]): string | null {
const wanted = code.trim().toLowerCase()
if (!wanted) {
return null
}
const exact = available.find((entry) => entry.toLowerCase() === wanted)
if (exact) {
return exact
}
const language = wanted.split(/[-_]/)[0]
const candidates = available.filter((entry) => entry.toLowerCase().split(/[-_]/)[0] === language)
if (candidates.length < 1) {
return null
}
return [...candidates].sort((a, b) => a.length - b.length || a.localeCompare(b))[0]
}
/** Whether an attribute block or a class list names the 2.x page-icon class. */
function namesIconClass(value: string): boolean {
return new RegExp(`(^|[\\s.])${V2_ICON_CLASS}(\\s|$)`).test(value)
}
/**
* Where a page-icon image actually loads from.
*
* The same resolution the renderer does for any image in a page (`fileSrc` in
* `frontend/src/renderers/shared.js`): a path from the site root, or one relative to the page's own
* folder, both of which name an uploaded file served under `/_files/`. Done here rather than left to
* render time because `pages.icon` is not rendered — it is handed to `<w-icon>` as it stands.
*
* Anything carrying its own scheme is left as it is: an icon hosted elsewhere still loads, and a
* `data:` URI is refused outright rather than stored, since the column is 255 characters.
*/
function pageIconFor(src: string, pagePath: string): string | null {
const value = src.trim().replace(/^["']|["']$/g, '')
if (!value || value.startsWith('data:')) {
return null
}
let resolved = value
if (!/^[a-z][a-z\d+.-]*:/i.test(value) && !value.startsWith('//')) {
const folder = pagePath.split('/').slice(0, -1).join('/')
try {
const url = new URL(value, `http://page.invalid/${folder ? `${folder}/` : ''}`)
resolved = `/_files/${url.pathname.replace(/^\/+/, '')}`
} catch {
return null
}
}
const icon = `img:${resolved}`
// -> `pages.icon` is varchar(255). A path that will not fit is dropped rather than truncated into
// an address that loads nothing
return icon.length <= 255 ? icon : null
}
/**
* Pull a 2.x page-icon image out of a page's source.
*
* One per page — 2.x's stylesheet pinned them all to the same spot, so a second was already
* invisible there — and the first one wins. The match is removed from the content along with the
* blank line it sat on, since an image promoted to the page's icon should not also appear in its
* body.
*
* @returns The icon, and the content with the image taken out.
*/
function extractPageIcon(
content: string,
pagePath: string
): { icon: string | null; content: string } {
const markdown = MD_IMAGE_WITH_ATTRS.exec(content)
if (markdown && namesIconClass(markdown[2])) {
const icon = pageIconFor(markdown[1], pagePath)
if (icon) {
return { icon, content: removeAt(content, markdown.index, markdown[0].length) }
}
}
// -> Scanned rather than matched once: a page may hold several `<img>` and only one of them is this
for (const tag of content.match(new RegExp(HTML_IMAGE.source, 'gi')) ?? []) {
const classAttr = /\bclass\s*=\s*["']([^"']*)["']/i.exec(tag)
if (!classAttr || !namesIconClass(classAttr[1])) {
continue
}
const src = /\bsrc\s*=\s*["']([^"']*)["']/i.exec(tag)
const icon = src ? pageIconFor(src[1], pagePath) : null
if (icon) {
return { icon, content: removeAt(content, content.indexOf(tag), tag.length) }
}
}
return { icon: null, content }
}
/** Cut a span out of the source, and the now-blank line it leaves behind with it. */
function removeAt(content: string, index: number, length: number): string {
const before = content.slice(0, index)
const after = content.slice(index + length)
// -> Only when the image was the whole of its line; one sitting inside a paragraph just goes
if (/(^|\n)[^\S\n]*$/.test(before) && /^[^\S\n]*(\n|$)/.test(after)) {
return (before.replace(/[^\S\n]*$/, '') + after.replace(/^[^\S\n]*\n?/, '')).replace(
/\n{3,}/g,
'\n\n'
)
}
// -> Taken out of the middle of a sentence, the spaces that flanked it would otherwise both remain
return before.replace(/[^\S\n]+$/, ' ') + after.replace(/^[^\S\n]+/, '')
}
/** /**
* A 2.x icon name in Iconify's spelling. * A 2.x icon name in Iconify's spelling.
* *
@ -729,7 +882,7 @@ class Import {
): Promise<IngestResult> { ): Promise<IngestResult> {
switch (stream) { switch (stream) {
case 'locales': case 'locales':
return this.#localesStream(records) return this.#localesStream(session, records)
case 'groups': case 'groups':
return this.#groupsStream(session, records) return this.#groupsStream(session, records)
case 'users': case 'users':
@ -752,6 +905,8 @@ class Import {
switch (stream) { switch (stream) {
case 'tree': case 'tree':
return this.#treeStream(target, records) return this.#treeStream(target, records)
case 'site':
return this.#siteStream(target, records)
case 'pages': case 'pages':
return this.#pagesStream(session, target, records) return this.#pagesStream(session, target, records)
case 'page-history': case 'page-history':
@ -782,10 +937,20 @@ class Import {
* migration is not this feature's business. What matters is that the operator learns their pages * migration is not this feature's business. What matters is that the operator learns their pages
* are arriving in a locale this wiki has no strings for, while there is still time to add it. * are arriving in a locale this wiki has no strings for, while there is still time to add it.
*/ */
async #localesStream(records: any[]): Promise<IngestResult> { async #localesStream(session: ImportSession, records: any[]): Promise<IngestResult> {
const installed = new Set( const installed = new Set(
(await WIKI.models.locales.getInstalledLocales()).map((locale: any) => locale.code) (await WIKI.models.locales.getInstalledLocales()).map((locale: any) => locale.code)
) )
/*
Whether anything is worth saying about a locale that is missing.
The settings stream installs the ones the site actually uses, matching 2.x's codes onto this
wiki's as it goes — and it runs minutes before this log is read. Warning here as well said a
locale was missing and named a screen to go and fix it by hand, for locales the import then
installed by itself two steps later. So the warning belongs to the case where nothing is going
to install them: Settings left unticked.
*/
const settingsWillInstall = session.includes.includes('settings')
const warnings: string[] = [] const warnings: string[] = []
let imported = 0 let imported = 0
for (const record of records) { for (const record of records) {
@ -795,9 +960,17 @@ class Import {
} }
if (installed.has(code)) { if (installed.has(code)) {
imported++ imported++
} else { continue
}
/*
And only for a locale the source wiki was actually SERVING. 2.x's table carries a row for
every locale it knows of, active or not, so most of what arrives here is a locale nobody ever
wrote a page in. `isActive` is the exporter's flag; a package written without it is treated as
active, which errs towards saying too much rather than too little.
*/
if (!settingsWillInstall && record?.isActive !== false) {
warnings.push( warnings.push(
`The package uses the locale "${code}", which is not installed here. Its content is imported; install the locale under Administration → Locale.` `The package uses the locale "${code}", which is not installed here. Its content is imported either way; install it under Administration → Locale, or tick Settings to have the import do it.`
) )
} }
} }
@ -1218,6 +1391,227 @@ class Import {
return { imported, skipped, warnings } return { imported, skipped, warnings }
} }
/**
* The locales a 2.x site was running, matched onto this wiki's codes and installed.
*
* **The codes are not the same vocabulary.** 2.x lowercases everything and carries a mixture of
* bare languages and language-region pairs (`en`, `fr`, `zh`, `pt-br`); 3.x uses BCP-47 as it is
* published upstream, where the region is capitalised (`pt-BR`, `zh-CN`). So an exact match is
* tried first, case-insensitively, and failing that the two are compared on their language alone —
* which is what turns 2.x's `pt-br` into `pt-BR`, and its bare `zh` into whichever Chinese this
* wiki publishes.
*
* A language match prefers the shortest candidate, so a bare `pt` wins over `pt-BR` when both
* exist: picking a region for somebody who never chose one is a guess, where falling back to the
* language is the same thing they had.
*
* Installing pulls the strings from upstream, so it needs the network and is skipped entirely on an
* `offline` instance. None of it is allowed to fail the import: a locale that cannot be fetched is
* reported and left out of the active list, because a site set to a language whose strings are not
* here reads as a half-translated wiki.
*/
async #importLocales(
source: any,
warnings: string[]
): Promise<{ primary: string; active: string[] } | null> {
const wanted = [
...new Set(
[
...(Array.isArray(source?.active) ? source.active : []),
...(source?.primary ? [source.primary] : [])
]
.map((code: unknown) => stringOf(code).trim())
.filter(Boolean)
)
]
if (wanted.length < 1) {
return null
}
const installed = (await WIKI.models.locales.getLocales({ cache: false }))
.filter((locale: any) => locale.isInstalled)
.map((locale: any) => locale.code as string)
/*
What this wiki could have, which is what the codes are matched against: everything published
upstream plus whatever is already here. Fetched once for the whole batch rather than per locale,
and its failure is not fatal — an instance with no route to the internet can still match what it
already holds.
*/
let available = [...installed]
if (WIKI.config.offline) {
warnings.push(
'This instance is offline, so no locale could be downloaded. Only the ones already installed were matched.'
)
} else {
try {
const remote = await WIKI.models.locales.fetchRemoteMetadata()
// -> A published locale is named by its FILE and carries no code of its own, which is how
// `locales.install` reads it too — the two have to agree or nothing installs
available = [
...new Set([
...installed,
...remote.map((entry) => path.basename(entry.file, '.json')).filter(Boolean)
])
]
} catch (err: any) {
warnings.push(`The list of available locales could not be fetched: ${err.message}`)
}
}
const resolved: string[] = []
for (const code of wanted) {
const match = matchLocale(code, available)
if (!match) {
warnings.push(`The locale "${code}" has no equivalent in Wiki.js 3.x and was left out.`)
continue
}
if (match.toLowerCase() !== code.toLowerCase()) {
warnings.push(`The 2.x locale "${code}" was matched to "${match}".`)
}
if (!installed.includes(match)) {
try {
await WIKI.models.locales.install(match)
installed.push(match)
warnings.push(`Installed the locale "${match}".`)
} catch (err: any) {
warnings.push(`The locale "${match}" could not be installed: ${err.message}`)
continue
}
}
resolved.push(match)
}
if (resolved.length < 1) {
return null
}
const primary = source?.primary ? matchLocale(stringOf(source.primary), resolved) : null
return {
// -> The site has to be readable in its primary locale, so one that did not survive the matching
// falls back to whatever did rather than leaving the site pointed at nothing
primary: primary ?? resolved[0],
active: [...new Set(resolved)]
}
}
/**
* The site's own settings, from `site.json`.
*
* Only the keys that mean the same thing in both versions, listed out one by one rather than merged
* wholesale: 2.x's config is a flat bag of a hundred settings whose names mostly do not survive the
* move, and copying what happened to match would write nonsense into a site's configuration the
* first time 2.x reused a name for something else. Everything not named here is left as this site
* has it — which for a site created moments ago is 3.x's own defaults.
*
* Deliberately NOT imported: the hostname, which is this instance's to decide; `logoUrl`, since 3.x
* keeps a site's logo as an uploaded asset rather than a URL; and 2.x's `security`, `uploads` and
* `editShortcuts`, which have no 3.x counterpart worth guessing at. The instance-wide
* `streams/settings.json` — mail, authentication strategies, storage — is a separate job and is
* still only reported.
*/
async #siteStream(target: ImportSessionSite, records: any[]): Promise<IngestResult> {
const warnings: string[] = []
const site = records[0]
if (!site || typeof site !== 'object') {
return { imported: 0, skipped: records.length, warnings }
}
const config: Record<string, any> = {}
const theme: Record<string, any> = {}
const features: Record<string, any> = {}
/** Copy a value across only when the package actually carries one. */
const carry = (target_: Record<string, any>, key: string, value: unknown) => {
if (value !== undefined && value !== null) {
target_[key] = value
}
}
carry(config, 'title', typeof site.title === 'string' ? site.title : undefined)
carry(config, 'company', typeof site.company === 'string' ? site.company : undefined)
carry(
config,
'contentLicense',
typeof site.contentLicense === 'string' ? site.contentLicense : undefined
)
// -> Renamed rather than remapped: the same field, called something else here
carry(
config,
'footerExtra',
typeof site.footerOverride === 'string' ? site.footerOverride : undefined
)
// -> 2.x writes an array; a comma string is accepted too, since the column is a text array either way
const extensions = Array.isArray(site.pageExtensions)
? site.pageExtensions
: typeof site.pageExtensions === 'string'
? site.pageExtensions.split(',')
: null
if (extensions) {
const cleaned = extensions
.map((ext: unknown) => stringOf(ext).trim().toLowerCase().replace(/^\./, ''))
.filter(Boolean)
if (cleaned.length > 0) {
config.pageExtensions = [...new Set(cleaned)]
}
}
// -> 2.x keeps the page description under `seo`; 3.x keeps it beside the title, where the rest of
// what describes a site lives
carry(
config,
'description',
typeof site.seo?.description === 'string' ? site.seo.description : undefined
)
/*
2.x's meta robots is a list of the directives it emits — `['index', 'follow']` — where 3.x asks
the two questions separately. A directive and its negation can both be absent, which is 2.x
emitting nothing and means the default rather than false, so each is read as "not denied".
*/
if (Array.isArray(site.seo?.robots)) {
const directives = site.seo.robots.map((d: unknown) => stringOf(d).trim().toLowerCase())
config.robots = {
index: !directives.includes('noindex'),
follow: !directives.includes('nofollow')
}
}
carry(
theme,
'dark',
typeof site.theme?.darkMode === 'boolean' ? site.theme.darkMode : undefined
)
for (const key of ['injectCSS', 'injectHead', 'injectBody']) {
carry(theme, key, typeof site.theme?.[key] === 'string' ? site.theme[key] : undefined)
}
if (Object.keys(theme).length > 0) {
config.theme = theme
}
carry(
features,
'comments',
typeof site.features?.featurePageComments === 'boolean'
? site.features.featurePageComments
: undefined
)
if (Object.keys(features).length > 0) {
config.features = features
}
const locales = await this.#importLocales(site.locales, warnings)
if (locales) {
config.locales = locales
}
if (Object.keys(config).length < 1) {
warnings.push('The package carried no site settings this wiki could use.')
return { imported: 0, skipped: 1, warnings }
}
await WIKI.models.sites.updateSite(target.siteId, { config })
warnings.push(`Site settings applied: ${Object.keys(config).sort().join(', ')}.`)
return { imported: 1, skipped: 0, warnings }
}
/** /**
* Pages. * Pages.
* *
@ -1239,6 +1633,7 @@ class Import {
const warnings: string[] = [] const warnings: string[] = []
let imported = 0 let imported = 0
let skipped = 0 let skipped = 0
let icons = 0
for (const record of records) { for (const record of records) {
const pagePath = stringOf(record?.path) const pagePath = stringOf(record?.path)
@ -1260,6 +1655,13 @@ class Import {
continue continue
} }
/*
2.x's page emblem, promoted to the field 3.x has for it. Taken out before the page is written,
so that the body stored, mirrored to every storage target and rendered is the one without it —
rather than saving the image and editing it back out afterwards.
*/
const { icon, content: body } = extractPageIcon(content, pagePath)
const sourceEditor = stringOf(record?.editorKey, 'markdown') const sourceEditor = stringOf(record?.editorKey, 'markdown')
const editor = EDITOR_MAP[sourceEditor] ?? 'markdown' const editor = EDITOR_MAP[sourceEditor] ?? 'markdown'
if (!EDITOR_MAP[sourceEditor]) { if (!EDITOR_MAP[sourceEditor]) {
@ -1280,7 +1682,7 @@ class Import {
stringOf(tag) stringOf(tag)
), ),
isPublished: record?.isPublished !== false, isPublished: record?.isPublished !== false,
content: editor === 'redirect' ? this.#redirectContent(content) : content, content: editor === 'redirect' ? this.#redirectContent(body) : body,
createdAt: dateOf(record?.createdAt), createdAt: dateOf(record?.createdAt),
updatedAt: dateOf(record?.updatedAt), updatedAt: dateOf(record?.updatedAt),
authorId: await this.#authorFor(session, record?.authorId), authorId: await this.#authorFor(session, record?.authorId),
@ -1303,6 +1705,10 @@ class Import {
continue continue
} }
await this.#markPendingRender(target.siteId, page.id, editor) await this.#markPendingRender(target.siteId, page.id, editor)
if (icon) {
await WIKI.db.update(pagesTable).set({ icon }).where(eq(pagesTable.id, page.id))
icons++
}
// -> The comments stream names its page by the 2.x page id and has nothing else to go on // -> The comments stream names its page by the 2.x page id and has nothing else to go on
await this.#remember(session.id, 'page', [{ sourceId: record?.id, targetId: page.id }]) await this.#remember(session.id, 'page', [{ sourceId: record?.id, targetId: page.id }])
imported++ imported++
@ -1311,6 +1717,11 @@ class Import {
skipped++ skipped++
} }
} }
if (icons > 0) {
warnings.push(
`${icons} pages had a corner image (.${V2_ICON_CLASS}); it became the page icon and was taken out of the body.`
)
}
return { imported, skipped, warnings } return { imported, skipped, warnings }
} }
@ -1484,6 +1895,9 @@ class Import {
skipped++ skipped++
continue continue
} }
// -> Stripped here as well, with no icon taken from it: restoring one of these versions must not
// put the corner image back into a body the page no longer keeps it in
const { content: body } = extractPageIcon(content, pagePath)
const id = this.#derive(session, target.sourceId, 'pageHistory', record?.id) const id = this.#derive(session, target.sourceId, 'pageHistory', record?.id)
const values = { const values = {
@ -1493,7 +1907,7 @@ class Import {
locale, locale,
path: pagePath, path: pagePath,
title: stringOf(record?.title, pagePath), title: stringOf(record?.title, pagePath),
content, content: body,
meta: { meta: {
description: stringOf(record?.description), description: stringOf(record?.description),
editor: EDITOR_MAP[stringOf(record?.editorKey, 'markdown')] ?? 'markdown', editor: EDITOR_MAP[stringOf(record?.editorKey, 'markdown')] ?? 'markdown',

@ -598,11 +598,17 @@ than a blob on the session because users and pages are unbounded: a jsonb column
batch is megabytes of write amplification by the end of a large import. Rows go with the session, batch is megabytes of write amplification by the end of a large import. Rows go with the session,
which is what stops it becoming a permanent record of somebody's old instance. which is what stops it becoming a permanent record of somebody's old instance.
### Settings are not imported ### Settings: the site's, yes; the instance's, not yet
[§10](#10-decisions-taken) item 5 stands as the design, and `streams/settings.json` is read far [§10](#10-decisions-taken) item 5 stands — the exporter judges nothing and the importer owns the
enough to be reported in the log, but nothing is applied: which 2.x key means what in 3.x is a table mapping — and the mapping is now drawn for the SITE's settings, from `sites/<site>/site.json`. The
of its own and is being settled separately. The overlay offers no Settings tick. named keys are listed in [§12](#sitessitesitejson); everything else in that document is left as this
site has it.
The instance-wide `streams/settings.json` — mail, authentication strategies, storage, API keys — is
still only reported. Those are not a site's settings and several of them carry credentials for
services this instance may not be able to reach; each wants deciding on its own rather than as part
of a content import.
--- ---
@ -740,6 +746,18 @@ would be handed a document it does not parse. Every page that takes that road is
A 2.x redirection's `content` is the target path; 3.x holds a redirection as JSON, so it is rewritten. A 2.x redirection's `content` is the target path; 3.x holds a redirection as JSON, so it is rewritten.
**A corner image becomes the page's icon.** 2.x had no icon field, so a per-page emblem was an image
anywhere in the body carrying `.align-abstopright`, which its stylesheet lifted out of the article and
pinned to the top right of the header. 3.x has `pages.icon` for exactly that, so the first such image
per page is promoted to `img:<url>` and removed from the body — resolved to a `/_files/` URL the way
the renderer resolves any image, including one written relative to the page's own folder.
Not a nicety: 3.x parses `{.align-abstopright}` too and its sanitizer keeps the class, but nothing
styles it, so the image would otherwise render as an ordinary full-size picture wherever it happened
to sit in the source. Both spellings are recognised — the markdown attribute block and the `<img
class="…">` a ckeditor page holds — and the same strip runs over page history, so restoring an old
version does not put the image back in a body the page no longer keeps it in.
### `sites/<site>/page-history.ndjson` ### `sites/<site>/page-history.ndjson`
Rows of 2.x's `pageHistory`, with `tags`: Rows of 2.x's `pageHistory`, with `tags`:
@ -855,6 +873,61 @@ cannot say what it is is dropped and named, rather than becoming a blank link. `
stored as a webfont name 3.x no longer ships. `visibilityMode: "restricted"` carries its groups stored as a webfont name 3.x no longer ships. `visibilityMode: "restricted"` carries its groups
through the id map. through the id map.
### `sites/<site>/site.json` and `streams/settings.json` ### `sites/<site>/site.json`
Read far enough to be reported, not applied — [§11](#settings-are-not-imported). The site's own settings, and the one document the manifest does not declare — the exporter writes it
unconditionally at this path, so it is read by convention.
```json
{ "id": "default", "title": "Wiki.js", "company": "requarks.io",
"contentLicense": "alr", "footerOverride": "",
"pageExtensions": ["md", "html", "txt"],
"locales": { "primary": "en", "active": ["en", "ar", "fr"], "namespacing": false },
"theme": { "darkMode": false, "injectCSS": "", "injectHead": "", "injectBody": "" },
"features": { "featurePageComments": false },
"seo": { "description": "Documentation for Wiki.js", "robots": ["index", "follow"] } }
```
Only the keys below are read, named one by one rather than merged wholesale: 2.x's config is a bag of
a hundred settings whose names mostly do not survive the move, and copying whatever happened to match
would write nonsense into a site the first time 2.x reused a name for something else.
| 2.x | 3.x |
| --- | --- |
| `title`, `company`, `contentLicense` | the same, unchanged — even the licence codes agree |
| `footerOverride` | `footerExtra` — the same field under another name |
| `pageExtensions` | the same; a comma string is accepted as well as a list |
| `seo.description` | `description`, which is where the rest of what describes a site lives |
| `seo.robots` | `robots.index` / `robots.follow` |
| `theme.darkMode` | `theme.dark` |
| `theme.injectCSS` / `injectHead` / `injectBody` | the same |
| `features.featurePageComments` | `features.comments` |
| `locales.primary` / `locales.active` | the same, after the matching below |
`seo.robots` is 2.x's list of the directives it emits. A directive and its negation can both be
absent — 2.x emitting nothing, which means the default rather than false — so each of the two is read
as "not denied" rather than "present".
**Deliberately not imported**: `hostname`, which is this instance's to decide; `logoUrl`, since 3.x
keeps a site's logo as an uploaded asset; and 2.x's `security`, `uploads` and `editShortcuts`, which
have no 3.x counterpart worth guessing at.
#### Locale codes are not the same vocabulary
2.x lowercases everything and carries a mixture of bare languages and language-region pairs (`en`,
`fr`, `pt-br`); 3.x uses BCP-47 as it is published upstream, where the region is capitalised
(`pt-BR`, `zh-CN`). So a code is matched **exactly first, ignoring case**, and failing that on its
**language subtag alone** — which turns `pt-br` into `pt-BR` and a bare `zh` into whichever Chinese
this wiki publishes. A language match prefers the shortest candidate, so a bare `pt` wins over
`pt-BR` where both exist: picking a region for somebody who never chose one is a guess, where falling
back to the language is what they already had.
Every matched locale is then **installed**, which pulls its strings from upstream. That needs the
network, is skipped entirely on an `offline` instance, and is never allowed to fail the import: a
locale that cannot be fetched is reported and left out of the active list, because a site set to a
language whose strings are not here reads as a half-translated wiki. A primary locale that did not
survive falls back to one that did rather than leaving the site pointed at nothing.
### `streams/settings.json`
Read far enough to be reported, not applied — [§11](#settings-the-sites-yes-the-instances-not-yet).

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts. removing an icon; `check-icons.mjs` fails the build if this drifts.
277 icons. 278 icons.
*/ */
export const BUNDLED_ICONS = { export const BUNDLED_ICONS = {
"la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32}, "la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32},
@ -245,6 +245,7 @@ export const BUNDLED_ICONS = {
"mdi:inbox-full": {"body":"<path fill=\"currentColor\" d=\"M19 15V5H5v10h4c0 1.66 1.34 3 3 3s3-1.34 3-3zm0-12c1.1 0 2 .9 2 2v14c0 1.1-.9 2-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zM7 13v-2h10v2zm0-4V7h10v2z\"/>","width":24,"height":24}, "mdi:inbox-full": {"body":"<path fill=\"currentColor\" d=\"M19 15V5H5v10h4c0 1.66 1.34 3 3 3s3-1.34 3-3zm0-12c1.1 0 2 .9 2 2v14c0 1.1-.9 2-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zM7 13v-2h10v2zm0-4V7h10v2z\"/>","width":24,"height":24},
"mdi:information": {"body":"<path fill=\"currentColor\" d=\"M13 9h-2V7h2m0 10h-2v-6h2m-1-9A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2\"/>","width":24,"height":24}, "mdi:information": {"body":"<path fill=\"currentColor\" d=\"M13 9h-2V7h2m0 10h-2v-6h2m-1-9A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2\"/>","width":24,"height":24},
"mdi:information-box": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14c0 .53-.21 1.04-.59 1.41c-.37.38-.88.59-1.41.59H5c-.53 0-1.04-.21-1.41-.59C3.21 20.04 3 19.53 3 19V5c0-1.11.89-2 2-2m8 6V7h-2v2zm0 8v-6h-2v6z\"/>","width":24,"height":24}, "mdi:information-box": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14c0 .53-.21 1.04-.59 1.41c-.37.38-.88.59-1.41.59H5c-.53 0-1.04-.21-1.41-.59C3.21 20.04 3 19.53 3 19V5c0-1.11.89-2 2-2m8 6V7h-2v2zm0 8v-6h-2v6z\"/>","width":24,"height":24},
"mdi:information-outline": {"body":"<path fill=\"currentColor\" d=\"M11 9h2V7h-2m1 13c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m0-18A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2m-1 15h2v-6h-2z\"/>","width":24,"height":24},
"mdi:keyboard-variant": {"body":"<path fill=\"currentColor\" d=\"M6 16h12v2H6zm0-3v2H2v-2zm1 2v-2h3v2zm4 0v-2h2v2zm3 0v-2h3v2zm4 0v-2h4v2zM2 10h3v2H2zm17 2v-2h3v2zm-1 0h-2v-2h2zM8 12H6v-2h2zm4 0H9v-2h3zm3 0h-2v-2h2zM2 9V7h2v2zm3 0V7h2v2zm3 0V7h2v2zm3 0V7h2v2zm3 0V7h2v2zm3 0V7h5v2z\"/>","width":24,"height":24}, "mdi:keyboard-variant": {"body":"<path fill=\"currentColor\" d=\"M6 16h12v2H6zm0-3v2H2v-2zm1 2v-2h3v2zm4 0v-2h2v2zm3 0v-2h3v2zm4 0v-2h4v2zM2 10h3v2H2zm17 2v-2h3v2zm-1 0h-2v-2h2zM8 12H6v-2h2zm4 0H9v-2h3zm3 0h-2v-2h2zM2 9V7h2v2zm3 0V7h2v2zm3 0V7h2v2zm3 0V7h2v2zm3 0V7h2v2zm3 0V7h5v2z\"/>","width":24,"height":24},
"mdi:letter-a-box": {"body":"<path fill=\"currentColor\" d=\"M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zm8 2a2 2 0 0 0-2 2v8h2v-4h2v4h2V9a2 2 0 0 0-2-2zm0 2h2v2h-2z\"/>","width":24,"height":24}, "mdi:letter-a-box": {"body":"<path fill=\"currentColor\" d=\"M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zm8 2a2 2 0 0 0-2 2v8h2v-4h2v4h2V9a2 2 0 0 0-2-2zm0 2h2v2h-2z\"/>","width":24,"height":24},
"mdi:letter-b-box": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m10 7.5V9a2 2 0 0 0-2-2H9v10h4a2 2 0 0 0 2-2v-1.5c0-.8-.7-1.5-1.5-1.5c.8 0 1.5-.7 1.5-1.5M13 15h-2v-2h2zm0-4h-2V9h2z\"/>","width":24,"height":24}, "mdi:letter-b-box": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m10 7.5V9a2 2 0 0 0-2-2H9v10h4a2 2 0 0 0 2-2v-1.5c0-.8-.7-1.5-1.5-1.5c.8 0 1.5-.7 1.5-1.5M13 15h-2v-2h2zm0-4h-2V9h2z\"/>","width":24,"height":24},

@ -76,10 +76,7 @@
</w-card> </w-card>
<w-card class="pb-2"> <w-card class="pb-2">
<w-card-header> <w-card-header>{{ t('admin.utilities.wikijs2Import.whatToImport') }}</w-card-header>
{{ t('admin.utilities.wikijs2Import.whatToImport') }}
<template #hint>{{ t('admin.utilities.wikijs2Import.whatToImportHint') }}</template>
</w-card-header>
<!-- <!--
A compact checklist rather than `w-item` rows: eight of those stand 450px tall and A compact checklist rather than `w-item` rows: eight of those stand 450px tall and
push the Start Import button below the fold, which is the one control on this panel push the Start Import button below the fold, which is the one control on this panel
@ -99,13 +96,30 @@
entry.parent ? 'content-child' : '', entry.parent ? 'content-child' : '',
entry.isLastChild ? 'content-child--last' : '' entry.isLastChild ? 'content-child--last' : ''
]"> ]">
<w-checkbox <div class="flex items-center gap-1">
v-model="state.content[entry.key]" <w-checkbox
color="primary" v-model="state.content[entry.key]"
:label="entry.label" color="primary"
:disabled=" :label="entry.label"
isRunning || (entry.parent ? !state.content[entry.parent] : false) :disabled="
" /> isRunning || (entry.parent ? !state.content[entry.parent] : false)
" />
<!--
The wrapper is what carries the tooltip, and what makes it reachable without a
mouse: `tabindex` puts it in the tab order so `focusin` shows the same text,
and the label is on the element itself so a screen reader gets it whether or
not the tooltip is drawn. See WTooltip.
-->
<span
v-if="entry.hint"
data-tooltip-anchor
tabindex="0"
class="inline-flex cursor-help text-grey-6 outline-offset-2"
:aria-label="entry.hint">
<w-icon name="mdi:information-outline" size="16px" />
<w-tooltip>{{ entry.hint }}</w-tooltip>
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -280,7 +294,8 @@ const state = reactive({
history: true, history: true,
groups: true, groups: true,
users: true, users: true,
navigation: true navigation: true,
settings: true
}, },
overwrite: false, overwrite: false,
/** The `File` the picker handed back, kept whole so its name and size can be shown. */ /** The `File` the picker handed back, kept whole so its name and size can be shown. */
@ -329,7 +344,16 @@ const contentEntries = computed(() => [
{ key: 'history', col: 1, parent: 'pages', label: t('admin.utilities.wikijs2Import.history') }, { key: 'history', col: 1, parent: 'pages', label: t('admin.utilities.wikijs2Import.history') },
{ key: 'groups', col: 2, label: t('admin.utilities.wikijs2Import.groups') }, { key: 'groups', col: 2, label: t('admin.utilities.wikijs2Import.groups') },
{ key: 'users', col: 2, label: t('admin.utilities.wikijs2Import.users') }, { key: 'users', col: 2, label: t('admin.utilities.wikijs2Import.users') },
{ key: 'navigation', col: 2, label: t('admin.utilities.wikijs2Import.navigation') } { key: 'navigation', col: 2, label: t('admin.utilities.wikijs2Import.navigation') },
{
key: 'settings',
col: 2,
label: t('admin.utilities.wikijs2Import.settings'),
// -> The one tick whose name promises more than it does: a handful of 2.x keys have a 3.x
// equivalent and the rest are left alone, which is worth saying before somebody assumes their
// whole configuration came across
hint: t('admin.utilities.wikijs2Import.settingsHint')
}
]) ])
/** /**

@ -206,6 +206,34 @@ function buildPlan({ manifest, packageSite, includes, log }) {
}) })
} }
/*
The site's own settings, and they go FIRST of everything site-scoped.
Not cosmetic ordering: `pageExtensions` is what decides whether an uploaded file is really a page,
so an asset imported before that setting lands is judged by the wrong list. The locales are the
same story one level up — content arrives in them, and they should exist first.
One document, and the only one the manifest does not declare: the exporter writes it
unconditionally at a path the format fixes (`dev/specs/wkbackup.md` §2), so it is read by
convention rather than looked up. Absent, the step contributes nothing.
*/
if (includes.includes('settings')) {
add({
kind: 'json',
label: labelFor('site'),
count: null,
url: (session, siteId) => `import/sessions/${session.id}/sites/${siteId}/streams/site`,
read: async (pkg) => {
const entry = pkg.entry(`sites/${packageSite.id ?? 'default'}/site.json`)
if (!entry) {
log('warn', 'Settings: the package carries no site.json.')
return []
}
return [await readJson(entry)]
}
})
}
for (const { name, requires } of SITE_STREAMS) { for (const { name, requires } of SITE_STREAMS) {
const declared = packageSite.streams?.[name] const declared = packageSite.streams?.[name]
if (!declared?.path || (requires && !includes.includes(requires))) { if (!declared?.path || (requires && !includes.includes(requires))) {
@ -409,6 +437,7 @@ function describeGenerator(manifest) {
} }
const LABELS = { const LABELS = {
site: 'Settings',
locales: 'Locales', locales: 'Locales',
groups: 'Groups', groups: 'Groups',
users: 'Users', users: 'Users',

Loading…
Cancel
Save