feat: handle 2.x ckeditor conversion to markdown during import

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

@ -246,7 +246,7 @@ npm run dev # nodemon, restarts on any backend file change
npm run start # plain node
npm run typecheck # tsc — type check only, never emits
npm run typecheck:watch
npm run db-generate # drizzle-kit generate — after editing db/schema.ts
npm run db-generate -- --name=foo-column # drizzle-kit generate — after editing db/schema.ts
npm run db-up # drizzle-kit up
# frontend
@ -605,7 +605,11 @@ Consequences worth knowing:
`reply.unauthorized()`, `reply.forbidden()`). The `setErrorHandler` in `index.ts` shapes `/_api/`
failures into `{ ok, error, statusCode, message }` JSON.
- **Schema changes**: edit `db/schema.ts`, then `npm run db-generate` and commit the generated
migration. Never hand-edit an existing migration.
migration. Never hand-edit an existing migration. **Always pass a name that says what the migration
does** — `npm run db-generate -- --name=comment-handles`, kebab-case, one or two terms. The script
carries `--name=scarlett` (the branch name) as its default and a later `--name` on the command line
overrides it, so leaving it off files the change as another `…_scarlett` directory that says nothing
about it.
- **A module prop marked `sensitive` is write-only.** A route answering with a module's stored config
runs it through `maskSensitiveProps` (`helpers/common.ts`) first, which replaces every non-empty
sensitive value with `SENSITIVE_MASK`; the client posts the whole configuration back, and

