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: -

-
- register(); /** - * Add settings link on plugins page + * Send the user into the wizard right after activation. */ -function emdash_exporter_settings_link($links) { - $api_url = rest_url('emdash/v1/probe'); - $links[] = 'Test API'; - return $links; +function emdash_exporter_activate() { + set_transient('emdash_exporter_activated', 1, 60); } -add_filter('plugin_action_links_' . plugin_basename(__FILE__), 'emdash_exporter_settings_link'); +register_activation_hook(__FILE__, 'emdash_exporter_activate'); diff --git a/plugins/emdash-exporter/includes/class-admin-page.php b/plugins/emdash-exporter/includes/class-admin-page.php new file mode 100644 index 0000000..5c2cfb0 --- /dev/null +++ b/plugins/emdash-exporter/includes/class-admin-page.php @@ -0,0 +1,213 @@ + admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('emdash_wizard'), + 'i18n' => [ + 'copied' => __('Copied!', 'emdash-exporter'), + 'copy' => __('Copy key', 'emdash-exporter'), + 'generating' => __('Generating…', 'emdash-exporter'), + 'error' => __('Something went wrong. Please try again.', 'emdash-exporter'), + 'revokeConfirm' => __('Revoke the migration key? EmDash will no longer be able to connect until you generate a new one.', 'emdash-exporter'), + ], + ]); + } + + /** + * Plugins-page action link to the wizard (replaces the old raw-JSON "Test API" link). + */ + public function action_links($links) { + array_unshift($links, sprintf( + '%s', + esc_url(admin_url('tools.php?page=' . self::SLUG)), + esc_html__('Start migration', 'emdash-exporter') + )); + return $links; + } + + /** + * One-time redirect into the wizard right after activation. + */ + public function maybe_redirect_after_activation() { + if (!get_transient('emdash_exporter_activated')) { + return; + } + delete_transient('emdash_exporter_activated'); + if (wp_doing_ajax() || !current_user_can('export')) { + return; + } + wp_safe_redirect(admin_url('tools.php?page=' . self::SLUG)); + exit; + } + + /** + * Small pointer on the plugins screen only (replaces the old always-on notice). + */ + public function activation_pointer() { + $screen = get_current_screen(); + if (!$screen || $screen->id !== 'plugins' || !current_user_can('export')) { + return; + } + printf( + '

%s %s %s

', + esc_html__('EmDash Exporter:', 'emdash-exporter'), + esc_html__('Ready to move this site to EmDash?', 'emdash-exporter'), + esc_url(admin_url('tools.php?page=' . self::SLUG)), + esc_html__('Start the migration wizard', 'emdash-exporter') + ); + } + + public function ajax_generate_key() { + check_ajax_referer('emdash_wizard', 'nonce'); + if (!current_user_can('export')) { + wp_send_json_error(['message' => __('You need export permissions to generate a migration key.', 'emdash-exporter')], 403); + } + + $result = EmDash_Migration_Key::generate(); + if (is_wp_error($result)) { + wp_send_json_error(['message' => $result->get_error_message()]); + } + wp_send_json_success($result); + } + + public function ajax_revoke_key() { + check_ajax_referer('emdash_wizard', 'nonce'); + if (!current_user_can('export')) { + wp_send_json_error(['message' => __('You need export permissions.', 'emdash-exporter')], 403); + } + EmDash_Migration_Key::revoke(); + wp_send_json_success(); + } + + public function render() { + $health = new EmDash_Health_Check(); + $checks = $health->run(); + $checks_ok = EmDash_Health_Check::all_ok($checks); + $key_exists = EmDash_Migration_Key::exists(); + $overview = $this->content_overview(); + + include EMDASH_EXPORTER_PATH . 'includes/views/wizard.php'; + } + + /** + * Honest "what gets migrated" overview data. + */ + private function content_overview() { + $post_types = get_post_types(['public' => true], 'objects'); + $types = []; + foreach ($post_types as $type) { + if ($type->name === 'attachment') { + continue; + } + $counts = wp_count_posts($type->name); + $total = 0; + foreach (['publish', 'draft', 'pending', 'private', 'future'] as $status) { + $total += isset($counts->$status) ? (int) $counts->$status : 0; + } + if ($total > 0) { + $types[] = ['label' => $type->label, 'count' => $total]; + } + } + + $taxonomies = []; + foreach (get_taxonomies(['public' => true], 'objects') as $taxonomy) { + $count = wp_count_terms(['taxonomy' => $taxonomy->name, 'hide_empty' => false]); + if (!is_wp_error($count) && (int) $count > 0) { + $taxonomies[] = ['label' => $taxonomy->label, 'count' => (int) $count]; + } + } + + $non_public = array_diff( + get_post_types(['public' => false]), + // WP-internal types that nobody expects to migrate + ['revision', 'nav_menu_item', 'custom_css', 'customize_changeset', 'oembed_cache', 'user_request', 'wp_block', 'wp_template', 'wp_template_part', 'wp_global_styles', 'wp_navigation', 'wp_font_family', 'wp_font_face', 'attachment'] + ); + + $i18n = EmDash_I18n_Exporter::site_info(); + + return [ + 'types' => $types, + 'taxonomies' => $taxonomies, + 'media_count' => (int) wp_count_posts('attachment')->inherit, + 'menu_count' => count(wp_get_nav_menus()), + 'comment_count' => EmDash_Comment_Exporter::count(), + 'acf' => class_exists('ACF'), + 'yoast' => defined('WPSEO_VERSION'), + 'rankmath' => class_exists('RankMath'), + 'i18n' => $i18n, + 'non_public_types' => array_values($non_public), + 'page_builder' => $this->detect_page_builder(), + ]; + } + + /** + * Detect page builders whose layouts cannot be converted 1:1. + */ + private function detect_page_builder() { + $builders = []; + if (defined('ELEMENTOR_VERSION')) { + $builders[] = 'Elementor'; + } + if (defined('ET_BUILDER_VERSION') || function_exists('et_setup_theme')) { + $builders[] = 'Divi'; + } + if (defined('WPB_VC_VERSION')) { + $builders[] = 'WPBakery'; + } + if (class_exists('FLBuilder')) { + $builders[] = 'Beaver Builder'; + } + if (defined('BRICKS_VERSION')) { + $builders[] = 'Bricks'; + } + return $builders; + } +} diff --git a/plugins/emdash-exporter/includes/class-comment-exporter.php b/plugins/emdash-exporter/includes/class-comment-exporter.php new file mode 100644 index 0000000..eca10d5 --- /dev/null +++ b/plugins/emdash-exporter/includes/class-comment-exporter.php @@ -0,0 +1,77 @@ + ['approve', 'hold'], + 'type' => 'comment', + 'orderby' => 'comment_ID', + 'order' => 'ASC', + 'number' => $per_page, + 'offset' => ($page - 1) * $per_page, + ]; + + $comments = get_comments($args); + + $total = (int) get_comments(array_merge($args, [ + 'count' => true, + 'number' => 0, + 'offset' => 0, + ])); + + $items = []; + foreach ($comments as $comment) { + $items[] = [ + 'id' => (int) $comment->comment_ID, + 'post_id' => (int) $comment->comment_post_ID, + 'parent_id' => (int) $comment->comment_parent ?: null, + 'author_name' => $comment->comment_author, + 'author_email' => $comment->comment_author_email, + // EmDash renders comment bodies as escaped plain text + 'body' => wp_strip_all_tags($comment->comment_content), + // comment_date_gmt is "Y-m-d H:i:s" in UTC + 'date_gmt' => str_replace(' ', 'T', $comment->comment_date_gmt) . 'Z', + 'status' => $comment->comment_approved === '1' ? 'approved' : 'pending', + ]; + } + + return [ + 'items' => $items, + 'total' => $total, + 'pages' => $per_page > 0 ? (int) ceil($total / $per_page) : 1, + 'page' => $page, + 'per_page' => $per_page, + ]; + } + + /** + * Count exportable comments (approved + pending), for the wizard overview. + * + * @return int + */ + public static function count() { + return (int) get_comments([ + 'status' => ['approve', 'hold'], + 'type' => 'comment', + 'count' => true, + ]); + } +} diff --git a/plugins/emdash-exporter/includes/class-content-exporter.php b/plugins/emdash-exporter/includes/class-content-exporter.php index c279d8a..e54613a 100644 --- a/plugins/emdash-exporter/includes/class-content-exporter.php +++ b/plugins/emdash-exporter/includes/class-content-exporter.php @@ -102,6 +102,12 @@ public function analyze() { $result['acf'] = $this->analyze_acf_fields(); } + // Multilingual plugin info (WPML/Polylang) if available + $i18n = EmDash_I18n_Exporter::site_info(); + if ($i18n) { + $result['i18n'] = $i18n; + } + return $result; } @@ -156,8 +162,17 @@ private function format_post($post, $include_meta = true) { 'comment_status' => $post->comment_status, 'ping_status' => $post->ping_status, 'guid' => $post->guid, + // The real public URL, so the importer can build a redirect map + // that preserves SEO when the URL structure changes. + 'permalink' => get_permalink($post), ]; + // Locale + translation group from WPML/Polylang, when active + $i18n = EmDash_I18n_Exporter::post_info($post->ID); + if (!empty($i18n)) { + $data = array_merge($data, $i18n); + } + // Taxonomies $taxonomies = get_object_taxonomies($post->post_type); $data['taxonomies'] = []; @@ -274,9 +289,10 @@ private function get_post_meta_clean($post_id) { // Single values are unwrapped $clean_meta[$key] = count($values) === 1 ? $values[0] : $values; - // Try to unserialize PHP serialized data - if (is_string($clean_meta[$key]) && $this->is_serialized($clean_meta[$key])) { - $unserialized = @unserialize($clean_meta[$key]); + // Decode PHP-serialized data. allowed_classes=false prevents + // object injection from untrusted database values. + if (is_string($clean_meta[$key]) && is_serialized($clean_meta[$key])) { + $unserialized = @unserialize($clean_meta[$key], ['allowed_classes' => false]); if ($unserialized !== false) { $clean_meta[$key] = $unserialized; } @@ -286,17 +302,6 @@ private function get_post_meta_clean($post_id) { return $clean_meta; } - /** - * 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; - } - /** * Analyze custom fields used by a post type */ @@ -361,7 +366,7 @@ private function infer_field_type($key, $sample) { if (!$sample) return 'string'; // Check sample value - if ($this->is_serialized($sample)) { + if (is_serialized($sample)) { return 'json'; } if (is_numeric($sample)) { diff --git a/plugins/emdash-exporter/includes/class-health-check.php b/plugins/emdash-exporter/includes/class-health-check.php new file mode 100644 index 0000000..f2cabd5 --- /dev/null +++ b/plugins/emdash-exporter/includes/class-health-check.php @@ -0,0 +1,202 @@ +check_permalinks(), + $this->check_application_passwords(), + $this->check_rest_loopback(), + $this->check_authorization_header(), + $this->check_reachability(), + ]; + } + + /** True when every check passes (warnings are acceptable). */ + public static function all_ok(array $checks) { + foreach ($checks as $check) { + if ($check['status'] === 'fail') { + return false; + } + } + return true; + } + + private function check_permalinks() { + $structure = get_option('permalink_structure'); + if ($structure) { + return [ + 'id' => 'permalinks', + 'label' => __('Pretty permalinks', 'emdash-exporter'), + 'status' => 'pass', + 'message' => __('Pretty permalinks are enabled, the REST API is available at /wp-json/.', 'emdash-exporter'), + ]; + } + return [ + 'id' => 'permalinks', + 'label' => __('Pretty permalinks', 'emdash-exporter'), + 'status' => 'warn', + 'message' => __('Plain permalinks are active. The REST API is only reachable via ?rest_route=, which some importers do not try.', 'emdash-exporter'), + 'fix' => sprintf( + /* translators: %s: URL of the permalinks settings screen */ + __('Go to Settings → Permalinks and choose any structure other than "Plain".', 'emdash-exporter'), + esc_url(admin_url('options-permalink.php')) + ), + ]; + } + + private function check_application_passwords() { + if (wp_is_application_passwords_available()) { + return [ + 'id' => 'app_passwords', + 'label' => __('Application Passwords', 'emdash-exporter'), + 'status' => 'pass', + 'message' => __('Application Passwords are available for API authentication.', 'emdash-exporter'), + ]; + } + return [ + 'id' => 'app_passwords', + 'label' => __('Application Passwords', 'emdash-exporter'), + 'status' => 'fail', + 'message' => __('Application Passwords are disabled on this site. EmDash cannot authenticate without them.', 'emdash-exporter'), + 'fix' => is_ssl() + ? __('A security plugin or a filter (wp_is_application_passwords_available) is disabling them. Re-enable Application Passwords in your security plugin settings.', 'emdash-exporter') + : __('WordPress disables Application Passwords on non-HTTPS sites. Enable HTTPS for this site, then reload this page.', 'emdash-exporter'), + ]; + } + + private function check_rest_loopback() { + $response = wp_remote_get(rest_url('emdash/v1/probe'), [ + 'timeout' => 10, + 'sslverify' => false, // ponytail: loopback to self; local certs are often self-signed + ]); + + if (is_wp_error($response)) { + return [ + 'id' => 'rest', + 'label' => __('REST API reachable', 'emdash-exporter'), + 'status' => 'fail', + 'message' => sprintf( + /* translators: %s: error message */ + __('This site cannot reach its own REST API: %s', 'emdash-exporter'), + esc_html($response->get_error_message()) + ), + 'fix' => __('Loopback requests are blocked. Check with your host, or look for security plugins blocking the REST API.', 'emdash-exporter'), + ]; + } + + $code = wp_remote_retrieve_response_code($response); + if ($code !== 200) { + return [ + 'id' => 'rest', + 'label' => __('REST API reachable', 'emdash-exporter'), + 'status' => 'fail', + 'message' => sprintf( + /* translators: %d: HTTP status code */ + __('The EmDash Exporter REST endpoint returned HTTP %d instead of 200.', 'emdash-exporter'), + (int) $code + ), + 'fix' => __('A security plugin or firewall rule is likely blocking /wp-json/. Allow the emdash/v1 namespace.', 'emdash-exporter'), + ]; + } + + return [ + 'id' => 'rest', + 'label' => __('REST API reachable', 'emdash-exporter'), + 'status' => 'pass', + 'message' => __('The EmDash Exporter REST API responds correctly.', 'emdash-exporter'), + ]; + } + + /** + * The classic Application Password failure: Apache/CGI setups strip the + * Authorization header before PHP sees it. We loop back to our own + * header-check endpoint with a dummy header and see if it arrives. + */ + private function check_authorization_header() { + $response = wp_remote_get(rest_url('emdash/v1/header-check'), [ + 'timeout' => 10, + 'sslverify' => false, + // Custom scheme on purpose: a Basic header with fake credentials + // would trigger Application Password validation (rejecting the + // whole REST request if an 'emdash' user exists) and can count as + // a failed login for brute-force protection plugins. A Bearer + // token could collide with JWT/OAuth plugins the same way. We + // only test whether the header string survives the server config. + 'headers' => ['Authorization' => 'EmDashCheck header-check'], + ]); + + $label = __('Authorization header', 'emdash-exporter'); + + if (is_wp_error($response)) { + return [ + 'id' => 'auth_header', + 'label' => $label, + 'status' => 'warn', + 'message' => __('Could not verify (loopback request failed). Authentication may still work from outside.', 'emdash-exporter'), + ]; + } + + $body = json_decode(wp_remote_retrieve_body($response), true); + if (is_array($body) && !empty($body['authorization_header_received'])) { + return [ + 'id' => 'auth_header', + 'label' => $label, + 'status' => 'pass', + 'message' => __('The Authorization header reaches WordPress. Application Password logins will work.', 'emdash-exporter'), + ]; + } + + return [ + 'id' => 'auth_header', + 'label' => $label, + 'status' => 'fail', + 'message' => __('Your server strips the Authorization header before it reaches WordPress, so Application Password logins fail.', 'emdash-exporter'), + 'fix' => __('Add this line to your .htaccess, directly after "RewriteEngine On":
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 @@ + +
+

+ + +

+ + +
+
+ +
+

+

+
+ + + +
+
+
    + +
  • + +
    + +

    + +

    + +
    +
  • + +
+ + + +
+
+ + +
+
+ 2 +
+

+

+
+
+
+
+ + + +

+ + + +
+
+
+ + +
+
+ 3 +
+

+

+
+
+
+
+
+

+
    +
  1. +
  2. +
  3. +
+
+
+

+

+ + + + +

+ +

+

+ +

+

+ +

+

+
    +
  1. +
  2. +
  3. +
+
+
+ +

+
+
+

+
    + +
  • + + +
  • + +
  • + 0) : ?> +
  • + + 0) : ?> +
  • + +
  • + +
  • + + +
  • + + +
  • + + +
  • + +
