diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..639751c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +emdash-exporter.zip diff --git a/plugins/emdash-exporter/assets/wizard.css b/plugins/emdash-exporter/assets/wizard.css new file mode 100644 index 0000000..1c90ee5 --- /dev/null +++ b/plugins/emdash-exporter/assets/wizard.css @@ -0,0 +1,189 @@ +.emdash-wizard { + max-width: 860px; +} + +.emdash-title { + margin-bottom: 20px; +} + +.emdash-title-sub { + color: #2271b1; + font-weight: 600; +} + +.emdash-step { + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 6px; + margin-bottom: 16px; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04); +} + +.emdash-step-header { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 16px 20px; + border-bottom: 1px solid #f0f0f1; +} + +.emdash-step-header h2 { + margin: 0 0 4px; + font-size: 16px; +} + +.emdash-step-header p { + margin: 0; + color: #646970; +} + +.emdash-step-badge { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 50%; + background: #f0f0f1; + color: #3c434a; + font-weight: 700; +} + +.emdash-step.is-active .emdash-step-badge { + background: #2271b1; + color: #fff; +} + +.emdash-step.is-done .emdash-step-badge { + background: #00a32a; + color: #fff; +} + +.emdash-step-status { + margin-inline-start: auto; + align-self: center; + color: #00a32a; + font-weight: 600; + white-space: nowrap; +} + +.emdash-step-body { + padding: 16px 20px 20px; +} + +.emdash-checks { + margin: 0 0 16px; +} + +.emdash-check { + display: flex; + gap: 10px; + padding: 10px 12px; + border-radius: 4px; + margin-bottom: 8px; + background: #f6f7f7; +} + +.emdash-check p { + margin: 2px 0 0; +} + +.emdash-check-icon { + flex-shrink: 0; + font-weight: 700; + width: 18px; + text-align: center; +} + +.emdash-check--pass .emdash-check-icon { color: #00a32a; } +.emdash-check--warn { background: #fcf9e8; } +.emdash-check--warn .emdash-check-icon { color: #dba617; } +.emdash-check--fail { background: #fcf0f1; } +.emdash-check--fail .emdash-check-icon { color: #d63638; } + +.emdash-check-fix { + color: #646970; +} + +.emdash-check-fix code { + user-select: all; +} + +.emdash-key-row { + display: flex; + gap: 8px; + margin-top: 16px; +} + +.emdash-key-input { + flex: 1; + font-family: Consolas, Monaco, monospace; + font-size: 13px; + padding: 6px 10px; +} + +#emdash-key-error { + margin-top: 12px; +} + +.emdash-columns { + display: flex; + gap: 24px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.emdash-column { + flex: 1; + min-width: 280px; +} + +.emdash-column h3 { + margin-top: 4px; +} + +.emdash-deploy-btn { + display: inline-flex !important; + align-items: center; + gap: 8px; + background: #f6821f !important; + border-color: #f6821f !important; +} + +.emdash-deploy-btn:hover, +.emdash-deploy-btn:focus { + background: #e07408 !important; + border-color: #e07408 !important; +} + +.emdash-cf-icon { + display: inline-block; + width: 18px; + height: 12px; + background: #fff; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 16'%3E%3Cpath d='M16.5 13.7l.4-1.4c.2-.8.1-1.5-.3-2-.4-.5-1-.8-1.8-.8l-9.7-.1c-.1 0-.1 0-.2-.1v-.2c0-.1.1-.1.2-.1l9.8-.1c1.2-.1 2.4-1 2.9-2.2l.6-1.5v-.3C17.7 2 15.2 0 12.2 0 9.4 0 7 1.8 6.2 4.3c-.5-.4-1.2-.6-2-.5C2.9 4 1.9 5 1.7 6.3c0 .3 0 .7.1 1C-.3 7.4 0 9.2 0 10.9c0 .1.1.2.2.2h16.1c.1 0 .2 0 .2-.1v2.7z'/%3E%3C/svg%3E") center / contain no-repeat; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 16'%3E%3Cpath d='M16.5 13.7l.4-1.4c.2-.8.1-1.5-.3-2-.4-.5-1-.8-1.8-.8l-9.7-.1c-.1 0-.1 0-.2-.1v-.2c0-.1.1-.1.2-.1l9.8-.1c1.2-.1 2.4-1 2.9-2.2l.6-1.5v-.3C17.7 2 15.2 0 12.2 0 9.4 0 7 1.8 6.2 4.3c-.5-.4-1.2-.6-2-.5C2.9 4 1.9 5 1.7 6.3c0 .3 0 .7.1 1C-.3 7.4 0 9.2 0 10.9c0 .1.1.2.2.2h16.1c.1 0 .2 0 .2-.1v2.7z'/%3E%3C/svg%3E") center / contain no-repeat; +} + +.emdash-overview { + margin: 0; +} + +.emdash-overview li { + padding: 4px 0; + border-bottom: 1px solid #f0f0f1; +} + +.emdash-count { + color: #646970; + font-size: 12px; + background: #f0f0f1; + border-radius: 10px; + padding: 1px 8px; + margin-inline-start: 6px; +} + +.emdash-yes { color: #00a32a; } +.emdash-no { color: #d63638; } +.emdash-overview--excluded li { color: #646970; } diff --git a/plugins/emdash-exporter/assets/wizard.js b/plugins/emdash-exporter/assets/wizard.js new file mode 100644 index 0000000..7196a45 --- /dev/null +++ b/plugins/emdash-exporter/assets/wizard.js @@ -0,0 +1,96 @@ +/** + * EmDash migration wizard: key generation, copy, revoke. + * Vanilla JS, no dependencies. + */ +(function () { + "use strict"; + + var cfg = window.emdashWizard || {}; + var generateBtn = document.getElementById("emdash-generate-key"); + var revokeBtn = document.getElementById("emdash-revoke-key"); + var result = document.getElementById("emdash-key-result"); + var keyInput = document.getElementById("emdash-key-value"); + var copyBtn = document.getElementById("emdash-copy-key"); + var errorBox = document.getElementById("emdash-key-error"); + + function post(action) { + var body = new URLSearchParams(); + body.set("action", action); + body.set("nonce", cfg.nonce); + return fetch(cfg.ajaxUrl, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }).then(function (response) { + return response.json(); + }); + } + + function showError(message) { + if (!errorBox) return; + errorBox.querySelector("p").textContent = message || cfg.i18n.error; + errorBox.hidden = false; + } + + if (generateBtn) { + generateBtn.addEventListener("click", function () { + generateBtn.disabled = true; + var originalText = generateBtn.textContent; + generateBtn.textContent = cfg.i18n.generating; + errorBox.hidden = true; + + post("emdash_generate_key") + .then(function (data) { + if (data && data.success && data.data && data.data.key) { + keyInput.value = data.data.key; + result.hidden = false; + keyInput.focus(); + keyInput.select(); + } else { + showError(data && data.data && data.data.message); + } + }) + .catch(function () { + showError(); + }) + .finally(function () { + generateBtn.disabled = false; + generateBtn.textContent = originalText; + }); + }); + } + + if (copyBtn) { + copyBtn.addEventListener("click", function () { + keyInput.select(); + var done = function () { + copyBtn.textContent = cfg.i18n.copied; + setTimeout(function () { + copyBtn.textContent = cfg.i18n.copy; + }, 2000); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(keyInput.value).then(done); + } else { + document.execCommand("copy"); + done(); + } + }); + } + + if (revokeBtn) { + revokeBtn.addEventListener("click", function () { + if (!window.confirm(cfg.i18n.revokeConfirm)) return; + revokeBtn.disabled = true; + post("emdash_revoke_key") + .then(function () { + window.location.reload(); + }) + .catch(function () { + revokeBtn.disabled = false; + showError(); + }); + }); + } +})(); diff --git a/plugins/emdash-exporter/emdash-exporter.php b/plugins/emdash-exporter/emdash-exporter.php index c76f30b..6cb0bf4 100644 --- a/plugins/emdash-exporter/emdash-exporter.php +++ b/plugins/emdash-exporter/emdash-exporter.php @@ -2,68 +2,46 @@ /** * Plugin Name: EmDash Exporter * Plugin URI: https://github.com/emdash-cms/wp-emdash - * Description: Export your WordPress content to EmDash CMS with one click - * Version: 1.0.0 + * Description: Migrate your WordPress content to EmDash CMS with a guided wizard and one-click migration key + * Version: 1.2.0 * Requires at least: 5.6 * Requires PHP: 7.4 - * Author: Matt Kane + * Author: EmDash * License: GPL3 * Text Domain: emdash-exporter */ defined('ABSPATH') || exit; -define('EMDASH_EXPORTER_VERSION', '1.0.0'); +define('EMDASH_EXPORTER_VERSION', '1.2.0'); define('EMDASH_EXPORTER_PATH', plugin_dir_path(__FILE__)); -// Load REST API endpoints require_once EMDASH_EXPORTER_PATH . 'includes/class-rest-controller.php'; require_once EMDASH_EXPORTER_PATH . 'includes/class-content-exporter.php'; require_once EMDASH_EXPORTER_PATH . 'includes/class-media-exporter.php'; +require_once EMDASH_EXPORTER_PATH . 'includes/class-menu-exporter.php'; +require_once EMDASH_EXPORTER_PATH . 'includes/class-comment-exporter.php'; +require_once EMDASH_EXPORTER_PATH . 'includes/class-i18n-exporter.php'; +require_once EMDASH_EXPORTER_PATH . 'includes/class-health-check.php'; +require_once EMDASH_EXPORTER_PATH . 'includes/class-migration-key.php'; +require_once EMDASH_EXPORTER_PATH . 'includes/class-admin-page.php'; /** - * Initialize the plugin + * Register REST API routes */ function emdash_exporter_init() { - // Register REST API routes $controller = new EmDash_Exporter_REST_Controller(); $controller->register_routes(); } add_action('rest_api_init', 'emdash_exporter_init'); -/** - * Add admin notice with connection info - */ -function emdash_exporter_admin_notice() { - $screen = get_current_screen(); - if ($screen->id !== 'plugins') { - return; - } - - $site_url = get_site_url(); - $api_url = rest_url('emdash/v1/'); - - ?> -
- EmDash Exporter:
- Your site is ready for export. Connect from EmDash using:
-
-
- API endpoint:
-
%s %s %s
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]', 'emdash-exporter'),
+ ];
+ }
+
+ private function check_reachability() {
+ $host = wp_parse_url(get_site_url(), PHP_URL_HOST);
+ $host = is_string($host) ? strtolower($host) : '';
+
+ $is_local = $host === 'localhost'
+ || substr($host, -6) === '.local'
+ || substr($host, -5) === '.test'
+ || (filter_var($host, FILTER_VALIDATE_IP) && !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE));
+
+ if ($is_local) {
+ return [
+ 'id' => 'reachability',
+ 'label' => __('Publicly reachable', 'emdash-exporter'),
+ 'status' => 'warn',
+ 'message' => sprintf(
+ /* translators: %s: host name */
+ __('This site (%s) is only reachable on your local machine. A cloud-hosted EmDash site cannot connect to it.', 'emdash-exporter'),
+ esc_html($host)
+ ),
+ 'fix' => __('Run EmDash locally on the same machine (npx emdash dev), or use a tunnel (e.g. cloudflared) to expose this site temporarily.', 'emdash-exporter'),
+ ];
+ }
+
+ return [
+ 'id' => 'reachability',
+ 'label' => __('Publicly reachable', 'emdash-exporter'),
+ 'status' => 'pass',
+ 'message' => __('The site URL looks publicly reachable.', 'emdash-exporter'),
+ ];
+ }
+}
diff --git a/plugins/emdash-exporter/includes/class-i18n-exporter.php b/plugins/emdash-exporter/includes/class-i18n-exporter.php
new file mode 100644
index 0000000..e361716
--- /dev/null
+++ b/plugins/emdash-exporter/includes/class-i18n-exporter.php
@@ -0,0 +1,142 @@
+ 'wpml',
+ 'default_locale' => self::to_bcp47($default_locale ?: get_locale()),
+ 'locales' => $locales,
+ ];
+ }
+
+ // Polylang
+ $locales = [];
+ $slugs = pll_languages_list(['fields' => 'slug']);
+ foreach ((array) $slugs as $slug) {
+ $locales[] = self::polylang_locale($slug);
+ }
+ $default_slug = function_exists('pll_default_language') ? pll_default_language('slug') : null;
+
+ return [
+ 'plugin' => 'polylang',
+ 'default_locale' => $default_slug ? self::polylang_locale($default_slug) : self::to_bcp47(get_locale()),
+ 'locales' => $locales,
+ ];
+ }
+
+ /**
+ * Per-post i18n fields, or [] when not applicable.
+ *
+ * @param int $post_id
+ * @return array May contain 'locale' and 'translation_group'.
+ */
+ public static function post_info($post_id) {
+ $plugin = self::active_plugin();
+ if ($plugin === null) {
+ return [];
+ }
+
+ if ($plugin === 'wpml') {
+ $data = [];
+ $details = apply_filters('wpml_post_language_details', null, $post_id);
+ if (is_array($details) && !empty($details['locale'])) {
+ $data['locale'] = self::to_bcp47($details['locale']);
+ }
+ $post_type = get_post_type($post_id);
+ $trid = apply_filters('wpml_element_trid', null, $post_id, 'post_' . $post_type);
+ if ($trid) {
+ $data['translation_group'] = 'wpml-' . $trid;
+ }
+ return $data;
+ }
+
+ // Polylang
+ $data = [];
+ if (function_exists('pll_get_post_language')) {
+ $slug = pll_get_post_language($post_id, 'slug');
+ if ($slug) {
+ $data['locale'] = self::polylang_locale($slug);
+ }
+ }
+ if (function_exists('pll_get_post_translations')) {
+ $translations = pll_get_post_translations($post_id);
+ if (is_array($translations) && count($translations) > 0) {
+ // ponytail: smallest post ID in the group is a stable group key
+ // without needing Polylang's internal term IDs.
+ $data['translation_group'] = 'pll-' . min(array_map('intval', $translations));
+ }
+ }
+ return $data;
+ }
+
+ /**
+ * Full BCP 47 locale for a Polylang language slug (e.g. 'de' -> 'de-DE').
+ */
+ private static function polylang_locale($slug) {
+ if (function_exists('PLL')) {
+ $language = PLL()->model->get_language($slug);
+ if ($language && !empty($language->locale)) {
+ return self::to_bcp47($language->locale);
+ }
+ }
+ return self::to_bcp47($slug);
+ }
+
+ /**
+ * WordPress locale ('de_DE') to BCP 47 ('de-DE').
+ */
+ private static function to_bcp47($locale) {
+ return str_replace('_', '-', (string) $locale);
+ }
+}
diff --git a/plugins/emdash-exporter/includes/class-media-exporter.php b/plugins/emdash-exporter/includes/class-media-exporter.php
index 736d228..ba7175c 100644
--- a/plugins/emdash-exporter/includes/class-media-exporter.php
+++ b/plugins/emdash-exporter/includes/class-media-exporter.php
@@ -67,7 +67,6 @@ private function format_attachment($attachment, $include_data = false) {
'id' => $attachment->ID,
'url' => $url,
'filename' => basename($url),
- 'file_path' => $file_path,
'mime_type' => $attachment->post_mime_type,
'title' => $attachment->post_title,
'alt' => get_post_meta($attachment->ID, '_wp_attachment_image_alt', true),
@@ -152,9 +151,10 @@ private function get_attachment_custom_meta($attachment_id) {
// Single values
$custom[$key] = count($values) === 1 ? $values[0] : $values;
- // Try to unserialize
- if (is_string($custom[$key]) && $this->is_serialized($custom[$key])) {
- $unserialized = @unserialize($custom[$key]);
+ // Decode PHP-serialized data. allowed_classes=false prevents
+ // object injection from untrusted database values.
+ if (is_string($custom[$key]) && is_serialized($custom[$key])) {
+ $unserialized = @unserialize($custom[$key], ['allowed_classes' => false]);
if ($unserialized !== false) {
$custom[$key] = $unserialized;
}
@@ -163,15 +163,4 @@ private function get_attachment_custom_meta($attachment_id) {
return $custom;
}
-
- /**
- * Check if a string is PHP serialized
- */
- private function is_serialized($data) {
- if (!is_string($data)) return false;
- $data = trim($data);
- if ($data === 'N;') return true;
- if (preg_match('/^([aOsbi]):/', $data)) return true;
- return false;
- }
}
diff --git a/plugins/emdash-exporter/includes/class-menu-exporter.php b/plugins/emdash-exporter/includes/class-menu-exporter.php
new file mode 100644
index 0000000..1cb55c0
--- /dev/null
+++ b/plugins/emdash-exporter/includes/class-menu-exporter.php
@@ -0,0 +1,77 @@
+term_id, ['update_post_term_cache' => false]);
+ if ($items === false) {
+ $items = [];
+ }
+
+ $result[] = [
+ 'id' => (int) $menu->term_id,
+ 'name' => $menu->slug,
+ 'label' => $menu->name,
+ 'locations' => $this->menu_locations($menu->term_id),
+ 'items' => array_map([$this, 'format_item'], $items),
+ ];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Theme locations this menu is assigned to (e.g. ['primary']).
+ */
+ private function menu_locations($menu_id) {
+ $locations = [];
+ foreach ((array) get_nav_menu_locations() as $location => $assigned_id) {
+ if ((int) $assigned_id === (int) $menu_id) {
+ $locations[] = $location;
+ }
+ }
+ return $locations;
+ }
+
+ /**
+ * Format a single nav menu item (WP_Post decorated by wp_setup_nav_menu_item).
+ */
+ private function format_item($item) {
+ $type = $item->type; // 'custom' | 'post_type' | 'taxonomy' | 'post_type_archive'
+ if (!in_array($type, ['custom', 'post_type', 'taxonomy'], true)) {
+ // ponytail: archives etc. degrade to a custom link; EmDash menus
+ // have no archive item type.
+ $type = 'custom';
+ }
+
+ return [
+ 'id' => (int) $item->ID,
+ 'parent_id' => $item->menu_item_parent ? (int) $item->menu_item_parent : null,
+ 'sort_order' => (int) $item->menu_order,
+ 'type' => $type,
+ 'object' => $item->object ?: null,
+ 'object_id' => $item->object_id ? (int) $item->object_id : null,
+ 'url' => (string) $item->url,
+ 'title' => (string) $item->title,
+ 'target' => $item->target ?: null,
+ 'classes' => is_array($item->classes) ? trim(implode(' ', array_filter($item->classes))) ?: null : null,
+ ];
+ }
+}
diff --git a/plugins/emdash-exporter/includes/class-migration-key.php b/plugins/emdash-exporter/includes/class-migration-key.php
new file mode 100644
index 0000000..626255b
--- /dev/null
+++ b/plugins/emdash-exporter/includes/class-migration-key.php
@@ -0,0 +1,115 @@
+exists()) {
+ return new WP_Error('not_logged_in', __('You must be logged in.', 'emdash-exporter'));
+ }
+
+ if (!wp_is_application_passwords_available_for_user($user)) {
+ return new WP_Error(
+ 'app_passwords_unavailable',
+ __('Application Passwords are not available for your user account.', 'emdash-exporter')
+ );
+ }
+
+ // Revoke a previous migration password so regenerating the key
+ // invalidates the old one instead of piling up credentials.
+ foreach (WP_Application_Passwords::get_user_application_passwords($user->ID) as $item) {
+ if ($item['name'] === self::APP_NAME) {
+ WP_Application_Passwords::delete_application_password($user->ID, $item['uuid']);
+ }
+ }
+
+ $created = WP_Application_Passwords::create_new_application_password($user->ID, [
+ 'name' => self::APP_NAME,
+ ]);
+
+ if (is_wp_error($created)) {
+ return $created;
+ }
+
+ list($password) = $created;
+
+ $payload = wp_json_encode([
+ 'v' => 1,
+ 'url' => get_site_url(),
+ 'user' => $user->user_login,
+ 'pass' => $password,
+ ]);
+
+ return [
+ 'key' => self::PREFIX . self::base64url_encode($payload),
+ 'user' => $user->user_login,
+ ];
+ }
+
+ /**
+ * Revoke the migration application password for the current user.
+ *
+ * @return bool Whether a password was revoked.
+ */
+ public static function revoke() {
+ $user = wp_get_current_user();
+ if (!$user || !$user->exists()) {
+ return false;
+ }
+
+ $revoked = false;
+ foreach (WP_Application_Passwords::get_user_application_passwords($user->ID) as $item) {
+ if ($item['name'] === self::APP_NAME) {
+ WP_Application_Passwords::delete_application_password($user->ID, $item['uuid']);
+ $revoked = true;
+ }
+ }
+ return $revoked;
+ }
+
+ /**
+ * Whether a migration password currently exists for the current user.
+ */
+ public static function exists() {
+ $user = wp_get_current_user();
+ if (!$user || !$user->exists()) {
+ return false;
+ }
+ foreach (WP_Application_Passwords::get_user_application_passwords($user->ID) as $item) {
+ if ($item['name'] === self::APP_NAME) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static function base64url_encode($data) {
+ return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
+ }
+}
diff --git a/plugins/emdash-exporter/includes/class-rest-controller.php b/plugins/emdash-exporter/includes/class-rest-controller.php
index 79f0577..18a001e 100644
--- a/plugins/emdash-exporter/includes/class-rest-controller.php
+++ b/plugins/emdash-exporter/includes/class-rest-controller.php
@@ -119,27 +119,102 @@ public function register_routes() {
'callback' => [$this, 'get_options'],
'permission_callback' => [$this, 'check_permission'],
]);
+
+ register_rest_route(self::NAMESPACE, '/menus', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'get_menus'],
+ 'permission_callback' => [$this, 'check_permission'],
+ ]);
+
+ register_rest_route(self::NAMESPACE, '/comments', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'get_comments'],
+ 'permission_callback' => [$this, 'check_permission'],
+ 'args' => [
+ 'per_page' => [
+ 'type' => 'integer',
+ 'default' => 500,
+ 'minimum' => 1,
+ 'maximum' => 500,
+ ],
+ 'page' => [
+ 'type' => 'integer',
+ 'default' => 1,
+ 'minimum' => 1,
+ ],
+ ],
+ ]);
+
+ // Public: reflects whether the Authorization header reaches PHP.
+ // Used by the wizard's loopback health check (some Apache/CGI setups
+ // strip the header, which silently breaks Application Passwords).
+ register_rest_route(self::NAMESPACE, '/header-check', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'header_check'],
+ 'permission_callback' => '__return_true',
+ ]);
}
/**
- * Check if user has permission to export
+ * Check if user has permission to export.
+ *
+ * Requires the `export` capability (Administrators and Editors). The
+ * export exposes all content including drafts, private posts, author
+ * emails, and site options -- `edit_posts` (Contributors) is not enough.
*/
public function check_permission() {
- // Allow if user is logged in and can export
if (current_user_can('export')) {
return true;
}
- // Check for Application Password authentication
- // WordPress 5.6+ supports this natively
- if (is_user_logged_in() && current_user_can('edit_posts')) {
- return true;
- }
-
return new WP_Error(
'rest_forbidden',
__('You must be authenticated with export permissions.', 'emdash-exporter'),
- ['status' => 401]
+ ['status' => rest_authorization_required_code()]
+ );
+ }
+
+ /**
+ * Header check endpoint - reports whether an Authorization header arrived
+ */
+ public function header_check() {
+ $header = null;
+ if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
+ $header = $_SERVER['HTTP_AUTHORIZATION'];
+ } elseif (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
+ $header = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
+ } elseif (function_exists('getallheaders')) {
+ // mod_php can expose the header via the Apache API only,
+ // without populating $_SERVER['HTTP_AUTHORIZATION'].
+ foreach (getallheaders() as $name => $value) {
+ if (strtolower($name) === 'authorization') {
+ $header = $value;
+ break;
+ }
+ }
+ }
+
+ return [
+ 'authorization_header_received' => !empty($header),
+ ];
+ }
+
+ /**
+ * Get all navigation menus
+ */
+ public function get_menus() {
+ $exporter = new EmDash_Menu_Exporter();
+ return $exporter->get_menus();
+ }
+
+ /**
+ * Get comments (approved + pending), paginated
+ */
+ public function get_comments($request) {
+ $exporter = new EmDash_Comment_Exporter();
+ return $exporter->get_comments(
+ $request->get_param('per_page'),
+ $request->get_param('page')
);
}
@@ -169,7 +244,9 @@ public function probe() {
$has_yoast = defined('WPSEO_VERSION');
$has_rankmath = class_exists('RankMath');
- return [
+ $i18n = EmDash_I18n_Exporter::site_info();
+
+ return array_merge($i18n ? ['i18n' => $i18n] : [], [
'emdash_exporter' => EMDASH_EXPORTER_VERSION,
'wordpress_version' => get_bloginfo('version'),
'site' => [
@@ -188,15 +265,18 @@ public function probe() {
],
'post_types' => $post_type_info,
'media_count' => (int) wp_count_posts('attachment')->inherit,
+ 'menu_count' => count(wp_get_nav_menus()),
'endpoints' => [
'analyze' => rest_url(self::NAMESPACE . '/analyze'),
'content' => rest_url(self::NAMESPACE . '/content'),
'media' => rest_url(self::NAMESPACE . '/media'),
'taxonomies' => rest_url(self::NAMESPACE . '/taxonomies'),
'options' => rest_url(self::NAMESPACE . '/options'),
+ 'menus' => rest_url(self::NAMESPACE . '/menus'),
+ 'comments' => rest_url(self::NAMESPACE . '/comments'),
],
'auth_instructions' => $this->get_auth_instructions(),
- ];
+ ]);
}
/**
@@ -276,7 +356,9 @@ public function get_taxonomies() {
$result[] = [
'name' => $taxonomy->name,
'label' => $taxonomy->label,
+ 'label_singular' => $taxonomy->labels->singular_name ?? $taxonomy->label,
'hierarchical' => $taxonomy->hierarchical,
+ 'post_types' => array_values((array) $taxonomy->object_type),
'terms' => $term_data,
];
}
@@ -313,6 +395,25 @@ public function get_options() {
$options[$key] = get_option($key);
}
+ // Site identity media (Customizer logo + site icon), with resolved
+ // URLs so the importer can side-load the files.
+ $custom_logo = (int) get_theme_mod('custom_logo');
+ if ($custom_logo) {
+ $logo_url = wp_get_attachment_url($custom_logo);
+ if ($logo_url) {
+ $options['custom_logo'] = $custom_logo;
+ $options['custom_logo_url'] = $logo_url;
+ }
+ }
+ $site_icon = (int) get_option('site_icon');
+ if ($site_icon) {
+ $icon_url = wp_get_attachment_url($site_icon);
+ if ($icon_url) {
+ $options['site_icon'] = $site_icon;
+ $options['site_icon_url'] = $icon_url;
+ }
+ }
+
// Include Yoast settings if available
if (defined('WPSEO_VERSION')) {
$yoast_keys = [
diff --git a/plugins/emdash-exporter/includes/views/wizard.php b/plugins/emdash-exporter/includes/views/wizard.php
new file mode 100644
index 0000000..eebf6e3
--- /dev/null
+++ b/plugins/emdash-exporter/includes/views/wizard.php
@@ -0,0 +1,193 @@
+
++ +
+