Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/wp-plugin-import-completeness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"emdash": minor
"@emdash-cms/admin": minor
---

Improves WordPress plugin imports: taxonomy terms and assignments (custom post type taxonomies are created as EmDash taxonomies automatically, scoped to the collections they map to), Yoast/Rank Math SEO titles and descriptions, ACF and custom meta fields (suggested as collection fields during analysis and populated on import), navigation menus, and comments (with authors, dates, threading, and approval status) are now imported. Site identity — title, tagline, logo, and favicon — is taken over from the WordPress site, replacing the starter template's placeholders. Internal links in imported content are rewritten to root-relative URLs so they stay on the new site instead of pointing back to the old WordPress domain. Fetches the full media library instead of the first 500 files, supports sites with plain permalinks via a `?rest_route=` fallback, and the import screen accepts a migration key generated by the EmDash Exporter plugin wizard.
150 changes: 148 additions & 2 deletions packages/admin/src/components/WordPressImport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,40 @@ import { CaretNext } from "./ArrowIcons.js";
const TRAILING_SLASH_REGEX = /\/$/;
const WHITESPACE_REGEX = /\s/g;

/** Prefix of migration keys generated by the EmDash Exporter plugin wizard */
const MIGRATION_KEY_PREFIX = "em1.";

/**
* Decode a migration key from the EmDash Exporter plugin
* (`em1.` + base64url of `{ v, url, user, pass }`).
* Returns null when the string isn't a valid key.
*/
function decodeMigrationKey(key: string): { url: string; user: string; pass: string } | null {
if (!key.startsWith(MIGRATION_KEY_PREFIX)) return null;
try {
// atob's forgiving-base64 accepts unpadded input, but restore the
// padding anyway so strict polyfills don't reject valid keys.
const base64url = key.slice(MIGRATION_KEY_PREFIX.length).replace(/-/g, "+").replace(/_/g, "/");
const base64 = base64url.padEnd(base64url.length + ((4 - (base64url.length % 4)) % 4), "=");
const parsed: unknown = JSON.parse(atob(base64));
Comment on lines +77 to +84

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] decodeMigrationKey converts base64url characters back to base64 but does not restore padding. atob requires the input length to be a multiple of four, so a standard unpadded base64url payload (e.g. a realistic JSON migration key) will throw and the UI will reject a valid key.

Suggested change
function decodeMigrationKey(key: string): { url: string; user: string; pass: string } | null {
if (!key.startsWith(MIGRATION_KEY_PREFIX)) return null;
try {
const base64 = key.slice(MIGRATION_KEY_PREFIX.length).replace(/-/g, "+").replace(/_/g, "/");
const parsed: unknown = JSON.parse(atob(base64));
function decodeMigrationKey(key: string): { url: string; user: string; pass: string } | null {
if (!key.startsWith(MIGRATION_KEY_PREFIX)) return null;
try {
let base64 = key
.slice(MIGRATION_KEY_PREFIX.length)
.replace(/-/g, "+")
.replace(/_/g, "/");
base64 = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
const parsed: unknown = JSON.parse(atob(base64));
// ...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Padding restored in 5464331. One factual note for the record: atob implements WHATWG forgiving-base64, which accepts unpadded input — only a remainder-of-1 length fails, and that's impossible for valid base64. That's why every live-tested key (all generated unpadded by the plugin's rtrim(base64_encode(...), '=')) decoded fine. Added the padEnd anyway so strict polyfills or non-browser environments can't trip on it — it costs one line.

if (
typeof parsed === "object" &&
parsed !== null &&
"url" in parsed &&
typeof parsed.url === "string" &&
"user" in parsed &&
typeof parsed.user === "string" &&
"pass" in parsed &&
typeof parsed.pass === "string"
) {
return { url: parsed.url, user: parsed.user, pass: parsed.pass };
}
} catch {
// malformed key -- treated as "not a key" by the caller
}
return null;
}

// ============================================================================
// Types
// ============================================================================
Expand Down Expand Up @@ -390,9 +424,29 @@ export function WordPressImport() {

const handleProbeUrl = (e: React.FormEvent) => {
e.preventDefault();
if (!urlInput.trim()) return;
const input = urlInput.trim();
if (!input) return;
// A pasted migration key skips probing entirely -- it carries the
// site URL and credentials.
if (handleMigrationKey(input)) return;
setStep("probing");
probeMutation.mutate(urlInput.trim());
probeMutation.mutate(input);
};

/**
* Connect using a migration key from the EmDash Exporter plugin wizard.
* Returns false when the string isn't a valid key.
*/
const handleMigrationKey = (key: string): boolean => {
const decoded = decodeMigrationKey(key.trim());
if (!decoded) return false;
const token = btoa(`${decoded.user}:${decoded.pass.replace(WHITESPACE_REGEX, "")}`);
setImportSource({ type: "wordpress-plugin", url: decoded.url, token });
setUrlInput(decoded.url);
setImportError(null);
setStep("analyzing-plugin");
wpPluginAnalyzeMutation.mutate({ url: decoded.url, token });
return true;
};

const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
Expand Down Expand Up @@ -691,6 +745,7 @@ export function WordPressImport() {
onProbeUrl={handleProbeUrl}
onFileSelect={handleFileSelect}
onDrop={handleDrop}
onMigrationKey={handleMigrationKey}
/>
)}

Expand Down Expand Up @@ -904,16 +959,72 @@ function ChooseStep({
onProbeUrl,
onFileSelect,
onDrop,
onMigrationKey,
}: {
urlInput: string;
onUrlChange: (url: string) => void;
onProbeUrl: (e: React.FormEvent) => void;
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
onDrop: (e: React.DragEvent) => void;
onMigrationKey: (key: string) => boolean;
}) {
const { t } = useLingui();
const [keyInput, setKeyInput] = React.useState("");
const [keyError, setKeyError] = React.useState(false);

const handleKeySubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!keyInput.trim()) return;
setKeyError(!onMigrationKey(keyInput));
};

return (
<div className="space-y-6">
{/* Migration key - fastest path, generated by the WP plugin wizard */}
<div className="rounded-lg border border-kumo-brand/40 bg-kumo-base p-6">
<div className="flex items-start gap-4">
<div className="p-3 rounded-full bg-kumo-brand/10">
<Sparkle className="h-6 w-6 text-kumo-brand" />
</div>
<div className="flex-1">
<h3 className="text-lg font-medium">{t`Paste your migration key`}</h3>
<p className="text-kumo-subtle mt-1">
{t`Install the EmDash Exporter plugin on your WordPress site and generate a key under Tools → EmDash Migration.`}
</p>
<form onSubmit={handleKeySubmit} className="mt-4 flex gap-2">
<Input
type="text"
placeholder="em1.…"
value={keyInput}
onChange={(e) => {
setKeyInput(e.target.value);
setKeyError(false);
}}
className="flex-1 font-mono"
aria-label={t`Migration key`}
/>
<Button type="submit" disabled={!keyInput.trim()}>
{t`Connect`}
</Button>
</form>
{keyError && (
<p className="mt-2 text-sm text-kumo-danger">
{t`This doesn't look like a valid migration key. Generate a new one in the plugin and paste the full string starting with "em1.".`}
</p>
)}
</div>
</div>
</div>

<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-kumo-base px-2 text-kumo-subtle">{t`or connect by URL`}</span>
</div>
</div>

{/* URL input - primary path */}
<div className="rounded-lg border bg-kumo-base p-6">
<div className="flex items-start gap-4">
Expand Down Expand Up @@ -2075,6 +2186,41 @@ function CompleteStep({
}),
);
}
if (result.taxonomiesCreated && result.taxonomiesCreated.length > 0) {
parts.push(
plural(result.taxonomiesCreated.length, {
one: "# taxonomy created",
other: "# taxonomies created",
}),
);
}
if (result.taxonomyAssignments && result.taxonomyAssignments > 0) {
parts.push(
plural(result.taxonomyAssignments, {
one: "# taxonomy assignment",
other: "# taxonomy assignments",
}),
);
}
if (result.menus && result.menus.created > 0) {
parts.push(
plural(result.menus.created, {
one: "# menu imported",
other: "# menus imported",
}),
);
}
if (result.comments && result.comments.imported > 0) {
parts.push(
plural(result.comments.imported, {
one: "# comment imported",
other: "# comments imported",
}),
);
}
if (result.siteSettings && result.siteSettings.length > 0) {
parts.push(t`site identity applied (title, tagline, logo)`);
}
if (hasContentErrors) {
parts.push(
plural(result.errors.length, { one: "# content error", other: "# content errors" }),
Expand Down
12 changes: 12 additions & 0 deletions packages/admin/src/lib/api/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,18 @@ export interface ImportResult {
skipped: number;
errors: Array<{ title: string; error: string }>;
byCollection: Record<string, number>;
/** Number of taxonomy term assignments written (plugin import) */
taxonomyAssignments?: number;
/** Source taxonomies skipped because no matching EmDash taxonomy def exists */
missingTaxonomies?: string[];
/** Custom taxonomy defs auto-created during the import (plugin import) */
taxonomiesCreated?: string[];
/** Navigation menu import summary (plugin import) */
menus?: { created: number; items: number };
/** Comment import summary (plugin import) */
comments?: { imported: number; skipped: number };
/** Site settings applied from the source (plugin import) */
siteSettings?: string[];
}

/**
Expand Down
Loading
Loading