+
+
+

+
    + +
  • + +
  • +
  • +
  • +
+
+
+
+
+
diff --git a/plugins/emdash-exporter/readme.md b/plugins/emdash-exporter/readme.md index e95456c..4843e64 100644 --- a/plugins/emdash-exporter/readme.md +++ b/plugins/emdash-exporter/readme.md @@ -5,36 +5,40 @@ Requires at least: 5.6 Tested up to: 6.4 Requires PHP: 7.4 - Stable tag: 1.0.0 + Stable tag: 1.2.0 License: GPL3 License URI: https://opensource.org/license/gpl-3.0 -Export your WordPress content to EmDash CMS via REST API. Full support for posts, pages, custom post types, ACF fields, media, and SEO data. +Migrate your WordPress site to EmDash CMS with a guided wizard. Full support for posts, pages, custom post types, ACF fields, media, menus, translations, and SEO data. ## Description -EmDash Exporter adds REST API endpoints to your WordPress site that allow EmDash CMS to import your content directly—no file downloads required. +EmDash Exporter adds a guided migration wizard (Tools → EmDash Migration) and REST API endpoints that allow EmDash CMS to import your content directly—no file downloads required. **Features:** -- **One-click import** – Connect EmDash to your site and import everything -- **Full content** – Posts, pages, and custom post types including drafts +- **Guided wizard** – Site checks with actionable fixes, one-click migration key, honest overview of what will (and won't) be migrated +- **Migration key** – One copy-paste key contains everything EmDash needs to connect; no manual Application Password setup +- **Full content** – Posts, pages, and custom post types including drafts, with the original permalink for redirect maps - **Media with metadata** – Images, videos, and files with alt text, captions, and dimensions - **Custom fields** – Full ACF support plus any custom meta fields - **SEO data** – Yoast SEO and Rank Math meta automatically included - **Taxonomies** – Categories, tags, and custom taxonomies with hierarchy +- **Navigation menus** – Menus with hierarchy and theme locations +- **Comments** – Approved and pending comments with authors, dates, and threading +- **Translations** – WPML and Polylang locales and translation groups - **Authors** – User data for proper attribution +- **No EmDash site yet?** – Deploy a starter site to Cloudflare straight from the wizard ## Authentication -Uses WordPress Application Passwords (built into WordPress 5.6+). Create an application password in your user profile, then use it with your WordPress username to authenticate API requests. +Uses WordPress Application Passwords (built into WordPress 5.6+). The wizard creates one for you and packages it into a single migration key. You can revoke it at any time. ## Installation -1. Upload the `emdash-exporter` folder to `/wp-content/plugins/` -2. Activate the plugin through the 'Plugins' menu in WordPress -3. Create an Application Password: Users → Your Profile → Application Passwords -4. In EmDash, enter your site URL and credentials to begin import +1. Download `emdash-exporter.zip` from the [latest release](https://github.com/emdash-cms/wp-emdash/releases) and install it via Plugins → Add New → Upload Plugin (or upload the `plugins/emdash-exporter` folder to `/wp-content/plugins/`) +2. Activate the plugin — you'll land in the migration wizard automatically +3. Follow the three steps: site check, generate your migration key, paste it in EmDash ## API Endpoints @@ -43,8 +47,9 @@ All endpoints are under `/wp-json/emdash/v1/` ### Public (no auth required): - `GET /probe` – Site info and capabilities +- `GET /header-check` – Reports whether the Authorization header reaches WordPress (used by the wizard) -### Authenticated: +### Authenticated (requires the `export` capability — Administrators and Editors): - `GET /analyze` – Full site analysis for import planning - `GET /content?post_type=post` – Get posts (paginated) @@ -52,16 +57,18 @@ All endpoints are under `/wp-json/emdash/v1/` - `GET /media/{id}?include_data=true` – Get single media item with base64 data - `GET /taxonomies` – Get all taxonomies and terms - `GET /options` – Get site options +- `GET /menus` – Get navigation menus with items and hierarchy +- `GET /comments` – Get comments (paginated, approved and pending) ## Frequently Asked Questions -### Do I need to install anything on my EmDash site? = +### Do I need to install anything on my EmDash site? > No. This plugin runs on your WordPress site and exposes an API that EmDash connects to. ### Is my content secure? -> Yes. All export endpoints (except /probe) require authentication. Use Application Passwords and HTTPS. +> Yes. All export endpoints (except /probe and /header-check) require authentication. Use Application Passwords and HTTPS. ### What about my media files? @@ -77,6 +84,23 @@ All endpoints are under `/wp-json/emdash/v1/` ## Changelog +### 1.2.0 + +- New `/comments` endpoint: exports approved and pending comments (authors, dates, threading) for import into EmDash's native comment system +- Site identity in `/options`: custom logo and site icon with resolved URLs, so EmDash can take over title, tagline, logo, and favicon +- Wizard overview now lists comments and site identity as migrated +- `/taxonomies` now includes each taxonomy's singular label and registered post types, so EmDash can auto-create custom post type taxonomies scoped to the right collections + +### 1.1.0 + +- Guided migration wizard under Tools → EmDash Migration with preflight site checks (permalinks, REST loopback, Authorization header, Application Passwords, reachability) +- One-click migration key: creates a revocable Application Password and packages URL + credentials into a single copy-paste string +- Deploy-to-Cloudflare shortcut for users without an EmDash site +- New `/menus` endpoint: navigation menus with hierarchy and theme locations +- Per-post `permalink` for SEO-preserving redirect maps +- WPML/Polylang support: per-post `locale` and `translation_group`, site-level `i18n` info in probe/analyze +- Security: export endpoints now require the `export` capability (previously `edit_posts` was enough), removed server file paths from media responses, hardened unserialization + ### 1.0.0 - Initial release