diff --git a/date/index.php b/date/index.php index fbd1cf3..f730c37 100644 --- a/date/index.php +++ b/date/index.php @@ -30,7 +30,7 @@ function date( $arg = false ) { (include __DIR__ . '/module-loader.php')(new class { public $name = 'tangible_date'; - public $version = '20261002'; + public $version = '20261003'; function load() { // Compatibility with older version of Date module diff --git a/design-settings/index.php b/design-settings/index.php new file mode 100644 index 0000000..f0e0ed6 --- /dev/null +++ b/design-settings/index.php @@ -0,0 +1,309 @@ + [...], 'panel' => '...' ] ) + * 'design' => tab( $plugin ) in register_plugin_settings' tabs + * + * Import never writes what the Customizer couldn't: every value is matched + * to a registered Customizer setting and run through that setting's own + * sanitize/validate, so there is no second allowlist to drift. Keys with no + * setting on this site (an inactive template's, a retired key) are skipped + * and reported. A plugin's `migrate` callback sees the raw section first, so + * exports from older versions still land. Licences and other options a + * plugin didn't register are never exported. + */ +namespace tangible\design_settings; + +use tangible\framework; + +const FORMAT = 'tangible-design-settings'; +const VERSION = 1; + +/** + * @param object $plugin From framework\register_plugin() + * @param array $config { + * @type string[] $options Option names holding the design. + * @type string[] $theme_mods Theme-mod key prefixes holding design (legacy). + * @type string $panel Customizer panel to deep-link. + * @type callable $migrate fn( array $section, string $from_version ): array + * } + */ +function register( $plugin, array $config ) { + framework::$state->design_settings[ $plugin->name ] = (object) array( + 'plugin' => $plugin, + 'options' => array_values( (array) ( $config['options'] ?? array() ) ), + 'theme_mods' => array_values( (array) ( $config['theme_mods'] ?? array() ) ), + 'panel' => (string) ( $config['panel'] ?? '' ), + 'migrate' => $config['migrate'] ?? null, + ); +} + +/** @return object[] name => registration */ +function registered() { + return framework::$state->design_settings ?? array(); +} + +function get( $name ) { + return registered()[ $name ] ?? null; +} + +/** + * The theme mods a registration owns, by prefix. + */ +function owned_theme_mods( $entry ) { + $mods = array(); + if ( ! $entry->theme_mods ) { + return $mods; + } + foreach ( (array) get_theme_mods() as $key => $value ) { + foreach ( $entry->theme_mods as $prefix ) { + if ( 0 === strpos( (string) $key, $prefix ) ) { + $mods[ $key ] = $value; + } + } + } + return $mods; +} + +/** + * Export payload for the named plugins (all registered when empty). + */ +function export( array $names = array() ) { + $plugins = array(); + foreach ( registered() as $name => $entry ) { + if ( $names && ! in_array( $name, $names, true ) ) { + continue; + } + $options = array(); + foreach ( $entry->options as $option ) { + $value = get_option( $option, null ); + if ( null !== $value ) { + $options[ $option ] = $value; + } + } + $mods = owned_theme_mods( $entry ); + $plugins[ $name ] = array( + 'title' => $entry->plugin->title ?? $name, + 'version' => (string) ( $entry->plugin->version ?? '' ), + // Maps, even when empty: PHP would encode an empty array as [], + // and a hand-edited file then can't gain keys. + 'options' => $options ? $options : new \stdClass(), + 'theme_mods' => $mods ? $mods : new \stdClass(), + ); + } + return array( + 'format' => FORMAT, + 'version' => VERSION, + 'exported' => gmdate( 'c' ), + 'site' => home_url(), + 'plugins' => $plugins, + ); +} + +/** + * Parse an uploaded file into a payload, or a WP_Error saying why not. + */ +function parse( $json ) { + $payload = json_decode( (string) $json, true ); + if ( ! is_array( $payload ) || ( $payload['format'] ?? '' ) !== FORMAT ) { + return new \WP_Error( 'format', 'This file is not a Tangible design settings export.' ); + } + if ( (int) ( $payload['version'] ?? 0 ) > VERSION ) { + return new \WP_Error( 'version', 'This export was made by a newer version. Update the plugins on this site first.' ); + } + if ( ! is_array( $payload['plugins'] ?? null ) ) { + return new \WP_Error( 'plugins', 'This export contains no design settings.' ); + } + return $payload; +} + +/** + * A Customizer manager with every plugin's settings registered, to borrow + * their sanitize/validate callbacks. Built once per request; a setting's + * sanitize_callback lives on a filter it adds when constructed, so anything + * that resets hooks (the test suite does, per test) needs a fresh one. + */ +function customizer( $fresh = false ) { + static $manager = null; + if ( $fresh || null === $manager ) { + require_once ABSPATH . WPINC . '/class-wp-customize-manager.php'; + $manager = new \WP_Customize_Manager( array( 'settings_previewed' => false ) ); + + // Registration callbacks are written for a real Customizer request: + // some read the global (LearnDash does), and one that throws must not + // take the settings page down. Settings it would have added are + // simply absent, so their values are skipped, and the review says so. + $previous = $GLOBALS['wp_customize'] ?? null; + $GLOBALS['wp_customize'] = $manager; + framework::$state->design_settings_error = null; + try { + do_action( 'customize_register', $manager ); + } catch ( \Throwable $e ) { + framework::$state->design_settings_error = $e->getMessage(); + } + $GLOBALS['wp_customize'] = $previous; + } + return $manager; +} + +/** + * Why the Customizer's settings couldn't all be loaded, if they couldn't. + */ +function customizer_error() { + return framework::$state->design_settings_error ?? null; +} + +/** + * Sanitize one value through the Customizer setting registered under $id. + * + * @return array{0: bool, 1: mixed} [ accepted, value ] + */ +function sanitize_setting( $id, $value, $type ) { + $setting = customizer()->get_setting( $id ); + if ( ! $setting || $setting->type !== $type ) { + return array( false, null ); + } + $clean = $setting->sanitize( $value ); + if ( null === $clean || is_wp_error( $clean ) ) { + return array( false, null ); + } + $valid = $setting->validate( $clean ); + if ( is_wp_error( $valid ) ) { + return array( false, null ); + } + return array( true, $clean ); +} + +/** + * What importing $payload would do, per plugin, without writing anything. + * + * @return array name => [ title, status: ready|missing, from, values: [options, theme_mods], applied, skipped: string[] ] + */ +function plan( array $payload ) { + $plan = array(); + foreach ( (array) $payload['plugins'] as $name => $section ) { + $section = (array) $section; + $entry = get( $name ); + $title = (string) ( $section['title'] ?? $name ); + if ( ! $entry ) { + $plan[ $name ] = array( 'title' => $title, 'status' => 'missing', 'from' => (string) ( $section['version'] ?? '' ), 'applied' => 0, 'skipped' => array(), 'values' => null ); + continue; + } + + if ( is_callable( $entry->migrate ) ) { + $section = (array) call_user_func( $entry->migrate, $section, (string) ( $section['version'] ?? '' ) ); + } + + $options = array(); + $mods = array(); + $skipped = array(); + $applied = 0; + + foreach ( (array) ( $section['options'] ?? array() ) as $option => $value ) { + if ( ! in_array( $option, $entry->options, true ) ) { + $skipped[] = $option; + continue; + } + if ( is_array( $value ) ) { + $options[ $option ] = array(); + foreach ( $value as $key => $item ) { + list( $ok, $clean ) = sanitize_setting( "{$option}[{$key}]", $item, 'option' ); + if ( $ok ) { + $options[ $option ][ $key ] = $clean; + $applied++; + } else { + $skipped[] = "{$option}[{$key}]"; + } + } + } else { + list( $ok, $clean ) = sanitize_setting( $option, $value, 'option' ); + if ( $ok ) { + $options[ $option ] = $clean; + $applied++; + } else { + $skipped[] = $option; + } + } + } + + foreach ( (array) ( $section['theme_mods'] ?? array() ) as $key => $value ) { + $owned = false; + foreach ( $entry->theme_mods as $prefix ) { + $owned = $owned || 0 === strpos( (string) $key, $prefix ); + } + list( $ok, $clean ) = $owned ? sanitize_setting( $key, $value, 'theme_mod' ) : array( false, null ); + if ( $ok ) { + $mods[ $key ] = $clean; + $applied++; + } else { + $skipped[] = $key; + } + } + + $plan[ $name ] = array( + 'title' => $entry->plugin->title ?? $title, + 'status' => 'ready', + 'from' => (string) ( $section['version'] ?? '' ), + 'applied' => $applied, + 'skipped' => $skipped, + 'values' => array( 'options' => $options, 'theme_mods' => $mods ), + ); + } + return $plan; +} + +/** + * Write a planned import for the chosen plugins. Each plugin's design is + * replaced, not merged: the import makes it match the export. + */ +function apply( array $plan, array $names ) { + foreach ( $names as $name ) { + $entry = get( $name ); + $step = $plan[ $name ] ?? null; + if ( ! $entry || ! $step || 'ready' !== $step['status'] ) { + continue; + } + foreach ( $entry->options as $option ) { + $value = $step['values']['options'][ $option ] ?? null; + if ( null === $value || array() === $value ) { + delete_option( $option ); + } else { + update_option( $option, $value ); + } + } + foreach ( owned_theme_mods( $entry ) as $key => $unused ) { + remove_theme_mod( $key ); + } + foreach ( $step['values']['theme_mods'] as $key => $value ) { + set_theme_mod( $key, $value ); + } + do_action( 'tangible_design_settings_imported', $entry->plugin ); + } +} + +/** + * Remove every design value the plugin registered. + */ +function reset( $name ) { + $entry = get( $name ); + if ( ! $entry ) { + return; + } + foreach ( $entry->options as $option ) { + delete_option( $option ); + } + foreach ( owned_theme_mods( $entry ) as $key => $unused ) { + remove_theme_mod( $key ); + } + do_action( 'tangible_design_settings_reset', $entry->plugin ); +} + +require_once __DIR__ . '/tab.php'; diff --git a/design-settings/readme.md b/design-settings/readme.md new file mode 100644 index 0000000..749d040 --- /dev/null +++ b/design-settings/readme.md @@ -0,0 +1,84 @@ +# Design settings + +Reset, export and import for plugins whose design lives in the Customizer. +One module, so every Tangible design plugin gets the same **Design** tab and +an export from one site carries the whole suite's design to another. + +## Use + +```php +use tangible\design_settings; + +$plugin = \tangible\framework\register_plugin( $config ); + +design_settings\register( $plugin, array( + 'options' => array( 'h5pd_designer_options' ), // option names holding the design + 'theme_mods' => array(), // theme-mod key prefixes (legacy templates) + 'panel' => 'h5pd_designer_panel', // Customizer panel to deep-link + 'migrate' => null, // fn( array $section, string $from_version ): array +) ); + +\tangible\framework\register_plugin_settings( $plugin, array( + 'tabs' => array( + 'design' => design_settings\tab( $plugin ), + 'license' => array( … ), + ), +) ); +``` + +## The tab + +- **Open the Customizer**: links to the registered panel. +- **Export**: this plugin, or every registered design plugin on the site. + It downloads a JSON file. Only registered options and theme mods go in; + licences and other settings never do. +- **Import**: upload a file and review it before anything changes. The review + shows each plugin's applied and skipped values, and lets you untick plugins. + Applying *replaces* each chosen plugin's design: it doesn't merge. +- **Reset**: deletes the registered options and theme mods after a confirm + dialog. + +The capability is `edit_theme_options`, the same one the Customizer uses. In +network admin the tab only explains that design is per site. + +## Import safety + +Every value is matched to a registered Customizer setting +(`option[key]`, `option` or a theme mod) and run through that setting's own +`sanitize()` and `validate()`, using a `WP_Customize_Manager` with every +plugin's `customize_register` callbacks applied. There's no second allowlist to +drift out of date. Anything without a setting on this site (an inactive +template's keys, a retired key, a foreign option) is skipped and listed in the +review. A plugin's `migrate` callback receives the raw section first, so exports +from older versions are renamed into today's keys before sanitizing. + +## Format + +```json +{ + "format": "tangible-design-settings", + "version": 1, + "exported": "2026-10-03T12:00:00+00:00", + "site": "https://example.com", + "plugins": { + "design-upgrade-pro-h5p": { + "title": "Design Upgrade Pro for H5P", + "version": "1.6.0", + "options": { "h5pd_designer_options": { "btn_color_primary": "#002bc9" } }, + "theme_mods": {} + } + } +} +``` + +A preset is the same format shipped with a plugin, so presets can reuse +`plan()` and `apply()`. + +## Hooks + +- `tangible_design_settings_imported( $plugin )`: fires after a plugin's design is replaced. +- `tangible_design_settings_reset( $plugin )`: fires after a reset. + +Plugins that cache output built from their options should rebuild on these, or +on their options' own `update_option_*` and `delete_option_*` hooks, which fire +anyway. diff --git a/design-settings/tab.php b/design-settings/tab.php new file mode 100644 index 0000000..2d10cdf --- /dev/null +++ b/design-settings/tab.php @@ -0,0 +1,299 @@ +, and a + * nested