get_current_user_id(), 'package' => $package], self::TOKEN_LIFETIME); return ['token' => $token] + $analysis; } /** * What would happen to every menu and option of a file, on this site as it is now. Writes nothing. * * @return array{menus: array, options: array, warnings: string[]} */ public static function analyze(array $package): array { self::loadAdminMenu(); $rows = []; foreach (PageRepository::rows() as $row) { $rows[(string) $row->menu_slug] = $row; } $menuIds = []; foreach ($rows as $row) { $value = PageRepository::decode($row->page_value); if (isset($value['menu_id'])) { $menuIds[(string) $value['menu_id']] = (string) $row->menu_slug; } } $owners = self::fieldOwners($rows); $menus = []; $options = []; $seen = []; $claimed = []; foreach ($package['menus_and_pages'] as $item) { $entry = self::analyzeMenu($item, $package['options'], $rows, $menuIds, $seen, $owners, $claimed); $menus[] = $entry['menu']; $options = array_merge($options, $entry['options']); } $warnings = []; if (! Transaction::supported()) { $warnings[] = __('The database tables of this site cannot roll back. If something goes wrong halfway, part of the import may remain.', 'admin-options-pages'); } return ['menus' => $menus, 'options' => $options, 'warnings' => array_merge($warnings, self::missingRoleWarnings($package))]; } /** * Roles of the pages in the file that this site does not have. The page is imported with them all the same: the * role gives nobody access until a plugin or the site builder adds it. * * @return string[] */ private static function missingRoleWarnings(array $package): array { $known = wp_roles()->roles; $warnings = []; foreach ($package['menus_and_pages'] as $item) { foreach ((array) ($item['page_value']['pages'] ?? []) as $page) { foreach ((array) ($page['roles'] ?? []) as $role) { if (is_string($role) && $role !== 'administrator' && ! isset($known[$role])) { $warnings[] = sprintf( /* translators: 1: the slug of a user role, 2: the menu title of a page */ __('The role "%1$s" of the page "%2$s" does not exist on this site. Until it does, users with that role cannot open the page.', 'admin-options-pages'), $role, (string) ($page['menu_title'] ?? '') ); } } } } return array_values(array_unique($warnings)); } /** * @return array{menu: array, options: array} */ private static function analyzeMenu(array $item, array $fileOptions, array $rows, array $menuIds, array &$seen, array $owners, array &$claimed): array { $value = $item['page_value']; $slug = (string) $item['menu_slug']; $existing = $rows[$slug] ?? null; $problems = []; if (isset($seen[$slug])) { $problems[] = __('This menu is in the file more than once.', 'admin-options-pages'); } $seen[$slug] = true; if ($existing !== null && ! PageRepository::isSupported($existing->page_value)) { $problems[] = __('The menu on this site was saved by a newer version of the plugin. Update the plugin first.', 'admin-options-pages'); } $menuId = (string) $value['menu_id']; if (isset($menuIds[$menuId]) && $menuIds[$menuId] !== $slug) { $problems[] = __('Another menu on this site already has the id of this menu.', 'admin-options-pages'); } $existingId = $existing !== null ? (int) $existing->id : null; // Fields that cannot be imported on this site are left out of the menu, and listed with the reason. $unavailable = self::unavailableFields($value, $owners); $reduced = self::selectedValue($value, [], $unavailable); foreach (array_unique(Exporter::optionNames($reduced)) as $name) { if (isset($claimed[$name]) && $claimed[$name] !== $slug) { $problems[] = sprintf( /* translators: %s: an option name */ __('The option name "%s" is used by more than one menu in the file.', 'admin-options-pages'), $name ); } $claimed[$name] ??= $slug; } if (! $problems) { $problems = self::problemsOf($reduced, $slug, $existingId, array_keys($owners)); } $optionRows = []; $seenNames = []; $tree = []; $fields = 0; foreach ($value['pages'] as $pageKey => $page) { $treeFields = []; foreach ($page['fields'] ?? [] as $fieldKey => $field) { $fields++; $name = (string) ($field['field_name'] ?? ''); $reason = $unavailable[$pageKey . ':' . $fieldKey] ?? null; $type = FieldRegistry::instance()->get((string) $field['type']); $treeFields[] = [ 'index' => $fieldKey, 'type' => (string) $field['type'], // The name of the type as the editor shows it; the stored type when it is not available here. 'type_label' => $type ? $type->label() : (string) $field['type'], 'name' => $name, // A layout field has no label; a title shows its text. 'label' => wp_strip_all_tags(stripslashes((string) ($field['field_label'] ?? $field['field_title'] ?? ''))), 'reason' => $reason, ]; if ($reason === null && $name !== '' && ! isset($seenNames[$name])) { $seenNames[$name] = true; $optionRows[] = self::optionStatus($name, $slug, $fileOptions, isset($owners[$name]), $owners[$name] ?? null) + ['page' => $pageKey, 'field' => $fieldKey] + ['value' => array_key_exists($name, $fileOptions) ? self::shownValue($fileOptions[$name], (string) $field['type']) : null]; } } $tree[] = [ 'index' => $pageKey, 'title' => (string) ($page['menu_title'] ?? ''), // The first page of a custom menu is its main page; when it is left out the next page takes over. 'main' => $pageKey === 0 && isset($value['menu']), 'fields' => $treeFields, ]; } return [ 'menu' => [ 'menu_slug' => $slug, 'title' => (string) ($value['menu']['menu_title'] ?? $value['existing_menu']['menu_name'] ?? $slug), 'kind' => isset($value['existing_menu']) ? 'existing' : 'custom', 'status' => $problems ? 'blocked' : ($existing !== null ? 'exists' : 'new'), 'pages' => count($value['pages']), 'fields' => $fields, 'tree' => $tree, 'problems' => array_values(array_unique($problems)), ], 'options' => $optionRows, ]; } /** * The fields of a page value that cannot be imported on this site, and why: a type that is not available, no * valid option name, or an option name that WordPress or another plugin uses. * * @return array "page:field" => reason */ private static function unavailableFields(array $value, array $owners): array { $known = FieldRegistry::instance()->all(); $missing = '__aop_missing__'; $result = []; foreach ($value['pages'] as $pageKey => $page) { foreach ($page['fields'] ?? [] as $fieldKey => $field) { $type = (string) $field['type']; $name = (string) ($field['field_name'] ?? ''); $reason = null; if (! isset($known[$type])) { $reason = sprintf( /* translators: %s: the name of a field type */ __('The field type "%s" is not available on this site. Activate the plugin that provides it.', 'admin-options-pages'), $type ); } elseif ($known[$type]->hasOption()) { if ($name === '' || ! preg_match('/^[a-z0-9_]{1,191}$/', $name) || ! preg_match('/[a-z0-9]/', $name)) { $reason = __('This is not a valid option name.', 'admin-options-pages'); } elseif (! isset($owners[$name]) && get_option($name, $missing) !== $missing) { $reason = __('This option name is used by WordPress or another plugin.', 'admin-options-pages'); } } if ($reason !== null) { $result[$pageKey . ':' . $fieldKey] = $reason; } } } return $result; } /** * The page value as it will be imported: without the pages and fields that were left out (`$exclude` holds * "page" and "page:field" keys) and without the fields that cannot be imported. The fields are numbered again. * * @param string[] $exclude Keys the person unchecked. * @param array $unavailable Keys of `unavailableFields()`. */ private static function selectedValue(array $value, array $exclude, array $unavailable): array { $drop = array_flip(array_merge($exclude, array_keys($unavailable))); $pages = []; foreach ($value['pages'] as $pageKey => $page) { if (isset($drop[(string) $pageKey])) { continue; } $fields = []; foreach ($page['fields'] ?? [] as $fieldKey => $field) { if (isset($drop[$pageKey . ':' . $fieldKey])) { continue; } $field['field_key'] = (string) count($fields); $fields[] = $field; } $page['fields'] = $fields; $pages[] = $page; } $value['pages'] = $pages; return $value; } /** * Why the editor would not save this page value, as sentences. Empty when it is fine. * * @return string[] */ private static function problemsOf(array $value, string $slug, ?int $existingId, array $ownNames): array { $problems = []; // Names that the plugin made itself may be taken over; names of WordPress or other plugins may not. AllowedOptionNames::set($ownNames); try { $prepared = self::prepare($value, $existingId); } catch (ValidationFailed $failure) { return [__('Something in this menu is not allowed.', 'admin-options-pages')]; } finally { AllowedOptionNames::clear(); } foreach ($prepared['errors'] as $error) { $problems[] = $error['message']; } if (! $problems && $prepared['menu_slug'] !== $slug) { $problems[] = __('The name of this menu does not match what is in it.', 'admin-options-pages'); } return $problems; } /** * Run a page value through the checks and the sanitizing of the editor, without saving it. * * @return array{errors: array, menu_slug: string} * * @throws ValidationFailed When a rule fails that means the data was made up. */ private static function prepare(array $value, ?int $existingId): array { // A stored value is in the domain the save flow reads: it was slashed like the $_POST of WordPress when it // was saved. It goes back in as it is, so an export and an import do not add slashes. $post = PageValueToPost::convert($value); $get = self::getFor($existingId); $errors = Validator::errors($post, $get); if ($errors) { return ['errors' => $errors, 'menu_slug' => '']; } $fields = Request::run($post, $get, function () use ($post) { $input = new Input(); return isset($post['existing_menu']) ? $input->exitingMenuFields(new Validation()) : $input->customMenuFields(new Validation()); }); return ['errors' => [], 'menu_slug' => (string) $fields['menu_slug']]; } /** * `$GLOBALS['menu']` only exists on admin screens; the rules fall back to the list stored by the plugin. */ private static function loadAdminMenu(): void { if (empty($GLOBALS['menu'])) { $GLOBALS['menu'] = (array) get_option('aop_admin_menu_list', []); } } private static function getFor(?int $existingId): array { return $existingId === null ? ['page' => SubpageCreate::SLUG] : ['page' => SubpageEdit::SLUG, 'optionpage' => (string) $existingId]; } /** * Where each option name of the plugin belongs now: option name => [menu_slug, title, id] of the row with a * field of that name. * * @param array $rows The rows by menu_slug. * * @return array */ private static function fieldOwners(array $rows): array { $owners = []; foreach ($rows as $row) { $value = PageRepository::decode($row->page_value); $title = (string) ($value['menu']['menu_title'] ?? $value['existing_menu']['menu_name'] ?? $row->menu_slug); foreach (Exporter::optionNames($value) as $name) { $owners[$name] ??= ['menu_slug' => (string) $row->menu_slug, 'title' => $title, 'id' => (int) $row->id]; } } return $owners; } /** * @param string $slug The menu the option belongs to in the file. * @param bool $own The plugin itself made this option name (a field of a stored menu has it). * @param array|null $owner The stored menu that has the field now. */ private static function optionStatus(string $name, string $slug, array $fileOptions, bool $own, ?array $owner): array { $missing = '__aop_missing__'; $current = get_option($name, $missing); $hasValue = array_key_exists($name, $fileOptions); if ($current === $missing) { $status = 'new'; } elseif (! $own) { // Somebody else's: WordPress or another plugin. $status = 'foreign'; } else { $status = $hasValue && self::sameValue($fileOptions[$name], $current) ? 'same' : 'different'; } return [ 'name' => $name, 'menu_slug' => $slug, 'status' => $status, 'has_value' => $hasValue, // The field is in another menu of this site now and moves to this one. 'taken_from' => $owner !== null && $owner['menu_slug'] !== $slug ? $owner['title'] : null, ]; } /** * The value of the file as one short line of text for the preview (the browser escapes it). A password is never * sent to the browser, a checkbox reads as checked or not, media as its id. * * @param mixed $value */ private static function shownValue($value, string $type): string { if ($type === 'password') { return $value === '' || $value === null ? '' : '••••••••'; } if ($type === 'checkbox') { return (string) $value === '1' ? __('Checked', 'admin-options-pages') : __('Not checked', 'admin-options-pages'); } if (is_array($value) || is_object($value)) { $value = (string) wp_json_encode($value); } $text = trim((string) preg_replace('/\s+/', ' ', wp_strip_all_tags((string) $value))); if (in_array($type, ['image', 'file'], true) && $text !== '') { /* translators: %s: the id of an image or file in the media library */ return sprintf(__('Media ID %s', 'admin-options-pages'), $text); } return mb_strlen($text) > 60 ? rtrim(mb_substr($text, 0, 60)) . '…' : $text; } private static function sameValue($imported, $current): bool { if (is_scalar($imported) && is_scalar($current)) { return (string) $imported === (string) $current; } return wp_json_encode($imported) === wp_json_encode($current); } /** * Do what was chosen. * * @param string $token From `preview()`. * @param array $menus menu_slug => import | overwrite | skip. Missing: the safe default. * @param array $options option name => write | keep. Missing: the safe default. * @param array $exclude menu_slug => the "page" and "page:field" keys that were unchecked. * * @return array{imported: int, overwritten: int, skipped: int, options_written: int, options_kept: int, warnings: string[]} * * @throws ImportInvalid Nothing was written. */ public static function commit(string $token, array $menus, array $options, array $exclude = []): array { $stored = get_transient(self::transientName($token)); if (! is_array($stored) || (int) ($stored['user'] ?? 0) !== get_current_user_id() || ! isset($stored['package'])) { throw new ImportInvalid('unknown_token', __('This import has expired. Choose the file again.', 'admin-options-pages'), [], 410); } $package = $stored['package']; // What the file says is decided by the analysis of now, not by what was seen at the preview. $analysis = self::analyze($package); $menuChoices = self::menuChoices($analysis['menus'], $menus); $optionChoices = self::optionChoices($analysis['options'], $options); $items = []; foreach ($package['menus_and_pages'] as $item) { $items[$item['menu_slug']] = $item; } $exclude = self::checkExclusions($exclude, $items); $result = ['imported' => 0, 'overwritten' => 0, 'skipped' => 0, 'options_written' => 0, 'options_kept' => 0, 'warnings' => [], 'imported_titles' => [], 'replaced_titles' => []]; $rows = []; foreach (PageRepository::rows() as $row) { $rows[(string) $row->menu_slug] = $row; } $owners = self::fieldOwners($rows); $selected = []; // What is imported of each menu: what was checked, and only what can be imported on this site. $finals = []; foreach ($menuChoices as $slug => $choice) { if ($choice !== 'skip') { $value = $items[$slug]['page_value']; $finals[$slug] = self::selectedValue($value, $exclude[$slug] ?? [], self::unavailableFields($value, $owners)); } } self::checkNewNames($menuChoices, $finals, $rows); AllowedOptionNames::set(array_keys($owners)); try { Transaction::run(function () use ($menuChoices, $finals, $rows, $owners, &$result, &$selected) { // A field that moves from one menu of this site to the imported one leaves the first menu. self::takeFieldsFromOtherMenus($menuChoices, $finals, $rows, $owners); foreach ($menuChoices as $slug => $choice) { if ($choice === 'skip') { $result['skipped']++; continue; } self::save($finals[$slug], $choice === 'overwrite' ? (int) $rows[$slug]->id : null); $result[$choice === 'overwrite' ? 'overwritten' : 'imported']++; // The names, so the screen can say what came in. $result[$choice === 'overwrite' ? 'replaced_titles' : 'imported_titles'][] = (string) ($finals[$slug]['menu']['menu_title'] ?? $finals[$slug]['existing_menu']['menu_name'] ?? $slug); $selected[] = $finals[$slug]; } }); } finally { AllowedOptionNames::clear(); } // The menus are saved: now the values. A menu that was skipped keeps all of its options as they are. self::writeOptions($selected, $package['options'], $optionChoices, $result); OptionNames::sync(); delete_transient(self::transientName($token)); return $result; } /** * Remove from the stored menus the fields whose option name an imported menu takes over. A menu that the * import replaces itself is left alone: the file decides what it has. * * @param array $choices menu_slug => import | overwrite | skip * @param array $finals What is imported of each menu, by menu_slug. * @param array $rows The stored rows by menu_slug. * @param array $owners See `fieldOwners()`. */ private static function takeFieldsFromOtherMenus(array $choices, array $finals, array $rows, array $owners): void { $taken = []; foreach ($choices as $slug => $choice) { if ($choice === 'skip') { continue; } foreach (array_unique(Exporter::optionNames($finals[$slug])) as $name) { $owner = $owners[$name] ?? null; if ($owner !== null && $owner['menu_slug'] !== $slug && ($choices[$owner['menu_slug']] ?? 'skip') !== 'overwrite') { $taken[$owner['menu_slug']][$name] = true; } } } foreach ($taken as $ownerSlug => $names) { $value = PageRepository::decode($rows[$ownerSlug]->page_value); foreach ($value['pages'] as $pageKey => $page) { $value['pages'][$pageKey]['fields'] = array_values(array_filter( $page['fields'] ?? [], function ($field) use ($names) { return ! isset($names[$field['field_name'] ?? '']); } )); } PageRepository::update((int) $rows[$ownerSlug]->id, $ownerSlug, PageRepository::encode($value)); } } /** * When the first page of a custom menu is left out, the next page becomes the main page and the menu gets its * name. That name must not be a different menu of this site. * * @param array $choices menu_slug => import | overwrite | skip * @param array $finals What is imported of each menu, by menu_slug. * @param array $rows The stored rows by menu_slug. * * @throws ImportInvalid */ private static function checkNewNames(array $choices, array $finals, array $rows): void { foreach ($finals as $slug => $final) { $newSlug = isset($final['menu']) ? (string) $final['pages'][0]['menu_slug'] : (string) $final['existing_menu']['menu_slug']; if ($newSlug === $slug || ! isset($rows[$newSlug])) { continue; } // Replacing a menu may give its row a new name, as long as no other row has it. throw new ImportInvalid( 'invalid_choice', sprintf( /* translators: %s: the name of a menu */ __('Without its first page the menu "%s" would get the name of a menu that is on this site already. Import the first page too, or choose another page as the first.', 'admin-options-pages'), $slug ) ); } } /** * What was unchecked must be something in the file, and a menu keeps at least one page. * * @param array $exclude * @param array $items The menus of the file by menu_slug. * * @return array * * @throws ImportInvalid */ private static function checkExclusions(array $exclude, array $items): array { $checked = []; foreach ($exclude as $slug => $keys) { $slug = (string) $slug; if (! isset($items[$slug]) || ! is_array($keys)) { throw new ImportInvalid('invalid_choice', __('A choice was made for a menu that is not in the file.', 'admin-options-pages')); } $value = $items[$slug]['page_value']; foreach ($keys as $key) { if (! is_string($key) || ! preg_match('/^(\d+)(?::(\d+))?$/', $key, $match) || ! isset($value['pages'][(int) $match[1]])) { throw new ImportInvalid('invalid_choice', __('A choice was made for a page or field that is not in the file.', 'admin-options-pages')); } } if (count(array_unique(array_filter($keys, function ($key) { return strpos($key, ':') === false; }))) >= count($value['pages'])) { throw new ImportInvalid('invalid_choice', __('A menu needs at least one page.', 'admin-options-pages')); } $checked[$slug] = array_values(array_unique($keys)); } return $checked; } /** * @return array menu_slug => import | overwrite | skip * * @throws ImportInvalid */ private static function menuChoices(array $menus, array $chosen): array { $allowed = ['new' => ['import', 'skip'], 'exists' => ['overwrite', 'skip'], 'blocked' => ['skip']]; $default = ['new' => 'import', 'exists' => 'skip', 'blocked' => 'skip']; $choices = []; foreach ($menus as $menu) { $choice = $chosen[$menu['menu_slug']] ?? $default[$menu['status']]; if (! in_array($choice, $allowed[$menu['status']], true)) { throw new ImportInvalid( 'invalid_choice', sprintf( /* translators: %s: the title of a menu */ __('That cannot be done with the menu "%s".', 'admin-options-pages'), $menu['title'] ) ); } $choices[$menu['menu_slug']] = $choice; } foreach (array_keys($chosen) as $slug) { if (! isset($choices[$slug])) { throw new ImportInvalid('invalid_choice', __('A choice was made for a menu that is not in the file.', 'admin-options-pages')); } } return $choices; } /** * @return array option name => write | keep * * @throws ImportInvalid */ private static function optionChoices(array $options, array $chosen): array { $choices = []; foreach ($options as $option) { $default = $option['status'] === 'new' ? 'write' : 'keep'; $choice = $chosen[$option['name']] ?? $default; if (! in_array($choice, ['write', 'keep'], true) || ($choice === 'write' && $option['status'] === 'foreign')) { throw new ImportInvalid( 'invalid_choice', sprintf( /* translators: %s: an option name */ __('That cannot be done with the option "%s".', 'admin-options-pages'), $option['name'] ) ); } $choices[$option['name']] = $choice; } return $choices; } /** * Save one menu the way the editor does. * * @throws ImportInvalid */ private static function save(array $value, ?int $existingId): void { $post = PageValueToPost::convert($value); try { Request::run($post, self::getFor($existingId), function () { (new PageSaver())->save(new Validation()); }); } catch (PageInvalid | ValidationFailed $failure) { throw new ImportInvalid('invalid_menu', __('A menu in the file could not be saved. Nothing was imported.', 'admin-options-pages')); } } /** * @param array[] $pageValues The menus that were saved. */ private static function writeOptions(array $pageValues, array $fileOptions, array $choices, array &$result): void { // The sanitizers of the field types report through the Settings API. if (! function_exists('add_settings_error')) { require_once ABSPATH . 'wp-admin/includes/template.php'; } foreach ($pageValues as $value) { foreach ($value['pages'] as $page) { foreach ($page['fields'] as $field) { $name = (string) ($field['field_name'] ?? ''); if ($name === '' || ! array_key_exists($name, $fileOptions)) { continue; } $type = FieldRegistry::instance()->get($field['type']); if ($type === null || ! $type->hasOption()) { continue; } if (($choices[$name] ?? 'keep') !== 'write') { $result['options_kept']++; continue; } $clean = FieldManager::sanitizeValue($field, $fileOptions[$name]); if (! self::sameValue($fileOptions[$name], $clean)) { $result['warnings'][] = sprintf( /* translators: %s: an option name */ __('The value of "%s" was changed by its field type.', 'admin-options-pages'), $name ); } Plugin::updateOption($name, $clean, ($field['toggle_autoload'] ?? 'yes') === 'no' ? 'no' : 'yes'); $result['options_written']++; } } } } private static function transientName(string $token): string { return Plugin::PREFIX_ . 'import_' . preg_replace('/[^A-Za-z0-9]/', '', $token); } }