@ -78,6 +78,7 @@ async function routes(app: FastifyInstance) {
sites: ImportSessionSite[]
includes: string[]
overwrite?: boolean
htmlConversion?: string
}
}>(
'/import/sessions',
@ -117,7 +118,14 @@ async function routes(app: FastifyInstance) {
description:
'Settings are not importable — which 2.x key means what in 3.x is being settled separately.'
},
overwrite: { type: 'boolean', default: false }
overwrite: { type: 'boolean', default: false },
htmlConversion: {
type: 'string',
enum: ['markdown', 'html'],
default: 'markdown',
description:
'What to do with a page 2.x wrote as HTML — its WYSIWYG and code editors, which 3.x has no equivalent of. `markdown` converts it and files it under the visual editor, so it stays editable the way it was written. `html` keeps the HTML exactly as it stands: it renders identically, but the page is only editable as source.'
}
},
required: ['source', 'sites', 'includes']
},
@ -133,6 +141,7 @@ async function routes(app: FastifyInstance) {
sites: req.body.sites,
includes: req.body.includes,
overwrite: req.body.overwrite === true,
htmlConversion: req.body.htmlConversion,
actorId: req.session?.user?.id ?? null
})
await audit(req, 'admin', 'startImport', {

@ -51,6 +51,7 @@ export function registerSchemas(app: FastifyInstance) {
},
includes: { type: 'array', items: { type: 'string' } },
overwrite: { type: 'boolean' },
htmlConversion: { type: 'string', enum: ['markdown', 'html'] },
state: { type: 'string', enum: ['open', 'finished', 'failed'] },
progress: {
type: 'object',

@ -0,0 +1 @@
ALTER TABLE "importSessions" ADD COLUMN "htmlConversion" varchar(16) DEFAULT 'markdown' NOT NULL;

File diff suppressed because it is too large Load Diff

@ -447,6 +447,16 @@ export const importSessions = pgTable(
/** Which content kinds the operator ticked. A stream for anything absent is refused. */
includes: jsonb().notNull().default([]),
overwrite: boolean().notNull().default(false),
/**
* What to do with a page 2.x wrote as HTML — its WYSIWYG and code editors.
*
* `markdown` converts it and files it under 3.x's visual editor, so it stays editable the way it
* was written; `html` keeps the HTML exactly as it stands, which renders identically but is only
* editable as source. It is a per-import choice because it is a trade the operator has to make
* rather than one this code can make for them: a conversion is a rewrite of their content, and
* the alternative is a wiki nobody can edit visually again.
*/
htmlConversion: varchar({ length: 16 }).notNull().default('markdown'),
state: importSessionStateEnum().notNull().default('open'),
/** `{ <stream>: <records written> }`, which is what a resumed tab reads to find its place. */
progress: jsonb().notNull().default({}),

@ -1517,6 +1517,10 @@
"admin.utilities.wikijs2Import.comments": "Comments",
"admin.utilities.wikijs2Import.groups": "Groups",
"admin.utilities.wikijs2Import.history": "History",
"admin.utilities.wikijs2Import.htmlConversion": "Visual Editor Conversion",
"admin.utilities.wikijs2Import.htmlConversionHint": "3.x uses markdown for the visual editor and requires a conversion from HTML to markdown to be editable. Should affected pages be converted to markdown to keep using the visual editor or stay as HTML but only editable as code?",
"admin.utilities.wikijs2Import.htmlConversionHtml": "Keep as raw HTML",
"admin.utilities.wikijs2Import.htmlConversionMarkdown": "Convert to markdown (recommended)",
"admin.utilities.wikijs2Import.inProgress": "Import in progress...",
"admin.utilities.wikijs2Import.navigation": "Navigation",
"admin.utilities.wikijs2Import.logStarted": "Reading {file}...",

@ -1,6 +1,8 @@
import crypto from 'node:crypto'
import fs from 'node:fs/promises'
import path from 'node:path'
import TurndownService from 'turndown'
import { gfm } from '@joplin/turndown-plugin-gfm'
import { v5 as uuidv5 } from 'uuid'
import { and, eq, inArray, lt, sql } from 'drizzle-orm'
import {
@ -158,6 +160,70 @@ const EDITOR_MAP: Record<string, string> = {
code: 'markdown'
}
/**
* The 2.x editor the conversion option governs, and the only one: its WYSIWYG editor.
*
* Both of 2.x's HTML editors lose their counterpart in 3.x, but only one of them raises a question.
* A `ckeditor` page was WRITTEN as formatted text and happens to be stored as HTML, so converting it
* gives its author back the editor they had — which is what `htmlConversion` decides.
*
* A `code` page is the opposite: its author chose to write HTML, and the HTML is the document rather
* than a representation of one. Converting it would throw away the thing they were editing, so it is
* never converted whatever the option says — it becomes a markdown page holding that HTML, which
* renders identically (markdown is configured with `allowHTML`) and is still edited as source, which
* is how it was edited in 2.x.
*/
const V2_VISUAL_EDITOR = 'ckeditor'
/** Why a page changed editor, for the one line per kind the log gets about it. */
const EDITOR_CHANGE_NOTES: Record<string, Record<string, string>> = {
ckeditor: {
visual:
'Their HTML was converted to markdown, so they open in the visual editor as they did in 2.x.',
markdown:
'Their HTML was kept as it was — it renders the same, since markdown is configured to allow it — but editing one shows HTML source rather than a formatting toolbar.'
},
code: {
markdown:
'3.x has no raw-HTML editor, so they became markdown pages holding that HTML — it renders the same and is still edited as source, as it was in 2.x.'
}
}
/**
* HTML → markdown, for a page 2.x wrote in an editor 3.x does not have.
*
* Turndown with the GitHub-flavoured rules (the Joplin fork, which is maintained where the original is not), and the plugin is not optional: a WYSIWYG page is mostly
* tables, and plain Turndown has no rule for one — it would flatten a table to a run of loose text
* and nobody would notice until they opened the page.
*
* Built once. The service holds only its rules, so the same instance converts every page of an
* import rather than being rebuilt per record.
*/
let turndown: TurndownService | null = null
function htmlToMarkdown(html: string): string {
turndown ??= new TurndownService({
headingStyle: 'atx',
hr: '---',
codeBlockStyle: 'fenced',
bulletListMarker: '-',
emDelimiter: '*'
}).use(gfm)
return turndown.turndown(html)
}
/**
* Which 3.x editor a page ends up in.
*
* Every 2.x editor but one has a single right answer. `ckeditor` is the exception, and the answer is
* the operator's — which is the whole of what `htmlConversion` decides.
*/
function targetEditorFor(sourceEditor: string, convertHtml: boolean): string {
if (sourceEditor === V2_VISUAL_EDITOR) {
return convertHtml ? 'visual' : 'markdown'
}
return EDITOR_MAP[sourceEditor] ?? 'markdown'
}
/** 2.x page-rule match kinds that 3.x also has. `SUBTREE` and `TAGALL` are 3.x additions. */
const RULE_MATCHES = new Set<GroupRuleMatch>(['START', 'END', 'REGEX', 'TAG', 'EXACT'])
@ -231,6 +297,7 @@ export interface ImportSession {
sites: ImportSessionSite[]
includes: ImportContentKind[]
overwrite: boolean
htmlConversion: 'markdown' | 'html'
state: 'open' | 'finished' | 'failed'
progress: Record<string, number>
warnings: string[]
@ -563,6 +630,7 @@ class Import {
sites,
includes,
overwrite,
htmlConversion,
actorId
}: {
source: string
@ -570,6 +638,7 @@ class Import {
sites: ImportSessionSite[]
includes: string[]
overwrite: boolean
htmlConversion?: string
actorId: string | null
}): Promise<ImportSession> {
if (source !== SUPPORTED_SOURCE) {
@ -621,7 +690,17 @@ class Import {
const rows = await WIKI.db
.insert(importSessionsTable)
.values({ source, namespace, sites, includes: kinds, overwrite, actorId })
.values({
source,
namespace,
sites,
includes: kinds,
overwrite,
// -> Anything but the explicit opt-out converts, which is the recommended answer and the one
// that leaves a wiki its authors can still edit
htmlConversion: htmlConversion === 'html' ? 'html' : 'markdown',
actorId
})
.returning()
return rows[0] as unknown as ImportSession
}
@ -1634,6 +1713,9 @@ class Import {
let imported = 0
let skipped = 0
let icons = 0
/** Source editor → how many pages of it changed editor on the way in. */
const converted = new Map<string, number>()
const convertHtml = session.htmlConversion !== 'html'
for (const record of records) {
const pagePath = stringOf(record?.path)
@ -1663,12 +1745,22 @@ class Import {
const { icon, content: body } = extractPageIcon(content, pagePath)
const sourceEditor = stringOf(record?.editorKey, 'markdown')
const editor = EDITOR_MAP[sourceEditor] ?? 'markdown'
if (!EDITOR_MAP[sourceEditor]) {
warnings.push(
`"${pagePath}" was written with the 2.x "${sourceEditor}" editor, which 3.x does not have. It was imported as markdown.`
)
const editor = targetEditorFor(sourceEditor, convertHtml)
/*
Counted per kind rather than reported per page, and counted at all — a page that changed
editor used to say nothing whatsoever, because the only warning here fired for an editor
missing from the map entirely and `ckeditor` is in it. So the whole of a 2.x wiki written in
the WYSIWYG editor arrived as markdown pages without a word about it.
*/
if (editor !== sourceEditor) {
converted.set(sourceEditor, (converted.get(sourceEditor) ?? 0) + 1)
}
/*
Converted AFTER the page icon has been taken out, and the order is load-bearing: 2.x marks a
corner image with a CSS class, and a class is exactly what does not survive the trip to
markdown. Extracting first means the icon is found on the HTML that still carries it.
*/
const source = editor === 'visual' ? htmlToMarkdown(body) : body
try {
const page = await WIKI.models.pages.adoptStoredPage({
@ -1682,7 +1774,7 @@ class Import {
stringOf(tag)
),
isPublished: record?.isPublished !== false,
content: editor === 'redirect' ? this.#redirectContent(body) : body,
content: editor === 'redirect' ? this.#redirectContent(source) : source,
createdAt: dateOf(record?.createdAt),
updatedAt: dateOf(record?.updatedAt),
authorId: await this.#authorFor(session, record?.authorId),
@ -1717,6 +1809,12 @@ class Import {
skipped++
}
}
for (const [sourceEditor, count] of converted) {
const note =
EDITOR_CHANGE_NOTES[sourceEditor]?.[targetEditorFor(sourceEditor, convertHtml)] ??
'3.x does not have that editor, so they were imported as markdown.'
warnings.push(`${count} pages were written with the 2.x "${sourceEditor}" editor. ${note}`)
}
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.`
@ -1897,7 +1995,15 @@ class Import {
}
// -> 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 { content: stripped } = extractPageIcon(content, pagePath)
/*
And converted the same way the page itself was. A version is restored by writing it back over
the page, so a history holding HTML under a page that is now markdown would turn a rollback
into a second, silent conversion in the opposite direction.
*/
const sourceEditor = stringOf(record?.editorKey, 'markdown')
const versionEditor = targetEditorFor(sourceEditor, session.htmlConversion !== 'html')
const body = versionEditor === 'visual' ? htmlToMarkdown(stripped) : stripped
const id = this.#derive(session, target.sourceId, 'pageHistory', record?.id)
const values = {
@ -1910,7 +2016,7 @@ class Import {
content: body,
meta: {
description: stringOf(record?.description),
editor: EDITOR_MAP[stringOf(record?.editorKey, 'markdown')] ?? 'markdown',
editor: versionEditor,
publishState: record?.isPublished === false ? 'draft' : 'published',
tags: Array.isArray(record?.tags) ? record.tags : []
},
@ -2228,7 +2334,7 @@ class Import {
.groupBy(pagesTable.editor)
: []
// -> `RENDERABLE_EDITORS` in `models/rendering.ts`, which is what the queue itself accepts
const renderable = new Set(['markdown', 'asciidoc'])
const renderable = new Set(['markdown', 'asciidoc', 'visual'])
let pendingRenders = 0
let unrenderable = 0
for (const row of pending) {

@ -43,7 +43,17 @@ import type { IconifyIconCustomisations } from '@iconify/utils'
* will accept, and the two have to agree. An editor missing from it is refused before anything is
* queued rather than after a browser has been started for it.
*/
const RENDERABLE_EDITORS = new Set(['markdown', 'asciidoc'])
const RENDERABLE_EDITORS = new Set(['markdown', 'asciidoc', 'visual'])
/**
* Editors whose pages are markdown under another name, and whose CONFIG therefore lives elsewhere.
*
* The visual editor writes markdown — `contentType: markdown` — and previews it through the very
* same renderer, with `editors.markdown`'s config rather than its own empty one (see
* `EditorVisual.vue`). A server-side render has to read the same config or it produces a page that
* differs from what the author was looking at, over settings like `allowHTML` and `linkify`.
*/
const EDITOR_CONFIG_ALIAS: Record<string, string> = { visual: 'markdown' }
/** How long the renderer bundle gets to load itself in the headless browser, in milliseconds. */
const RENDER_READY_TIMEOUT = 30000
@ -1102,9 +1112,10 @@ class Rendering {
)
continue
}
const configKey = EDITOR_CONFIG_ALIAS[page.editor] ?? page.editor
const html = await renderer.render(
page.content ?? '',
WIKI.sites[entry.siteId]?.config?.editors?.[page.editor]?.config ?? {},
WIKI.sites[entry.siteId]?.config?.editors?.[configKey]?.config ?? {},
{ pagePath: page.path, editor: page.editor }
)
await WIKI.models.pages.storeRender(entry.siteId, page.id, html, {

@ -27,6 +27,7 @@
"@google-cloud/storage": "8.0.1",
"@gquittet/graceful-server": "6.0.10",
"@iconify/utils": "3.1.4",
"@joplin/turndown-plugin-gfm": "1.0.68",
"@node-saml/node-saml": "5.1.0",
"@prometheus-io/client": "0.16.1",
"@simplewebauthn/server": "13.3.2",
@ -58,6 +59,7 @@
"semver": "7.8.5",
"simple-git": "3.36.0",
"ssh2-sftp-client": "12.1.1",
"turndown": "7.2.4",
"uuid": "14.0.1",
"y-protocols": "1.0.7",
"yjs": "13.6.32"
@ -72,6 +74,7 @@
"@types/sanitize-html": "2.16.1",
"@types/semver": "7.8.0",
"@types/ssh2-sftp-client": "9.0.6",
"@types/turndown": "5.0.6",
"@types/ws": "8.18.1",
"drizzle-kit": "1.0.0-rc.4",
"nodemon": "3.1.14",
@ -2409,6 +2412,12 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@joplin/turndown-plugin-gfm": {
"version": "1.0.68",
"resolved": "https://registry.npmjs.org/@joplin/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.68.tgz",
"integrity": "sha512-m8DfAQNC/V7g0j5H6Jv60WhekBBjodD+zTPGrU+g2m+ux/z8ZX07KQIl6kaAmbKwfvsQhC04op52g63I78sVgg==",
"license": "MIT"
},
"node_modules/@js-temporal/polyfill": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz",
@ -2452,6 +2461,12 @@
"node": ">=8"
}
},
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause"
},
"node_modules/@nodable/entities": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
@ -3636,6 +3651,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/turndown": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
"integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@ -7933,6 +7955,19 @@
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/turndown": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
"integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
"license": "MIT",
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
},
"engines": {
"node": ">=18",
"npm": ">=9"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",

@ -53,6 +53,7 @@
"@google-cloud/storage": "8.0.1",
"@gquittet/graceful-server": "6.0.10",
"@iconify/utils": "3.1.4",
"@joplin/turndown-plugin-gfm": "1.0.68",
"@node-saml/node-saml": "5.1.0",
"@prometheus-io/client": "0.16.1",
"@simplewebauthn/server": "13.3.2",
@ -84,6 +85,7 @@
"semver": "7.8.5",
"simple-git": "3.36.0",
"ssh2-sftp-client": "12.1.1",
"turndown": "7.2.4",
"uuid": "14.0.1",
"y-protocols": "1.0.7",
"yjs": "13.6.32"
@ -101,6 +103,7 @@
"@types/sanitize-html": "2.16.1",
"@types/semver": "7.8.0",
"@types/ssh2-sftp-client": "9.0.6",
"@types/turndown": "5.0.6",
"@types/ws": "8.18.1",
"drizzle-kit": "1.0.0-rc.4",
"nodemon": "3.1.14",

@ -0,0 +1,18 @@
/**
* `@joplin/turndown-plugin-gfm` ships no types of its own.
*
* The Joplin fork rather than the original `turndown-plugin-gfm`, which has not been touched in
* years: same rules, kept up with Turndown and with the table shapes a real WYSIWYG page produces —
* merged cells, a table with no header row, nested markup inside a cell.
*
* Only `gfm` is used, the bundle of every rule, because a page converted from 2.x needs all of them
* — tables above all, since plain Turndown has no rule for one and would flatten it to loose text.
*/
declare module '@joplin/turndown-plugin-gfm' {
import type TurndownService from 'turndown'
export const gfm: TurndownService.Plugin
export const tables: TurndownService.Plugin
export const strikethrough: TurndownService.Plugin
export const taskListItems: TurndownService.Plugin
export const highlightedCodeBlock: TurndownService.Plugin
}

@ -739,10 +739,38 @@ Rows of 2.x's `pages` minus `render` and `toc`, with `tags` flattened to a list
([§8](#8-the-bottleneck-that-shapes-the-schedule)). `contentBlob` is a `blobs/<sha256>` digest and
replaces `content` (set to `null`) when the source is over 1 MiB.
`editorKey` maps `markdown → markdown`, `asciidoc → asciidoc`, `redirect → redirect`, and 2.x's two
HTML editors (`ckeditor`, `code`) onto **markdown** rather than 3.x's `visual`: markdown is configured
with `allowHTML`, so a body of HTML renders as it stood, where `visual` is a WYSIWYG over markdown and
would be handed a document it does not parse. Every page that takes that road is named in the log.
`editorKey` maps `markdown → markdown`, `asciidoc → asciidoc` and `redirect → redirect`. 2.x's two
HTML editors both lose their counterpart, since 3.x has no editor over HTML at all — but only one of
them raises a question.
**`ckeditor`, 2.x's WYSIWYG editor**, is the operator's choice (`htmlConversion` on the session,
**Options → Visual Editor Conversion** in the overlay). Those pages were WRITTEN as formatted text and
merely stored as HTML, so converting gives their author back the editor they had:
- **Convert to markdown**, the default. The HTML is converted with Turndown plus the GitHub-flavoured
rules — the Joplin fork, which is maintained where the original is not, and not optional: a WYSIWYG
page is mostly tables and plain Turndown has no rule for one, so it would flatten a table to loose
text. The page is filed under 3.x's `visual` editor, so it opens the way it was written in 2.x.
- **Keep as raw HTML.** The body is stored untouched and filed under `markdown`, which is configured
with `allowHTML` and so renders it exactly as it stood — but the page is only editable as source.
**`code`, 2.x's raw-HTML editor, is never converted**, whatever that option says. Its author chose to
write HTML: the markup IS the document rather than a representation of one, and converting it would
throw away the thing they were editing — the `<div>` wrappers, the classes, the inline styles. Such a
page becomes a `markdown` page holding that HTML, which renders identically and is still edited as
source, exactly as it was in 2.x.
Either way the count is reported per editor kind. Two things follow from converting, and both are
the price of markdown having no syntax for them: a `<figure>`/`<figcaption>` becomes an image
followed by a paragraph, and inline styling markdown cannot express is dropped. What it does NOT lose
is text that looks like markup — `5 * 3 * 2` and `my_file_name.txt` come back escaped, so they render
as themselves.
**The conversion runs after the page icon is extracted**, because 2.x marks a corner image with a CSS
class and a class is precisely what does not survive the trip to markdown.
Page history is converted the same way, so restoring an old version does not quietly convert a page
back in the opposite direction.
A 2.x redirection's `content` is the target path; 3.x holds a redirection as JSON, so it is rewritten.

@ -21,7 +21,7 @@
color="white"
:aria-label="t(`common.actions.viewDocs`)"
icon="la:question-circle"
:href="siteStore.docsBase + `/admin/utilities`"
:href="siteStore.docsBase + `/setup/upgrade#upgrade-from-2x`"
target="_blank"
type="a" />
<w-btn-group push>
@ -152,6 +152,29 @@
:aria-label="t('admin.utilities.wikijs2Import.overwrite')" />
</w-item-section>
</w-item>
<w-item>
<blueprint-icon icon="markdown" />
<w-item-section>
<w-item-label>{{
t('admin.utilities.wikijs2Import.htmlConversion')
}}</w-item-label>
<w-item-label caption>{{
t('admin.utilities.wikijs2Import.htmlConversionHint')
}}</w-item-label>
</w-item-section>
<w-item-section side>
<w-select
outlined
dense
emit-value
map-options
style="min-width: 220px"
v-model="state.htmlConversion"
:options="htmlConversionOptions"
:disabled="isRunning"
:aria-label="t('admin.utilities.wikijs2Import.htmlConversion')" />
</w-item-section>
</w-item>
</w-card>
<w-card class="pb-2">
@ -298,6 +321,12 @@ const state = reactive({
settings: true
},
overwrite: false,
/**
* What to do with a page 2.x wrote as HTML — its WYSIWYG and code editors, which 3.x has no
* equivalent of. Converting is the default because it is the answer that leaves the wiki editable;
* see the option's own hint.
*/
htmlConversion: 'markdown',
/** The `File` the picker handed back, kept whole so its name and size can be shown. */
archive: null,
/** `{ ts, level, message }`, appended as the import reports its progress. */
@ -332,6 +361,11 @@ const siteOptions = computed(() =>
adminStore.sites.map((site) => ({ value: site.id, label: site.title }))
)
const htmlConversionOptions = computed(() => [
{ value: 'markdown', label: t('admin.utilities.wikijs2Import.htmlConversionMarkdown') },
{ value: 'html', label: t('admin.utilities.wikijs2Import.htmlConversionHtml') }
])
/**
* The rows under *What to import*, with the two that depend on `pages` marked as such. Built here
* rather than written out in the template so that the indent, the disabled state and the cascade
@ -466,6 +500,7 @@ async function startImport() {
siteId: state.siteId,
includes: selectedContent.value,
overwrite: state.overwrite,
htmlConversion: state.htmlConversion,
log: addLog,
onProgress: (value) => {
state.progress = value

@ -57,19 +57,29 @@ const SITE_STREAMS = [
* @param siteId The site on this instance everything site-scoped lands in
* @param includes The content kinds ticked in the overlay
* @param overwrite Whether an existing record is replaced
* @param htmlConversion `markdown` to convert a 2.x HTML page and keep it visually editable, `html`
* to keep the HTML as it stands
* @param log `(level, message)` — `info` / `success` / `warn` / `error`
* @param onProgress `(fraction)` from 0 to 1
*/
export async function runImport({ file, siteId, includes, overwrite, log, onProgress }) {
export async function runImport({
file,
siteId,
includes,
overwrite,
htmlConversion,
log,
onProgress
}) {
const pkg = await openPackage(file)
try {
return await drive({ pkg, siteId, includes, overwrite, log, onProgress })
return await drive({ pkg, siteId, includes, overwrite, htmlConversion, log, onProgress })
} finally {
await pkg.close()
}
}
async function drive({ pkg, siteId, includes, overwrite, log, onProgress }) {
async function drive({ pkg, siteId, includes, overwrite, htmlConversion, log, onProgress }) {
const { manifest } = pkg
log(
'info',
@ -107,7 +117,8 @@ async function drive({ pkg, siteId, includes, overwrite, log, onProgress }) {
sourceInstanceId: manifest.source.instanceId ?? '',
sites: [{ sourceId, siteId }],
includes,
overwrite
overwrite,
htmlConversion
}
}).json()
log('info', `Import session opened.`)

Loading…
Cancel
Save