diff --git a/.travis.yml b/.travis.yml index 1dbd3883..3234f80e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,6 +13,8 @@ node_js: env: - WP_VERSION=trunk WP_MULTISITE=0 + - WP_VERSION=latest WP_MULTISITE=0 + - WP_VERSION=4.6.1 WP_MULTISITE=0 - WP_VERSION=latest WP_MULTISITE=1 install: diff --git a/composer.json b/composer.json index a0390de4..35ecb40d 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "xwp/wp-customize-snapshots", "description": "Allow Customizer states to be drafted, and previewed with a private URL.", - "version": "0.5.2", + "version": "0.6.0", "type": "wordpress-plugin", "homepage": "https://github.com/xwp/wp-customize-snapshots", "license": "GPL-2.0+", diff --git a/css/customize-snapshots-admin.css b/css/customize-snapshots-admin.css new file mode 100644 index 00000000..07d1468c --- /dev/null +++ b/css/customize-snapshots-admin.css @@ -0,0 +1,12 @@ +details.snapshot-setting-removed summary { + text-decoration: line-through; +} +details:not(.snapshot-setting-removed) .snapshot-toggle-setting-removal { + color: #a00 +} +details:not(.snapshot-setting-removed) .snapshot-toggle-setting-removal:hover { + color: #f00 +} +details .snapshot-toggle-setting-removal { + float: right; +} diff --git a/css/customize-snapshots.css b/css/customize-snapshots.css index 7b25b429..e98c6f3e 100644 --- a/css/customize-snapshots.css +++ b/css/customize-snapshots.css @@ -1,17 +1,18 @@ #snapshot-preview-link, -#snapshot-schedule-button { +#snapshot-expand-button { float: right; margin-top: 13px; margin-right: 4px; color: #656a6f; } -#snapshot-schedule-button { +#snapshot-expand-button { display: block; } -#snapshot-schedule-button:hover, -#snapshot-schedule-button:focus, -#snapshot-schedule-button:active { + +#snapshot-expand-button:hover, +#snapshot-expand-button:focus, +#snapshot-expand-button:active { color: #191e23; } @@ -64,7 +65,7 @@ } } -#snapshot-schedule { +#customize-snapshot { background: #fff !important; border-bottom: 1px solid #ddd; line-height: 1.5; @@ -72,28 +73,30 @@ top: 46px; position: absolute; width: 100%; - box-shadow: 0 5px 0 0 rgba(0,0,0,0.05); - padding:10px; + box-shadow: 0 5px 0 0 rgba(0, 0, 0, 0.05); + padding: 10px; box-sizing: border-box; } -#snapshot-schedule .snapshot-schedule-title { +#customize-snapshot .snapshot-schedule-title { color: #555; } -#snapshot-schedule .snapshot-schedule-title h3 { +#customize-snapshot .snapshot-schedule-title h3 { margin: .2em 2em .75em 0; } -#snapshot-schedule .snapshot-schedule-description { +#customize-snapshot .snapshot-schedule-description { margin-bottom: 0.5em; + margin-top: 0; } -#snapshot-schedule .timezone-info { + +#customize-snapshot .timezone-info { margin-top: 0.5em; font-size: smaller; } -#snapshot-schedule a.snapshot-edit-link { +#customize-snapshot a.snapshot-edit-link { position: absolute; top: 4px; right: 1px; @@ -109,12 +112,12 @@ padding: 10px; } -#snapshot-schedule a.snapshot-edit-link:focus, -#snapshot-schedule a.snapshot-edit-link:hover { +#customize-snapshot a.snapshot-edit-link:focus, +#customize-snapshot a.snapshot-edit-link:hover { color: #0073aa; } -#snapshot-schedule a.snapshot-edit-link:before { +#customize-snapshot a.snapshot-edit-link:before { padding: 4px; position: absolute; top: 5px; @@ -123,7 +126,7 @@ border-radius: 100%; } -#snapshot-schedule a.snapshot-edit-link:focus:before { +#customize-snapshot a.snapshot-edit-link:focus:before { -webkit-box-shadow: 0 0 0 1px #5b9dd9, 0 0 2px 1px rgba(30, 140, 190, .8); @@ -132,11 +135,11 @@ 0 0 2px 1px rgba(30, 140, 190, .8); } -#snapshot-schedule .accordion-section-title { +#customize-snapshot .accordion-section-title { padding: 10px; } -#snapshot-schedule .reset-time { +#customize-snapshot .reset-time { font-weight: normal; font-size: 80%; display: none; @@ -168,13 +171,13 @@ min-width: 4em; } -.snapshot-schedule-control input[type="number"]{ - width:100%; +.snapshot-schedule-control input[type="number"] { + width: 100%; } .snapshot-schedule-control .time-special-char { - padding-left:2px; - padding-right:2px; + padding-left: 2px; + padding-right: 2px; } .snapshot-schedule-control select { @@ -201,3 +204,147 @@ .select2-container { z-index: 500100 !important; } + +.snapshot-control input[type="text"] { + width: 100%; + line-height: 18px; + margin: 0; +} + +#customize-snapshot .snapshot-schedule-title h3 { + font-size: 16px; +} + +#customize-snapshot .snapshot-controls { + list-style: none; + margin: 0; +} + +.snapshot-future-date-notification.notice { + margin-bottom: 5px; + border-top: 1px solid #eee; + padding: 5px; +} + +/*Status Button*/ +/** @todo Need to add snapshot specific class for all ui classes. **/ + +#snapshot-status-button-wrapper { + float: right; + position: relative; + margin-top: 9px; + height: 28px; +} + +#snapshot-status-button-wrapper .button-primary { + margin-top: 0; + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Chrome/Safari/Opera */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; +} + +.snapshot-status-button-wrapper .button:active{ + -webkit-transform: none; + -moz-transform: none; + -ms-transform: none; + -o-transform: none; + transform: none; /* 4.7 added translateY(1px) */ +} + +.ui-selectmenu-menu { + padding: 0; + margin: 0; + position: absolute; + top: 0; + left: 0; + display: none; +} + +.ui-selectmenu-menu .ui-menu { + overflow: auto; + margin: 0; + margin-top: 2px; + overflow-x: hidden; + background: #FFFFFF; + border-top: 0; + border-radius: 0 0 5px 5px; + box-shadow: 0 1px 4px 0 rgba(33, 38, 34, .12), 0 2px 11px 0 rgba(30, 36, 37, .14) +} + +.ui-selectmenu-menu .ui-menu li { + padding: 5px 10px 5px 5px; + margin: 0; +} + +.ui-selectmenu-menu .ui-menu .ui-state-focus { + background: #0073aa; + color: #FFFFFF; + cursor: pointer; +} + +.ui-selectmenu-menu .ui-menu .ui-selectmenu-optgroup { + font-size: 1em; + font-weight: bold; + line-height: 1.5; + padding: 2px 0.4em; + margin: 0.5em 0 0 0; + height: auto; + border: 0; +} + +.ui-selectmenu-open { + display: block; +} + +#snapshot-status-button-wrapper .ui-selectmenu-button span.dashicons { + text-indent: 0; + float: right; + border-radius: 0 3px 3px 0; + padding-left: 0; + padding-right: 0; + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; + font-size: 20px; + width: 20px; +} + +#snapshot-status-button-wrapper .ui-selectmenu-button span.dashicons:before { + display: inline-block; + margin-left: -3px; +} + +@media screen and ( max-width: 640px ) { + #snapshot-status-button-wrapper .ui-selectmenu-button span.dashicons { + font-size: 17px; + } +} + +.ui-selectmenu-button span.ui-selectmenu-text { + border-radius: 3px 0 0 3px; + opacity: 0; +} + +.ui-selectmenu-menu.ui-front { + z-index: 999999; +} + +.snapshot-status-button-overlay.button { + width: calc( 100% - 20px ); + height: 28px; + position: absolute; + top: 0; + left: 0; + z-index: 1; + border-radius: 3px 0 0 3px; + box-shadow: none; +} + +#customize-header-actions .ui-selectmenu-button { + display: inline-block; + outline-width: 2px; + outline-offset: -2px; +} + diff --git a/customize-snapshots.php b/customize-snapshots.php index bf0946fd..ec921121 100644 --- a/customize-snapshots.php +++ b/customize-snapshots.php @@ -3,7 +3,7 @@ * Plugin Name: Customize Snapshots * Plugin URI: https://github.com/xwp/wp-customize-snapshots * Description: Allow Customizer states to be drafted, and previewed with a private URL. - * Version: 0.5.2 + * Version: 0.6.0-rc1 * Author: XWP * Author URI: https://xwp.co/ * License: GPLv2+ diff --git a/dev-lib b/dev-lib index 4eab31dd..85f9cf48 160000 --- a/dev-lib +++ b/dev-lib @@ -1 +1 @@ -Subproject commit 4eab31ddb538f57d8e147657f2fd34437aeda44f +Subproject commit 85f9cf48ec5d2fbdeb3eb26fcee9fc2383f36805 diff --git a/instance.php b/instance.php index d1c8fa65..350fa45c 100644 --- a/instance.php +++ b/instance.php @@ -33,7 +33,12 @@ function get_plugin_instance() { * @return bool Whether previewing settings. */ function is_previewing_settings() { - return get_plugin_instance()->customize_snapshot_manager->is_previewing_settings(); + $manager = get_plugin_instance()->customize_snapshot_manager; + if ( get_plugin_instance()->compat ) { + return $manager->is_previewing_settings(); + } else { + return ( isset( $manager->customize_manager ) && $manager->customize_manager->is_preview() ) || did_action( 'customize_preview_init' ); + } } /** @@ -51,3 +56,19 @@ function current_snapshot_uuid() { return $customize_snapshot_uuid; } } + +/** + * Returns whether it is back compat or not. + * + * @return bool is compat. + */ +function is_back_compat() { + $wp_version = get_bloginfo( 'version' ); + + // Fix in case version contains extra string for example 4.7-src in that case php version_compare fails. + $pos = strpos( $wp_version, '-' ); + if ( false !== $pos ) { + $wp_version = substr( $wp_version, 0, $pos ); + } + return version_compare( $wp_version, '4.7', '<' ); +} diff --git a/js/customize-snapshots-frontend.js b/js/compat/customize-snapshots-frontend.js similarity index 100% rename from js/customize-snapshots-frontend.js rename to js/compat/customize-snapshots-frontend.js diff --git a/js/customize-snapshots-preview.js b/js/compat/customize-snapshots-preview.js similarity index 99% rename from js/customize-snapshots-preview.js rename to js/compat/customize-snapshots-preview.js index 80a8f18e..78aa8fbc 100644 --- a/js/customize-snapshots-preview.js +++ b/js/compat/customize-snapshots-preview.js @@ -194,7 +194,7 @@ var CustomizeSnapshotsPreview = (function( api, $ ) { // Now preventDefault as is done on the normal submit.preview handler in customize-preview.js. event.preventDefault(); - }); + } ); }; return component; diff --git a/js/compat/customize-snapshots.js b/js/compat/customize-snapshots.js new file mode 100644 index 00000000..c519c74b --- /dev/null +++ b/js/compat/customize-snapshots.js @@ -0,0 +1,511 @@ +/* global jQuery, wp, _customizeSnapshotsCompatSettings */ +/* eslint consistent-this: ["error", "snapshot"] */ + +( function( api, $ ) { + 'use strict'; + + api.SnapshotsCompat = api.Snapshots.extend( { + + uuidParam: 'customize_snapshot_uuid', + + initialize: function initialize( snapshotsConfig ) { + var snapshot = this; + + if ( _.isObject( snapshotsConfig ) ) { + _.extend( snapshot.data, snapshotsConfig ); + } + + window._wpCustomizeControlsL10n.save = snapshot.data.i18n.publish; + window._wpCustomizeControlsL10n.saved = snapshot.data.i18n.published; + + api.bind( 'ready', function() { + api.state.create( 'snapshot-exists', snapshot.data.snapshotExists ); + snapshot.extendPreviewerQuery(); + + if ( api.state( 'snapshot-exists' ).get() ) { + api.state( 'saved' ).set( false ); + snapshot.resetSavedStateQuietly(); + } + } ); + + // Make sure that saved state is false so that Published button behaves as expected. + api.bind( 'save', function() { + api.state( 'saved' ).set( false ); + } ); + + api.bind( 'change', function() { + api.state( 'snapshot-saved' ).set( false ); + } ); + + api.bind( 'saved', function( response ) { + var url = window.location.href, + updatedUrl, + urlParts; + + // Update the UUID. + if ( response.new_customize_snapshot_uuid ) { + snapshot.data.uuid = response.new_customize_snapshot_uuid; + snapshot.previewLink.attr( 'target', snapshot.data.uuid ); + } + + api.state( 'snapshot-exists' ).set( false ); + + // Replace the history state with an updated Customizer URL that does not include the Snapshot UUID. + urlParts = url.split( '?' ); + if ( history.replaceState && urlParts[1] ) { + updatedUrl = urlParts[0] + '?' + _.filter( urlParts[1].split( '&' ), function( queryPair ) { + return ! /^(customize_snapshot_uuid)=/.test( queryPair ); + } ).join( '&' ); + updatedUrl = updatedUrl.replace( /\?$/, '' ); + if ( updatedUrl !== url ) { + history.replaceState( {}, document.title, updatedUrl ); + } + } + } ); + + api.Snapshots.prototype.initialize.call( snapshot, snapshotsConfig ); + }, + + /** + * Update button text. + * + * @returns {void} + */ + updateButtonText: function updateButtonText() { + var snapshot = this, date = snapshot.getDateFromInputs(); + if ( snapshot.isFutureDate() && date && snapshot.data.currentUserCanPublish ) { + snapshot.snapshotButton.text( snapshot.data.i18n.scheduleButton ); + } else { + snapshot.snapshotButton.text( api.state( 'snapshot-exists' ).get() ? snapshot.data.i18n.updateButton : snapshot.data.i18n.saveButton ); + } + }, + + /** + * Make the AJAX request to update/save a snapshot. + * + * @param {object} options Options. + * @param {string} options.status The post status for the snapshot. + * @return {void} + */ + sendUpdateSnapshotRequest: function sendUpdateSnapshotRequest( options ) { + var snapshot = this, + spinner = $( '#customize-header-actions' ).find( '.spinner' ), + request, data; + + data = _.extend( + { + status: 'draft' + }, + api.previewer.query(), + options, + { + nonce: api.settings.nonce.snapshot, + customize_snapshot_uuid: snapshot.data.uuid + } + ); + + request = wp.ajax.post( 'customize_update_snapshot', data ); + + spinner.addClass( 'is-active' ); + request.always( function( response ) { + spinner.removeClass( 'is-active' ); + if ( response.edit_link ) { + snapshot.data.editLink = response.edit_link; + } + if ( response.snapshot_publish_date ) { + snapshot.data.publishDate = response.snapshot_publish_date; + } + if ( response.title ) { + snapshot.data.title = response.title; + } + snapshot.updateSnapshotEditControls(); + snapshot.data.dirty = false; + + // @todo Remove privateness from _handleSettingValidities in Core. + if ( api._handleSettingValidities && response.setting_validities ) { + api._handleSettingValidities( { + settingValidities: response.setting_validities, + focusInvalidControl: true + } ); + } + } ); + + request.done( function( response ) { + var url = api.previewer.previewUrl(), + regex = new RegExp( '([?&])customize_snapshot_uuid=.*?(&|$)', 'i' ), + notFound = -1, + separator = url.indexOf( '?' ) !== notFound ? '&' : '?', + customizeUrl = window.location.href, + customizeSeparator = customizeUrl.indexOf( '?' ) !== notFound ? '&' : '?'; + + if ( url.match( regex ) ) { + url = url.replace( regex, '$1customize_snapshot_uuid=' + encodeURIComponent( snapshot.data.uuid ) + '$2' ); + } else { + url = url + separator + 'customize_snapshot_uuid=' + encodeURIComponent( snapshot.data.uuid ); + } + + // Change the save button text to update. + api.state( 'snapshot-exists' ).set( true ); + + // Replace the history state with an updated Customizer URL that includes the Snapshot UUID. + if ( history.replaceState && ! customizeUrl.match( regex ) ) { + customizeUrl += customizeSeparator + 'customize_snapshot_uuid=' + encodeURIComponent( snapshot.data.uuid ); + history.replaceState( {}, document.title, customizeUrl ); + } + + api.state( 'snapshot-saved' ).set( true ); + if ( 'pending' === data.status ) { + api.state( 'snapshot-submitted' ).set( true ); + } + snapshot.resetSavedStateQuietly(); + + // Trigger an event for plugins to use. + api.trigger( 'customize-snapshots-update', { + previewUrl: url, + customizeUrl: customizeUrl, + uuid: snapshot.data.uuid, + response: response + } ); + } ); + + request.fail( function( response ) { + var id = 'snapshot-dialog-error', + snapshotDialogShareError = wp.template( id ), + messages = snapshot.data.i18n.errorMsg, + invalidityCount = 0, + dialogElement; + + if ( response.setting_validities ) { + invalidityCount = _.size( response.setting_validities, function( validity ) { + return true !== validity; + } ); + } + + /* + * Short-circuit if there are setting validation errors, since the error messages + * will be displayed with the controls themselves. Eventually, once we have + * a global notification area in the Customizer, we can eliminate this + * short-circuit and instead display the messages in there. + * See https://core.trac.wordpress.org/ticket/35210 + */ + if ( invalidityCount > 0 ) { + return; + } + + if ( response.errors ) { + messages += ' ' + _.pluck( response.errors, 'message' ).join( ' ' ); + } + + // Insert the snapshot dialog error template. + dialogElement = $( '#' + id ); + if ( ! dialogElement.length ) { + dialogElement = $( snapshotDialogShareError( { + title: snapshot.data.i18n.errorTitle, + message: messages + } ) ); + $( 'body' ).append( dialogElement ); + } + + // Open the dialog. + dialogElement.dialog( { + autoOpen: true, + modal: true + } ); + } ); + + return request; + }, + + /** + * Amend the preview query so we can update the snapshot during `customize_save`. + * + * @return {void} + */ + extendPreviewerQuery: function extendPreviewerQuery() { + var snapshot = this, originalQuery = api.previewer.query; + + api.previewer.query = function() { + var retval = originalQuery.apply( this, arguments ); + if ( api.state( 'snapshot-exists' ).get() ) { + retval.customize_snapshot_uuid = snapshot.data.uuid; + if ( snapshot.snapshotTitle && snapshot.snapshotTitle.val() ) { + retval.title = snapshot.snapshotTitle.val(); + } + } + return retval; + }; + }, + + /** + * Create the snapshot buttons. + * + * @return {void} + */ + addButtons: function addButtons() { + var snapshot = this, + header = $( '#customize-header-actions' ), + templateData = {}, setPreviewLinkHref; + + snapshot.publishButton = header.find( '#save' ); + snapshot.spinner = header.find( '.spinner' ); + snapshot.dirtyScheduleDate = new api.Value(); + + // Save/update button. + if ( api.state( 'snapshot-exists' ).get() ) { + if ( 'future' === snapshot.data.postStatus ) { + templateData.buttonText = snapshot.data.i18n.scheduleButton; + } else { + templateData.buttonText = snapshot.data.i18n.updateButton; + } + } else { + templateData.buttonText = snapshot.data.i18n.saveButton; + } + + snapshot.snapshotButton = $( $.trim( wp.template( 'snapshot-save' )( templateData ) ) ); + + if ( ! snapshot.data.currentUserCanPublish ) { + snapshot.snapshotButton.attr( 'title', api.state( 'snapshot-exists' ).get() ? snapshot.data.i18n.permsMsg.update : snapshot.data.i18n.permsMsg.save ); + } + snapshot.snapshotButton.prop( 'disabled', true ); + + snapshot.snapshotButton.on( 'click', function( event ) { + var status; + event.preventDefault(); + status = snapshot.isFutureDate() ? 'future' : 'draft'; + + snapshot.snapshotButton.prop( 'disabled', true ); + snapshot.updateSnapshot( status ).done( function() { + snapshot.snapshotButton.prop( 'disabled', true ); + } ).fail( function() { + snapshot.snapshotButton.prop( 'disabled', false ); + } ); + } ); + + snapshot.snapshotButton.insertAfter( snapshot.publishButton ); + + // Preview link. + snapshot.previewLink = $( $.trim( wp.template( 'snapshot-preview-link' )() ) ); + snapshot.previewLink.toggle( api.state( 'snapshot-saved' ).get() ); + snapshot.previewLink.attr( 'target', snapshot.data.uuid ); + setPreviewLinkHref = _.debounce( function() { + if ( api.state( 'snapshot-exists' ).get() ) { + snapshot.previewLink.attr( 'href', snapshot.getSnapshotFrontendPreviewUrl() ); + } else { + snapshot.previewLink.attr( 'href', snapshot.frontendPreviewUrl.get() ); + } + } ); + snapshot.frontendPreviewUrl.bind( setPreviewLinkHref ); + setPreviewLinkHref(); + api.state.bind( 'change', setPreviewLinkHref ); + api.bind( 'saved', setPreviewLinkHref ); + snapshot.snapshotButton.after( snapshot.previewLink ); + api.state( 'snapshot-saved' ).bind( function( saved ) { + snapshot.previewLink.toggle( saved ); + } ); + + // Edit button. + snapshot.snapshotExpandButton = $( $.trim( wp.template( 'snapshot-expand-button' )( {} ) ) ); + snapshot.snapshotExpandButton.insertAfter( snapshot.snapshotButton ); + + if ( ! snapshot.data.editLink ) { + snapshot.snapshotExpandButton.hide(); + } + + api.state( 'change', function() { + snapshot.snapshotExpandButton.toggle( api.state( 'snapshot-saved' ).get() && api.state( 'snapshot-exists' ).get() ); + } ); + + api.state( 'snapshot-exists' ).bind( function( exist ) { + snapshot.snapshotExpandButton.toggle( exist ); + } ); + + api.state( 'snapshot-saved' ).bind( function( saved ) { + snapshot.snapshotButton.prop( 'disabled', saved ); + } ); + + api.state( 'saved' ).bind( function( saved ) { + if ( saved ) { + snapshot.snapshotButton.prop( 'disabled', true ); + } + } ); + api.bind( 'change', function() { + snapshot.snapshotButton.prop( 'disabled', false ); + } ); + + api.state( 'snapshot-exists' ).bind( function( exists ) { + var buttonText, permsMsg; + if ( exists ) { + buttonText = snapshot.data.i18n.updateButton; + permsMsg = snapshot.data.i18n.permsMsg.update; + } else { + buttonText = snapshot.data.i18n.saveButton; + permsMsg = snapshot.data.i18n.permsMsg.save; + } + + snapshot.snapshotButton.text( buttonText ); + if ( ! snapshot.data.currentUserCanPublish ) { + snapshot.snapshotButton.attr( 'title', permsMsg ); + } + } ); + + snapshot.editControlSettings.bind( function() { + snapshot.snapshotButton.prop( 'disabled', false ); + snapshot.updateButtonText(); + } ); + snapshot.dirtyScheduleDate.bind( function( dirty ) { + var date; + if ( dirty ) { + date = snapshot.getDateFromInputs(); + if ( ! date || ! snapshot.data.currentUserCanPublish ) { + return; + } + snapshot.snapshotButton.text( snapshot.data.i18n.scheduleButton ); + } else { + snapshot.updateButtonText(); + } + } ); + + // Submit for review button. + if ( ! snapshot.data.currentUserCanPublish ) { + snapshot.addSubmitButton(); + } + + header.addClass( 'button-added' ); + }, + + /** + * Silently update the saved state to be true without triggering the + * changed event so that the AYS beforeunload dialog won't appear + * if no settings have been changed after saving a snapshot. Note + * that it would be better if jQuery's callbacks allowed them to + * disabled and then re-enabled later, for example: + * wp.customize.state.topics.change.disable(); + * wp.customize.state( 'saved' ).set( true ); + * wp.customize.state.topics.change.enable(); + * But unfortunately there is no such enable method. + * + * @return {void} + */ + resetSavedStateQuietly: function resetSavedStateQuietly() { + api.state( 'saved' )._value = true; + }, + + /** + * Toggles date notification. + * + * @return {void}. + */ + toggleDateNotification: function showDateNotification() { + var snapshot = this; + if ( ! _.isEmpty( snapshot.dateNotification ) ) { + snapshot.dateNotification.toggle( ! snapshot.isFutureDate() ); + } + }, + + /** + * Overrides the autoSaveEditBox method used in api.Snapshots + * because we do not auto save in < 4.7. + * + * @inheritdoc + */ + autoSaveEditBox: function autoSaveEditor() { + + }, + + /** + * Renders snapshot schedule and handles it's events. + * + * @returns {void} + */ + editSnapshotUI: function editSnapshotUI() { + var snapshot = this; + api.Snapshots.prototype.editSnapshotUI.call( snapshot ); + + api.state( 'saved' ).bind( function( saved ) { + if ( saved && ! _.isEmpty( snapshot.editContainer ) ) { + snapshot.data.dirty = false; + snapshot.data.publishDate = snapshot.getCurrentTime(); + snapshot.snapshotEditContainerDisplayed.set( false ); + snapshot.updateSnapshotEditControls(); + } + } ); + }, + + /** + * Updates snapshot schedule with `snapshot.data`. + * + * @return {void} + */ + updateSnapshotEditControls: function updateSnapshotEditControls() { + var snapshot = this, parsed, + sliceBegin = 0, + sliceEnd = -2; + + if ( _.isEmpty( snapshot.editContainer ) ) { + return; + } + + if ( snapshot.data.currentUserCanPublish ) { + if ( '0000-00-00 00:00:00' === snapshot.data.publishDate ) { + snapshot.data.publishDate = snapshot.getCurrentTime(); + } + + // Normalize date with seconds removed. + snapshot.data.publishDate = snapshot.data.publishDate.slice( sliceBegin, sliceEnd ) + '00'; + parsed = snapshot.parseDateTime( snapshot.data.publishDate ); + + // Update date controls. + snapshot.schedule.inputs.each( function() { + var input = $( this ), + fieldName = input.data( 'date-input' ); + + $( this ).val( parsed[fieldName] ); + } ); + } + + snapshot.editContainer.find( 'a.snapshot-edit-link' ) + .attr( 'href', snapshot.data.editLink ) + .show(); + if ( ! _.isEmpty( snapshot.data.title ) ) { + snapshot.snapshotTitle.val( snapshot.data.title ); + } + snapshot.populateSetting(); + }, + + /** + * Populate setting value from the inputs. + * + * @returns {void} + */ + populateSetting: function populateSetting() { + var snapshot = this, + date = snapshot.getDateFromInputs(), + scheduled, isDirtyDate, editControlSettings; + + editControlSettings = _.extend( {}, snapshot.editControlSettings.get() ); + + if ( ! date || ! snapshot.data.currentUserCanPublish ) { + editControlSettings.title = snapshot.snapshotTitle.val(); + snapshot.editControlSettings.set( editControlSettings ); + return; + } + + date.setSeconds( 0 ); + scheduled = snapshot.formatDate( date ) !== snapshot.data.publishDate; + + isDirtyDate = scheduled && snapshot.isFutureDate(); + snapshot.dirtyScheduleDate.set( isDirtyDate ); + + editControlSettings.title = snapshot.snapshotTitle.val(); + editControlSettings.date = snapshot.formatDate( date ); + + snapshot.editControlSettings.set( editControlSettings ); + + snapshot.updateCountdown(); + snapshot.editContainer.find( '.reset-time' ).toggle( scheduled ); + } + } ); + + api.snapshotsCompat = new api.SnapshotsCompat( _customizeSnapshotsCompatSettings ); + +} )( wp.customize, jQuery ); diff --git a/js/customize-migrate.js b/js/customize-migrate.js new file mode 100644 index 00000000..aab0bcd9 --- /dev/null +++ b/js/customize-migrate.js @@ -0,0 +1,79 @@ +/* global jQuery, wp */ +(function( $ ) { + 'use strict'; + var component = { + doingAjax: false, + postMigrationCount: 20 + }; + + /** + * Initialize js. + * + * @return {void} + */ + component.init = function() { + $( function() { + component.el = $( '#customize-snapshot-migration' ); + component.bindClick(); + component.spinner = $( '.spinner.customize-snapshot-spinner' ); + component.spinner.css( 'margin', '0' ); + } ); + }; + + /** + * Bind migrate click event. + * + * @return {void} + */ + component.bindClick = function() { + component.el.click( function() { + if ( component.doingAjax ) { + return; + } + component.spinner.css( 'visibility', 'visible' ); + component.doingAjax = true; + component.migrate( component.el.data( 'nonce' ), component.postMigrationCount ); + } ); + }; + + /** + * Initiate migrate ajax request. + * + * @param {String} nonce Nonce. + * @param {Number} limit Limit for migrate posts. + * + * @return {void} + */ + component.migrate = function( nonce, limit ) { + var request, + requestData = { + nonce: nonce, + limit: limit + }; + + request = wp.ajax.post( 'customize_snapshot_migration', requestData ); + + request.done( function( data ) { + var outerDiv = $( 'div.customize-snapshot-migration' ), delay = 100, newLimit; + if ( data.remaining_posts ) { + newLimit = data.remaining_posts > limit ? limit : data.remaining_posts; + _.delay( component.migrate, delay, nonce, newLimit ); + } else { + component.spinner.css( 'visibility', 'hidden' ); + outerDiv.removeClass( 'notice-error' ).addClass( 'notice-success' ).find( 'p' ).html( component.el.data( 'migration-success' ) ); + component.doingAjax = false; + } + } ); + + request.fail( function() { + component.spinner.css( 'visibility', 'initial' ); + component.doingAjax = false; + if ( window.console ) { + window.console.error( 'Migration ajax failed. Click notice to start it again.' ); + } + } ); + }; + + component.init(); + +})( jQuery ); diff --git a/js/customize-snapshots-admin.js b/js/customize-snapshots-admin.js new file mode 100644 index 00000000..617e4671 --- /dev/null +++ b/js/customize-snapshots-admin.js @@ -0,0 +1,114 @@ +/* global jQuery */ +/* exported CustomizeSnapshotsAdmin */ +var CustomizeSnapshotsAdmin = (function( $ ) { + 'use strict'; + var component = {}; + + component.data = { + deleteInputName: 'customize_snapshot_remove_settings[]' + }; + + /** + * Initialize component. + * + * @param {object} args Args. + * @return {void} + */ + component.init = function( args ) { + component.data = _.extend( component.data, args ); + $( function() { + component.deleteSetting(); + } ); + }; + + /** + * Handles snapshot setting delete actions. + * + * @return {void} + */ + component.deleteSetting = function() { + var $linkToRemoveOrRestore = $( '.snapshot-toggle-setting-removal' ), + linkActions = ['remove', 'restore'], + dataSlug = 'cs-action'; + + $linkToRemoveOrRestore.data( dataSlug, linkActions[0] ); + + component.isLinkSetToRemoveSetting = function( $link ) { + return linkActions[ 0 ] === component.getClickedLinkAction( $link ); + }; + + component.isLinkSetToRestoreSetting = function( $link ) { + return linkActions[ 1 ] === component.getClickedLinkAction( $link ); + }; + + component.getClickedLinkAction = function( $link ) { + return $link.data( dataSlug ); + }; + + component.hideSettingAndChangeLinkText = function( $link ) { + var $settingDisplay, settingId; + $settingDisplay = component.getSettingDisplay( $link ); + settingId = component.getSettingId( $link ); + + $link.data( dataSlug, linkActions[ 1 ] ) + .after( component.constructHiddenInputWithValue( settingId ) ); + component.changeLinkText( $link ); + $settingDisplay.removeAttr( 'open' ) + .addClass( 'snapshot-setting-removed' ); + }; + + component.getSettingDisplay = function( $link ) { + return $link.parents( 'details' ); + }; + + component.getSettingId = function( $link ) { + return $link.attr( 'id' ); + }; + + component.constructHiddenInputWithValue = function( settingId ) { + return $( '' ).attr( { + 'name': component.data.deleteInputName, + 'type': 'hidden' + } ) + .val( settingId ); + }; + + component.changeLinkText = function( $link ) { + var oldLinkText, newLinkText; + oldLinkText = $link.text(); + newLinkText = $link.data( 'text-restore' ); + + $link.data( 'text-restore', oldLinkText ) + .text( newLinkText ); + }; + + component.showSettingAndChangeLinkText = function( $link ) { + var $settingDisplay, settingId; + $settingDisplay = component.getSettingDisplay( $link ); + settingId = component.getSettingId( $link ); + + $link.data( dataSlug, linkActions[ 0 ] ); + component.changeLinkText( $link ); + component.removeHiddenInputWithValue( settingId ); + $settingDisplay.removeClass( 'snapshot-setting-removed' ); + }; + + component.removeHiddenInputWithValue = function( settingId ) { + $( 'input[name="' + component.data.deleteInputName + '"][value="' + settingId + '"]' ).remove(); + }; + + $linkToRemoveOrRestore.on( 'click', function( event ) { + var $clickedLink = $( this ); + + event.preventDefault(); + + if ( component.isLinkSetToRemoveSetting( $clickedLink ) ) { + component.hideSettingAndChangeLinkText( $clickedLink ); + } else if ( component.isLinkSetToRestoreSetting( $clickedLink ) ) { + component.showSettingAndChangeLinkText( $clickedLink ); + } + } ); + }; + + return component; +})( jQuery ); diff --git a/js/customize-snapshots.js b/js/customize-snapshots.js index c60a4d30..3b5e9fba 100644 --- a/js/customize-snapshots.js +++ b/js/customize-snapshots.js @@ -1,850 +1,1125 @@ -/* global jQuery, _customizeSnapshots */ +/* global jQuery, wp, _customizeSnapshotsSettings */ +/* eslint no-magic-numbers: [ "error", { "ignore": [0,1,-1] } ], consistent-this: [ "error", "snapshot" ] */ -( function( api, $ ) { +(function( api, $ ) { 'use strict'; - var component, escKeyCode = 27; + var escKeyCode = 27; - if ( ! api.Snapshots ) { - api.Snapshots = {}; - } + api.Snapshots = api.Class.extend( { - component = api.Snapshots; - - component.schedule = {}; - - component.data = { - action: '', - uuid: '', - editLink: '', - publishDate: '', - postStatus: '', - currentUserCanPublish: true, - initialServerDate: '', - initialServerTimestamp: 0, - initialClientTimestamp: 0, - i18n: {}, - dirty: false - }; - - if ( 'undefined' !== typeof _customizeSnapshots ) { - _.extend( component.data, _customizeSnapshots ); - } + data: { + action: '', + uuid: '', + editLink: '', + title: '', + publishDate: '', + postStatus: '', + currentUserCanPublish: true, + initialServerDate: '', + initialServerTimestamp: 0, + initialClientTimestamp: 0, + i18n: {}, + dirty: false + }, - /** - * Inject the functionality. - * - * @return {void} - */ - component.init = function() { - window._wpCustomizeControlsL10n.save = component.data.i18n.publish; - window._wpCustomizeControlsL10n.saved = component.data.i18n.published; - - // Set the initial client timestamp. - component.data.initialClientTimestamp = component.dateValueOf(); - - api.bind( 'ready', function() { - api.state.create( 'snapshot-exists', component.data.snapshotExists ); - api.state.create( 'snapshot-saved', true ); - api.state.create( 'snapshot-submitted', true ); - api.bind( 'change', function() { - api.state( 'snapshot-saved' ).set( false ); - api.state( 'snapshot-submitted' ).set( false ); - } ); - component.frontendPreviewUrl = new api.Value( api.previewer.previewUrl.get() ); - component.frontendPreviewUrl.link( api.previewer.previewUrl ); + uuidParam: 'customize_changeset_uuid', - component.extendPreviewerQuery(); - component.addButtons(); - if ( component.data.currentUserCanPublish ) { - component.addSchedule(); + initialize: function initialize( snapshotsConfig ) { + var snapshot = this; + + snapshot.schedule = {}; + + if ( _.isObject( snapshotsConfig ) ) { + _.extend( snapshot.data, snapshotsConfig ); } - $( '#snapshot-save' ).on( 'click', function( event ) { - var scheduleDate; - event.preventDefault(); - if ( ! _.isEmpty( component.schedule.container ) && component.isFutureDate() ) { - scheduleDate = component.getDateFromInputs(); - component.sendUpdateSnapshotRequest( { - status: 'future', - publish_date: component.formatDate( scheduleDate ) - } ); - } else { - component.sendUpdateSnapshotRequest( { status: 'draft' } ); + // Set the initial client timestamp. + snapshot.data.initialClientTimestamp = snapshot.dateValueOf(); + + api.bind( 'ready', function() { + api.state.create( 'snapshot-exists', false ); + api.state.create( 'snapshot-saved', true ); + api.state.create( 'snapshot-submitted', true ); + + snapshot.data.uuid = snapshot.data.uuid || api.settings.changeset.uuid; + snapshot.data.title = snapshot.data.title || snapshot.data.uuid; + + if ( api.state.has( 'changesetStatus' ) && api.state( 'changesetStatus' ).get() ) { + api.state( 'snapshot-exists' ).set( true ); } + + snapshot.editControlSettings = new api.Value( { + title: snapshot.data.title, + date: snapshot.data.publishDate + } ); + + api.bind( 'change', function() { + api.state( 'snapshot-submitted' ).set( false ); + } ); + + snapshot.frontendPreviewUrl = new api.Value( api.previewer.previewUrl.get() ); + snapshot.frontendPreviewUrl.link( api.previewer.previewUrl ); + + snapshot.addButtons(); + snapshot.editSnapshotUI(); + snapshot.prefilterAjax(); + + api.trigger( 'snapshots-ready', snapshot ); } ); - $( '#snapshot-submit' ).on( 'click', function( event ) { - event.preventDefault(); - component.sendUpdateSnapshotRequest( { status: 'pending' } ); - } ); - if ( api.state( 'snapshot-exists' ).get() ) { - api.state( 'saved' ).set( false ); - component.resetSavedStateQuietly(); + api.bind( 'save', function( request ) { + + request.fail( function( response ) { + var id = '#snapshot-dialog-error', + snapshotDialogPublishError = wp.template( 'snapshot-dialog-error' ); + + if ( response.responseText ) { + + // Insert the dialog error template. + if ( 0 === $( id ).length ) { + $( 'body' ).append( snapshotDialogPublishError( { + title: snapshot.data.i18n.publish, + message: api.state( 'snapshot-exists' ).get() ? snapshot.data.i18n.permsMsg.update : snapshot.data.i18n.permsMsg.save + } ) ); + } + + snapshot.spinner.removeClass( 'is-active' ); + + $( id ).dialog( { + autoOpen: true, + modal: true + } ); + } + } ); + + return request; + } ); + }, + + /** + * Update snapshot. + * + * @param {string} status post status. + * @returns {jQuery.promise} Request or promise. + */ + updateSnapshot: function updateSnapshot( status ) { + var snapshot = this, inputDate, + deferred = new $.Deferred(), + request, + requestData = { + status: status + }; + + if ( snapshot.statusButton && snapshot.statusButton.needConfirm ) { + snapshot.statusButton.disbleButton.set( false ); + snapshot.statusButton.updateButtonText( 'confirm-text' ); + snapshot.statusButton.needConfirm = false; + return deferred.promise(); } - api.trigger( 'snapshots-ready', component ); - } ); + if ( snapshot.snapshotTitle && snapshot.snapshotTitle.val() && 'publish' !== status ) { + requestData.title = snapshot.editControlSettings.get().title; + } - api.bind( 'save', function( request ) { + if ( ! _.isEmpty( snapshot.editContainer ) && snapshot.isFutureDate() && 'publish' !== status ) { + inputDate = snapshot.getDateFromInputs(); + requestData.date = snapshot.formatDate( inputDate ); + } - // Make sure that saved state is false so that Published button behaves as expected. - api.state( 'saved' ).set( false ); + if ( 'future' === status ) { + if ( requestData.date ) { + request = snapshot.sendUpdateSnapshotRequest( requestData ); + } + } else { + request = snapshot.sendUpdateSnapshotRequest( requestData ); + } - request.fail( function( response ) { - var id = 'snapshot-dialog-error', - snapshotDialogPublishError = wp.template( id ); + return request ? request : deferred.promise(); + }, + + /** + * Make the AJAX request to update/save a snapshot. + * + * @param {object} options Options. + * @param {string} options.status The post status for the snapshot. + * @return {object} request. + */ + sendUpdateSnapshotRequest: function sendUpdateSnapshotRequest( options ) { + var snapshot = this, + request, data, publishStatus; + + data = _.extend( + { + status: 'draft' + }, + options + ); + + api.state( 'snapshot-saved' ).set( false ); + snapshot.statusButton.disable( true ); + snapshot.spinner.addClass( 'is-active' ); + + request = api.previewer.save( data ); + + publishStatus = 'publish' === data.status; + + request.always( function( response ) { + snapshot.spinner.removeClass( 'is-active' ); + if ( response.edit_link ) { + snapshot.data.editLink = response.edit_link; + } + if ( response.publish_date ) { + snapshot.data.publishDate = response.publish_date; + } + if ( response.title ) { + snapshot.data.title = response.title; + } - if ( response.responseText ) { + snapshot.data.dirty = false; + } ); - // Insert the dialog error template. - if ( 0 === $( '#' + id ).length ) { - $( 'body' ).append( snapshotDialogPublishError( { - title: component.data.i18n.publish, - message: api.state( 'snapshot-exists' ).get() ? component.data.i18n.permsMsg.update : component.data.i18n.permsMsg.save - } ) ); + request.done( function( response ) { + var url = api.previewer.previewUrl(), + customizeUrl = window.location.href, + savedDelay = 400; + + /*** + * Delay because api.Posts.updateSettingsQuietly updates the settings after save, which triggers + * api change causing the publish button to get enabled again. + */ + _.delay( function() { + api.state( 'snapshot-saved' ).set( true ); + if ( 'pending' === data.status ) { + api.state( 'snapshot-submitted' ).set( true ); } + }, savedDelay ); + + api.state( 'snapshot-exists' ).set( true ); + + snapshot.statusButton.disableSelect.set( publishStatus ); + snapshot.statusButton.disbleButton.set( true ); + snapshot.snapshotExpandButton.toggle( ! publishStatus ); + snapshot.previewLink.toggle( ! publishStatus ); + + snapshot.statusButton.updateButtonText( 'alt-text' ); - $( '#customize-header-actions .spinner' ).removeClass( 'is-active' ); + // Trigger an event for plugins to use. + api.trigger( 'customize-snapshots-update', { + previewUrl: url, + customizeUrl: customizeUrl, + uuid: snapshot.data.uuid, + response: response + } ); + } ); + + request.fail( function( response ) { + var id = '#snapshot-dialog-error', + snapshotDialogShareError = wp.template( 'snapshot-dialog-error' ), + messages = snapshot.data.i18n.errorMsg, + invalidityCount = 0, + dialogElement; - // Open the dialog. - $( '#' + id ).dialog( { - autoOpen: true, - modal: true + snapshot.statusButton.disableSelect.set( false ); + + if ( response.setting_validities ) { + invalidityCount = _.size( response.setting_validities, function( validity ) { + return true !== validity; } ); } - } ); - return request; - } ); - api.bind( 'saved', function( response ) { - var url = window.location.href, - updatedUrl, - urlParts; + /* + * Short-circuit if there are setting validation errors, since the error messages + * will be displayed with the controls themselves. Eventually, once we have + * a global notification area in the Customizer, we can eliminate this + * short-circuit and instead display the messages in there. + * See https://core.trac.wordpress.org/ticket/35210 + */ + if ( invalidityCount > 0 ) { + return; + } - // Update the UUID. - if ( response.new_customize_snapshot_uuid ) { - component.data.uuid = response.new_customize_snapshot_uuid; - component.previewLink.attr( 'target', component.data.uuid ); - } - if ( response.edit_link ) { - component.data.editLink = response.edit_link; - } + if ( response.errors ) { + messages += ' ' + _.pluck( response.errors, 'message' ).join( ' ' ); + } - api.state( 'snapshot-exists' ).set( false ); + // Insert the snapshot dialog error template. + dialogElement = $( id ); + if ( ! dialogElement.length ) { + dialogElement = $( snapshotDialogShareError( { + title: snapshot.data.i18n.errorTitle, + message: messages + } ) ); + $( 'body' ).append( dialogElement ); + } + + // Open the dialog. + $( id ).dialog( { + autoOpen: true, + modal: true + } ); + } ); - // Replace the history state with an updated Customizer URL that does not include the Snapshot UUID. - urlParts = url.split( '?' ); - if ( history.replaceState && urlParts[1] ) { - updatedUrl = urlParts[0] + '?' + _.filter( urlParts[1].split( '&' ), function( queryPair ) { - return ! /^(customize_snapshot_uuid)=/.test( queryPair ); - } ).join( '&' ); - updatedUrl = updatedUrl.replace( /\?$/, '' ); - if ( updatedUrl !== url ) { - history.replaceState( {}, document.title, updatedUrl ); + return request; + }, + + /** + * Create the snapshot buttons. + * + * @return {void} + */ + addButtons: function addButtons() { + var snapshot = this, setPreviewLinkHref; + + snapshot.spinner = $( '#customize-header-actions' ).find( '.spinner' ); + snapshot.publishButton = $( '#save' ); + + snapshot.publishButton.addClass( 'hidden' ); + snapshot.statusButton = snapshot.addStatusButton(); + snapshot.statusButton.disbleButton.set( true ); + + if ( api.state( 'changesetStatus' ).get() ) { + if ( 'auto-draft' === api.state( 'changesetStatus' ).get() ) { + snapshot.statusButton.disable( false ); + } else { + snapshot.statusButton.updateButtonText( 'alt-text' ); } - } - } ); - }; - - /** - * Amend the preview query so we can update the snapshot during `customize_save`. - * - * @return {void} - */ - component.extendPreviewerQuery = function() { - var originalQuery = api.previewer.query; - - api.previewer.query = function() { - var retval = originalQuery.apply( this, arguments ); - if ( api.state( 'snapshot-exists' ).get() ) { - retval.customize_snapshot_uuid = component.data.uuid; - } - return retval; - }; - }; - - /** - * Get the preview URL with the snapshot UUID attached. - * - * @returns {string} URL. - */ - component.getSnapshotFrontendPreviewUrl = function getSnapshotFrontendPreviewUrl() { - var a = document.createElement( 'a' ); - a.href = component.frontendPreviewUrl.get(); - if ( a.search ) { - a.search += '&'; - } - a.search += 'customize_snapshot_uuid=' + component.data.uuid; - return a.href; - }; - - /** - * Create the snapshot buttons. - * - * @return {void} - */ - component.addButtons = function() { - var header = $( '#customize-header-actions' ), - publishButton = header.find( '#save' ), - snapshotButton, scheduleButton, submitButton, data, setPreviewLinkHref, snapshotButtonText; - - // Save/update button. - snapshotButton = wp.template( 'snapshot-save' ); - if ( api.state( 'snapshot-exists' ).get() ) { - if ( 'future' === component.data.postStatus ) { - snapshotButtonText = component.data.i18n.scheduleButton; } else { - snapshotButtonText = component.data.i18n.updateButton; + snapshot.statusButton.disable( true ); } - } else { - snapshotButtonText = component.data.i18n.saveButton; - } - data = { - buttonText: snapshotButtonText - }; - snapshotButton = $( $.trim( snapshotButton( data ) ) ); - if ( ! component.data.currentUserCanPublish ) { - snapshotButton.attr( 'title', api.state( 'snapshot-exists' ).get() ? component.data.i18n.permsMsg.update : component.data.i18n.permsMsg.save ); - } - snapshotButton.prop( 'disabled', true ); - snapshotButton.insertAfter( publishButton ); - // Schedule button. - if ( component.data.currentUserCanPublish ) { - scheduleButton = wp.template( 'snapshot-schedule-button' ); - scheduleButton = $( $.trim( scheduleButton( {} ) ) ); - scheduleButton.insertAfter( snapshotButton ); + // Preview link. + snapshot.previewLink = $( $.trim( wp.template( 'snapshot-preview-link' )() ) ); + snapshot.previewLink.toggle( api.state( 'snapshot-saved' ).get() ); + snapshot.previewLink.attr( 'target', snapshot.data.uuid ); + setPreviewLinkHref = _.debounce( function() { + if ( api.state( 'snapshot-exists' ).get() ) { + snapshot.previewLink.attr( 'href', snapshot.getSnapshotFrontendPreviewUrl() ); + } else { + snapshot.previewLink.attr( 'href', snapshot.frontendPreviewUrl.get() ); + } + } ); + snapshot.frontendPreviewUrl.bind( setPreviewLinkHref ); + setPreviewLinkHref(); + api.state.bind( 'change', setPreviewLinkHref ); + api.bind( 'saved', setPreviewLinkHref ); + snapshot.statusButton.container.after( snapshot.previewLink ); + api.state( 'snapshot-saved' ).bind( function( saved ) { + snapshot.previewLink.toggle( saved ); + } ); - if ( ! component.data.editLink ) { - scheduleButton.hide(); + // Edit button. + snapshot.snapshotExpandButton = $( $.trim( wp.template( 'snapshot-expand-button' )( {} ) ) ); + snapshot.statusButton.container.after( snapshot.snapshotExpandButton ); + + if ( ! snapshot.data.editLink ) { + snapshot.snapshotExpandButton.hide(); + snapshot.previewLink.hide(); } api.state( 'change', function() { - scheduleButton.toggle( api.state( 'snapshot-saved' ).get() && api.state( 'snapshot-exists' ).get() ); + snapshot.snapshotExpandButton.toggle( api.state( 'snapshot-saved' ).get() && api.state( 'snapshot-exists' ).get() ); } ); api.state( 'snapshot-exists' ).bind( function( exist ) { - scheduleButton.toggle( exist ); + snapshot.snapshotExpandButton.toggle( exist ); + snapshot.previewLink.toggle( exist ); } ); - } - api.state( 'snapshot-saved' ).bind( function( saved ) { - snapshotButton.prop( 'disabled', saved ); - } ); + api.bind( 'change', function() { + if ( api.state( 'snapshot-saved' ).get() ) { + snapshot.statusButton.disable( false ); + if ( snapshot.statusButton.button.data( 'confirm-text' ) !== snapshot.statusButton.buttonText.get() ) { + snapshot.statusButton.updateButtonText( 'button-text' ); + } + if ( snapshot.submitButton ) { + snapshot.submitButton.prop( 'disabled', false ); + } + if ( snapshot.saveButton ) { + snapshot.saveButton.prop( 'disabled', false ); + } + api.state( 'snapshot-saved' ).set( false ); + } + } ); - api.state( 'saved' ).bind( function( saved ) { - if ( saved ) { - snapshotButton.prop( 'disabled', true ); - } - } ); - api.bind( 'change', function() { - snapshotButton.prop( 'disabled', false ); - } ); - - api.state( 'snapshot-exists' ).bind( function( exists ) { - var buttonText, permsMsg; - if ( exists ) { - buttonText = component.data.i18n.updateButton; - permsMsg = component.data.i18n.permsMsg.update; - } else { - buttonText = component.data.i18n.saveButton; - permsMsg = component.data.i18n.permsMsg.save; + if ( ! snapshot.data.currentUserCanPublish ) { + snapshot.addSubmitButton(); + snapshot.addSaveButton(); } + }, - snapshotButton.text( buttonText ); - if ( ! component.data.currentUserCanPublish ) { - snapshotButton.attr( 'title', permsMsg ); - } - } ); - - // Preview link. - component.previewLink = $( $.trim( wp.template( 'snapshot-preview-link' )() ) ); - component.previewLink.toggle( api.state( 'snapshot-saved' ).get() ); - component.previewLink.attr( 'target', component.data.uuid ); - setPreviewLinkHref = _.debounce( function() { - if ( api.state( 'snapshot-exists' ).get() ) { - component.previewLink.attr( 'href', component.getSnapshotFrontendPreviewUrl() ); + /** + * Adds Submit Button when user does not have 'customize_publish' permission. + * + * @return {void} + */ + addSubmitButton: function() { + var snapshot = this, disableSubmitButton; + + disableSubmitButton = 'pending' === snapshot.data.postStatus || ! api.state( 'snapshot-exists' ).get(); + + if ( snapshot.statusButton ) { + snapshot.statusButton.container.hide(); } else { - component.previewLink.attr( 'href', component.frontendPreviewUrl.get() ); + snapshot.publishButton.hide(); } - } ); - component.frontendPreviewUrl.bind( setPreviewLinkHref ); - setPreviewLinkHref(); - api.state.bind( 'change', setPreviewLinkHref ); - api.bind( 'saved', setPreviewLinkHref ); - snapshotButton.after( component.previewLink ); - api.state( 'snapshot-saved' ).bind( function( saved ) { - component.previewLink.toggle( saved ); - } ); - - // Submit for review button. - if ( ! component.data.currentUserCanPublish ) { - publishButton.hide(); - submitButton = wp.template( 'snapshot-submit' ); - submitButton = $( $.trim( submitButton( { - buttonText: component.data.i18n.submit + + snapshot.submitButton = $( $.trim( wp.template( 'snapshot-submit' )( { + buttonText: snapshot.data.i18n.submit } ) ) ); - submitButton.prop( 'disabled', ! api.state( 'snapshot-exists' ).get() ); - submitButton.insertBefore( snapshotButton ); + + snapshot.submitButton.prop( 'disabled', disableSubmitButton ); + snapshot.submitButton.insertBefore( snapshot.publishButton ); api.state( 'snapshot-submitted' ).bind( function( submitted ) { - submitButton.prop( 'disabled', submitted ); + snapshot.submitButton.prop( 'disabled', submitted ); } ); - } - header.addClass( 'button-added' ); - }; + snapshot.submitButton.on( 'click', function( event ) { + event.preventDefault(); + snapshot.submitButton.prop( 'disabled', true ); + if ( snapshot.saveButton ) { + snapshot.saveButton.prop( 'disabled', true ); + } + snapshot.updateSnapshot( 'pending' ).fail( function() { + snapshot.submitButton.prop( 'disabled', false ); + } ); + } ); - /** - * Renders snapshot schedule and handles it's events. - * - * @returns {void} - */ - component.addSchedule = function addSchedule() { - var sliceBegin = 0, - sliceEnd = -2, - scheduleButton = $( '#snapshot-schedule-button' ); + snapshot.editControlSettings.bind( function() { + if ( api.state( 'snapshot-saved' ).get() ) { + snapshot.submitButton.prop( 'disabled', false ); + } + } ); + }, - component.scheduleContainerDisplayed = new api.Value(); + /** + * Adds Save Button when user does not have 'customize_publish' permission. + * + * @return {void} + */ + addSaveButton: function() { + var snapshot = this, disableSaveButton, isSaved; - if ( ! component.data.currentUserCanPublish ) { - return; - } + isSaved = _.contains( [ 'future', 'pending', 'draft' ], api.state( 'changesetStatus' ).get() ); + disableSaveButton = isSaved || ! api.state( 'snapshot-exists' ).get(); + + snapshot.saveButton = $( $.trim( wp.template( 'snapshot-save' )( { + buttonText: isSaved ? snapshot.data.i18n.updateButton : snapshot.data.i18n.saveButton + } ) ) ); + + snapshot.saveButton.prop( 'disabled', disableSaveButton ); + snapshot.saveButton.insertBefore( snapshot.publishButton ); + + api.state( 'snapshot-submitted' ).bind( function( submitted ) { + if ( submitted ) { + snapshot.saveButton.prop( 'disabled', true ); + } + } ); + + snapshot.saveButton.on( 'click', function( event ) { + event.preventDefault(); + snapshot.saveButton.prop( 'disabled', true ); + snapshot.submitButton.prop( 'disabled', true ); + snapshot.updateSnapshot( 'draft' ).done( function() { + snapshot.saveButton.prop( 'disabled', true ); + snapshot.submitButton.prop( 'disabled', false ); + snapshot.saveButton.text( snapshot.data.i18n.updateButton ); + } ).fail( function() { + snapshot.saveButton.prop( 'disabled', false ); + snapshot.submitButton.prop( 'disabled', false ); + } ); + } ); + + snapshot.editControlSettings.bind( function() { + if ( api.state( 'snapshot-saved' ).get() ) { + snapshot.saveButton.prop( 'disabled', false ); + } + } ); + }, + + /** + * Renders snapshot schedule and handles it's events. + * + * @returns {void} + */ + editSnapshotUI: function editSnapshotUI() { + var snapshot = this, sliceBegin = 0, + sliceEnd = -2, updateUI; + + snapshot.snapshotEditContainerDisplayed = new api.Value( false ); + + updateUI = function() { + snapshot.populateSetting(); + }; + + // Inject the UI. + if ( _.isEmpty( snapshot.editContainer ) ) { + if ( '0000-00-00 00:00:00' === snapshot.data.publishDate ) { + snapshot.data.publishDate = snapshot.getCurrentTime(); + } - // Inject the UI. - if ( _.isEmpty( component.schedule.container ) ) { - if ( '0000-00-00 00:00:00' === component.data.publishDate ) { - component.data.publishDate = component.getCurrentTime(); + // Normalize date with secs set as zeros removed. + snapshot.data.publishDate = snapshot.data.publishDate.slice( sliceBegin, sliceEnd ) + '00'; + + // Extend the snapshots data object and add the parsed datetime strings. + snapshot.data = _.extend( snapshot.data, snapshot.parseDateTime( snapshot.data.publishDate ) ); + + // Add the template to the DOM. + snapshot.editContainer = $( $.trim( wp.template( 'snapshot-edit-container' )( snapshot.data ) ) ); + snapshot.editContainer.hide().appendTo( $( '#customize-header-actions' ) ); + snapshot.dateNotification = snapshot.editContainer.find( '.snapshot-future-date-notification' ); + snapshot.countdown = snapshot.editContainer.find( '.snapshot-scheduled-countdown' ); + + if ( snapshot.data.currentUserCanPublish ) { + + // Store the date inputs. + snapshot.schedule.inputs = snapshot.editContainer.find( '.date-input' ); + + snapshot.schedule.inputs.on( 'input', updateUI ); + + snapshot.schedule.inputs.on( 'blur', function() { + snapshot.populateInputs(); + updateUI(); + } ); + + snapshot.updateCountdown(); + + snapshot.editContainer.find( '.reset-time a' ).on( 'click', function( event ) { + event.preventDefault(); + snapshot.updateSnapshotEditControls(); + } ); + } + + if ( snapshot.statusButton && 'future' !== snapshot.statusButton.value.get() ) { + snapshot.countdown.hide(); + } + + snapshot.snapshotTitle = snapshot.editContainer.find( '#snapshot-title' ); + snapshot.snapshotTitle.on( 'input', updateUI ); } - // Normalize date with secs set as zeros removed. - component.data.publishDate = component.data.publishDate.slice( sliceBegin, sliceEnd ) + '00'; + // Set up toggling of the schedule container. + snapshot.snapshotEditContainerDisplayed.bind( function( isDisplayed ) { + if ( isDisplayed ) { + snapshot.editContainer.stop().slideDown( 'fast' ).attr( 'aria-expanded', 'true' ); + snapshot.snapshotExpandButton.attr( 'aria-pressed', 'true' ); + snapshot.snapshotExpandButton.prop( 'title', snapshot.data.i18n.collapseSnapshotScheduling ); + snapshot.toggleDateNotification(); + } else { + snapshot.editContainer.stop().slideUp( 'fast' ).attr( 'aria-expanded', 'false' ); + snapshot.snapshotExpandButton.attr( 'aria-pressed', 'false' ); + snapshot.snapshotExpandButton.prop( 'title', snapshot.data.i18n.expandSnapshotScheduling ); + } + } ); - // Extend the components data object and add the parsed datetime strings. - component.data = _.extend( component.data, component.parseDateTime( component.data.publishDate ) ); + snapshot.editControlSettings.bind( function() { + snapshot.toggleDateNotification(); + } ); - // Add the template to the DOM. - component.schedule.container = $( $.trim( wp.template( 'snapshot-schedule' )( component.data ) ) ); - component.schedule.container.hide().appendTo( $( '#customize-header-actions' ) ); + // Toggle schedule container when clicking the button. + snapshot.snapshotExpandButton.on( 'click', function( event ) { + event.preventDefault(); + snapshot.snapshotEditContainerDisplayed.set( ! snapshot.snapshotEditContainerDisplayed.get() ); + } ); - // Store the date inputs. - component.schedule.inputs = component.schedule.container.find( '.date-input' ); + // Collapse the schedule container when Esc is pressed while the button is focused. + snapshot.snapshotExpandButton.on( 'keydown', function( event ) { + if ( escKeyCode === event.which && snapshot.snapshotEditContainerDisplayed.get() ) { + event.stopPropagation(); + event.preventDefault(); + snapshot.snapshotEditContainerDisplayed.set( false ); + } + } ); - component.schedule.inputs.on( 'input', function() { - component.populateSetting(); + // Collapse the schedule container when Esc is pressed inside of the schedule container. + snapshot.editContainer.on( 'keydown', function( event ) { + if ( escKeyCode === event.which && snapshot.snapshotEditContainerDisplayed.get() ) { + event.stopPropagation(); + event.preventDefault(); + snapshot.snapshotEditContainerDisplayed.set( false ); + snapshot.snapshotExpandButton.focus(); + } } ); - component.schedule.inputs.on( 'blur', function() { - component.populateInputs(); - component.populateSetting(); + // Collapse the schedule container interacting outside the schedule container. + $( 'body' ).on( 'mousedown', function( event ) { + var isDisplayed = snapshot.snapshotEditContainerDisplayed.get(), + isTargetEditContainer = snapshot.editContainer.is( event.target ) || 0 !== snapshot.editContainer.has( event.target ).length, + isTargetExpandButton = snapshot.snapshotExpandButton.is( event.target ); + + if ( isDisplayed && ! isTargetEditContainer && ! isTargetExpandButton ) { + snapshot.snapshotEditContainerDisplayed.set( false ); + } } ); - component.updateCountdown(); + snapshot.snapshotEditContainerDisplayed.set( false ); - component.schedule.container.find( '.reset-time a' ).on( 'click', function( event ) { - event.preventDefault(); - component.updateSchedule(); + api.state( 'snapshot-saved' ).bind( function( saved ) { + if ( saved ) { + snapshot.updateSnapshotEditControls(); + } } ); - } - // Set up toggling of the schedule container. - component.scheduleContainerDisplayed.bind( function( isDisplayed ) { - if ( isDisplayed ) { - component.schedule.container.stop().slideDown( 'fast' ).attr( 'aria-expanded', 'true' ); - scheduleButton.attr( 'aria-pressed', 'true' ); - scheduleButton.prop( 'title', component.data.i18n.collapseSnapshotScheduling ); - } else { - component.schedule.container.stop().slideUp( 'fast' ).attr( 'aria-expanded', 'false' ); - scheduleButton.attr( 'aria-pressed', 'false' ); - scheduleButton.prop( 'title', component.data.i18n.expandSnapshotScheduling ); - } - } ); - - // Toggle schedule container when clicking the button. - scheduleButton.on( 'click', function( event ) { - event.preventDefault(); - component.scheduleContainerDisplayed.set( ! component.scheduleContainerDisplayed.get() ); - } ); - - // Collapse the schedule container when Esc is pressed while the button is focused. - scheduleButton.on( 'keydown', function( event ) { - if ( escKeyCode === event.which && component.scheduleContainerDisplayed.get() ) { - event.stopPropagation(); - event.preventDefault(); - component.scheduleContainerDisplayed.set( false ); - } - }); + api.bind( 'change', function() { + snapshot.data.dirty = true; + snapshot.editContainer.find( 'a.snapshot-edit-link' ).hide(); + } ); - // Collapse the schedule container when Esc is pressed inside of the schedule container. - component.schedule.container.on( 'keydown', function( event ) { - if ( escKeyCode === event.which && component.scheduleContainerDisplayed.get() ) { - event.stopPropagation(); - event.preventDefault(); - component.scheduleContainerDisplayed.set( false ); - scheduleButton.focus(); - } - }); + api.state( 'snapshot-exists' ).bind( function( exists ) { + if ( exists && ! _.isEmpty( snapshot.editContainer ) ) { + snapshot.updateSnapshotEditControls(); + } else { + snapshot.snapshotEditContainerDisplayed.set( false ); + } + } ); - // Collapse the schedule container interacting outside the schedule container. - $( 'body' ).on( 'mousedown', function( event ) { - if ( component.scheduleContainerDisplayed.get() && ! $.contains( component.schedule.container[0], event.target ) && ! scheduleButton.is( event.target ) ) { - component.scheduleContainerDisplayed.set( false ); + if ( snapshot.statusButton ) { + snapshot.updateSnapshotEditControls(); } - }); - component.scheduleContainerDisplayed.set( false ); + snapshot.autoSaveEditBox(); + }, + + /** + * Auto save the edit box values. + * + * @return {void} + */ + autoSaveEditBox: function() { + var snapshot = this, update, + delay = 2000, status, isValidChangesetStatus; + + snapshot.updatePending = false; + snapshot.dirtyEditControlValues = false; + + update = _.debounce( function() { + status = snapshot.statusButton.value.get(); + if ( 'publish' === status || ! snapshot.isFutureDate() ) { + snapshot.updatePending = false; + return; + } + snapshot.updatePending = true; + snapshot.updateSnapshot( status ).done( function() { + snapshot.updatePending = snapshot.dirtyEditControlValues; + if ( ! snapshot.updatePending ) { + snapshot.updateSnapshotEditControls(); + } else if ( snapshot.dirtyEditControlValues ) { + update(); + } + snapshot.dirtyEditControlValues = false; + } ).fail( function() { + snapshot.updatePending = false; + } ); + }, delay ); + + snapshot.editControlSettings.bind( function() { + if ( snapshot.isFutureDate() ) { + if ( ! snapshot.updatePending ) { + update(); + } else { + snapshot.dirtyEditControlValues = true; + } + } + } ); + + $( window ).on( 'beforeunload.customize-confirm', function() { + if ( snapshot.updatePending || snapshot.dirtyEditControlValues ) { + return snapshot.data.i18n.aysMsg; + } + return undefined; + } ); + + isValidChangesetStatus = function() { + return _.contains( [ 'future', 'pending', 'draft' ], api.state( 'changesetStatus' ).get() ); + }; + + // @todo Show loader and disable button while auto saving. + api.bind( 'changeset-save', function() { + if ( isValidChangesetStatus() ) { + snapshot.updatePending = true; + snapshot.extendPreviewerQuery(); + } + } ); - api.state( 'snapshot-saved' ).bind( function( saved ) { - if ( saved ) { - component.updateSchedule(); + api.bind( 'changeset-saved', function() { + api.state( 'saved' ).set( true ); // Suppress the AYS dialog. + + if ( isValidChangesetStatus() ) { + snapshot.updatePending = false; + } + }); + }, + + /** + * Toggles date notification. + * + * @return {void}. + */ + toggleDateNotification: function showDateNotification() { + var snapshot = this; + if ( ! _.isEmpty( snapshot.dateNotification ) ) { + snapshot.dateNotification.toggle( ! snapshot.isFutureDate() ); + + if ( 'future' === snapshot.statusButton.value.get() ) { + snapshot.statusButton.disbleButton.set( ! snapshot.isFutureDate() ); + } } - } ); - - api.bind( 'change', function() { - component.data.dirty = true; - component.schedule.container.find( 'a.snapshot-edit-link' ).hide(); - } ); - - api.state( 'saved' ).bind( function( saved ) { - if ( saved && ! _.isEmpty( component.schedule.container ) ) { - component.data.dirty = false; - component.data.publishDate = component.getCurrentTime(); - component.scheduleContainerDisplayed.set( false ); - component.updateSchedule(); + }, + + /** + * Get the preview URL with the snapshot UUID attached. + * + * @returns {string} URL. + */ + getSnapshotFrontendPreviewUrl: function getSnapshotFrontendPreviewUrl() { + var snapshot = this, a = document.createElement( 'a' ); + a.href = snapshot.frontendPreviewUrl.get(); + if ( a.search ) { + a.search += '&'; } - } ); - - api.state( 'snapshot-exists' ).bind( function( exists ) { - if ( exists && ! _.isEmpty( component.schedule.container ) ) { - component.updateSchedule(); - } else { - component.scheduleContainerDisplayed.set( false ); + a.search += snapshot.uuidParam + '=' + snapshot.data.uuid; + return a.href; + }, + + /** + * Updates snapshot schedule with `snapshot.data`. + * + * @return {void} + */ + updateSnapshotEditControls: function updateSnapshotEditControls() { + var snapshot = this, + parsed, + status, + sliceBegin = 0, + sliceEnd = -2; + + if ( _.isEmpty( snapshot.editContainer ) ) { + return; } - } ); - }; - - /** - * Updates snapshot schedule with `component.data`. - * - * @return {void} - */ - component.updateSchedule = function updateSchedule() { - var parsed, - sliceBegin = 0, - sliceEnd = -2; - - if ( _.isEmpty( component.schedule.container ) || ! component.data.currentUserCanPublish ) { - return; - } - if ( '0000-00-00 00:00:00' === component.data.publishDate ) { - component.data.publishDate = component.getCurrentTime(); - } + status = api.state( 'changesetStatus' ).get(); - // Normalize date with seconds removed. - component.data.publishDate = component.data.publishDate.slice( sliceBegin, sliceEnd ) + '00'; - - // Update date controls. - component.schedule.container.find( 'a.snapshot-edit-link' ) - .attr( 'href', component.data.editLink ) - .show(); - parsed = component.parseDateTime( component.data.publishDate ); - - component.schedule.inputs.each( function() { - var input = $( this ), - fieldName = input.data( 'date-input' ); - - $( this ).val( parsed[fieldName] ); - } ); - - component.populateSetting(); - }; - - /** - * Update the scheduled countdown text. - * - * Hides countdown if post_status is not already future. - * Toggles the countdown if there is no remaining time. - * - * @returns {boolean} True if date inputs are valid. - */ - component.updateCountdown = function updateCountdown() { - var countdown = component.schedule.container.find( '.snapshot-scheduled-countdown' ), - countdownTemplate = wp.template( 'snapshot-scheduled-countdown' ), - dateTimeFromInput = component.getDateFromInputs(), - millisecondsDivider = 1000, - remainingTime; - - if ( ! dateTimeFromInput ) { - return false; - } + if ( snapshot.data.currentUserCanPublish ) { + if ( '0000-00-00 00:00:00' === snapshot.data.publishDate || ! status || 'auto-draft' === status ) { + snapshot.data.publishDate = snapshot.getCurrentTime(); + } - remainingTime = dateTimeFromInput.valueOf(); - remainingTime -= component.dateValueOf( component.getCurrentTime() ); - remainingTime = Math.ceil( remainingTime / millisecondsDivider ); - - if ( 0 < remainingTime ) { - countdown.text( countdownTemplate( { - remainingTime: remainingTime - } ) ); - countdown.show(); - } else { - countdown.hide(); - } + // Normalize date with seconds removed. + snapshot.data.publishDate = snapshot.data.publishDate.slice( sliceBegin, sliceEnd ) + '00'; + parsed = snapshot.parseDateTime( snapshot.data.publishDate ); - return true; - }; - - /** - * Silently update the saved state to be true without triggering the - * changed event so that the AYS beforeunload dialog won't appear - * if no settings have been changed after saving a snapshot. Note - * that it would be better if jQuery's callbacks allowed them to - * disabled and then re-enabled later, for example: - * wp.customize.state.topics.change.disable(); - * wp.customize.state( 'saved' ).set( true ); - * wp.customize.state.topics.change.enable(); - * But unfortunately there is no such enable method. - * - * @return {void} - */ - component.resetSavedStateQuietly = function() { - api.state( 'saved' )._value = true; - }; - - /** - * Make the AJAX request to update/save a snapshot. - * - * @param {object} options Options. - * @param {string} options.status The post status for the snapshot. - * @return {void} - */ - component.sendUpdateSnapshotRequest = function( options ) { - var spinner = $( '#customize-header-actions .spinner' ), - request, data; - - data = _.extend( - { - status: 'draft' - }, - api.previewer.query(), - options, - { - nonce: api.settings.nonce.snapshot, - customize_snapshot_uuid: component.data.uuid - } - ); - request = wp.ajax.post( 'customize_update_snapshot', data ); - - spinner.addClass( 'is-active' ); - request.always( function( response ) { - spinner.removeClass( 'is-active' ); - if ( response.edit_link ) { - component.data.editLink = response.edit_link; + // Update date controls. + snapshot.schedule.inputs.each( function() { + var input = $( this ), + fieldName = input.data( 'date-input' ); + $( this ).val( parsed[fieldName] ); + } ); } - if ( response.snapshot_publish_date ) { - component.data.publishDate = response.snapshot_publish_date; + + snapshot.editContainer.find( 'a.snapshot-edit-link' ) + .attr( 'href', snapshot.data.editLink ) + .show(); + if ( ! _.isEmpty( snapshot.data.title ) ) { + snapshot.snapshotTitle.val( snapshot.data.title ); } - component.updateSchedule(); - component.data.dirty = false; - - // @todo Remove privateness from _handleSettingValidities in Core. - if ( api._handleSettingValidities && response.setting_validities ) { - api._handleSettingValidities( { - settingValidities: response.setting_validities, - focusInvalidControl: true - } ); + snapshot.populateSetting(); + }, + + /** + * Update the scheduled countdown text. + * + * Hides countdown if post_status is not already future. + * Toggles the countdown if there is no remaining time. + * + * @returns {boolean} True if date inputs are valid. + */ + updateCountdown: function updateCountdown() { + var snapshot = this, + countdownTemplate = wp.template( 'snapshot-scheduled-countdown' ), + dateTimeFromInput = snapshot.getDateFromInputs(), + millisecondsDivider = 1000, + remainingTime; + + if ( ! dateTimeFromInput ) { + return false; } - } ); - - request.done( function() { - var url = api.previewer.previewUrl(), - regex = new RegExp( '([?&])customize_snapshot_uuid=.*?(&|$)', 'i' ), - notFound = -1, - separator = url.indexOf( '?' ) !== notFound ? '&' : '?', - customizeUrl = window.location.href, - customizeSeparator = customizeUrl.indexOf( '?' ) !== notFound ? '&' : '?'; - - if ( url.match( regex ) ) { - url = url.replace( regex, '$1customize_snapshot_uuid=' + encodeURIComponent( component.data.uuid ) + '$2' ); + + remainingTime = dateTimeFromInput.valueOf(); + remainingTime -= snapshot.dateValueOf( snapshot.getCurrentTime() ); + remainingTime = Math.ceil( remainingTime / millisecondsDivider ); + + if ( 0 < remainingTime ) { + snapshot.countdown.text( countdownTemplate( { + remainingTime: remainingTime + } ) ); + snapshot.countdown.show(); } else { - url = url + separator + 'customize_snapshot_uuid=' + encodeURIComponent( component.data.uuid ); + snapshot.countdown.hide(); + } + + return true; + }, + + /** + * Get date from inputs. + * + * @returns {Date|null} Date created from inputs or null if invalid date. + */ + getDateFromInputs: function getDateFromInputs() { + var snapshot = this, + template = snapshot.editContainer, + monthOffset = 1, + date; + + date = new Date( + parseInt( template.find( '[data-date-input="year"]' ).val(), 10 ), + parseInt( template.find( '[data-date-input="month"]' ).val(), 10 ) - monthOffset, + parseInt( template.find( '[data-date-input="day"]' ).val(), 10 ), + parseInt( template.find( '[data-date-input="hour"]' ).val(), 10 ), + parseInt( template.find( '[data-date-input="minute"]' ).val(), 10 ) + ); + + if ( isNaN( date.valueOf() ) ) { + return null; } - // Change the save button text to update. - api.state( 'snapshot-exists' ).set( true ); + date.setSeconds( 0 ); + + return date; + }, - // Replace the history state with an updated Customizer URL that includes the Snapshot UUID. - if ( history.replaceState && ! customizeUrl.match( regex ) ) { - customizeUrl += customizeSeparator + 'customize_snapshot_uuid=' + encodeURIComponent( component.data.uuid ); - history.replaceState( {}, document.title, customizeUrl ); + /** + * Parse datetime string. + * + * @param {string} datetime Date/Time string. + * @returns {object|null} Returns object containing date components or null if parse error. + */ + parseDateTime: function parseDateTime( datetime ) { + var matches = datetime.match( /^(\d\d\d\d)-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)$/ ); + + if ( ! matches ) { + return null; } - api.state( 'snapshot-saved' ).set( true ); - if ( 'pending' === data.status ) { - api.state( 'snapshot-submitted' ).set( true ); + matches.shift(); + + return { + year: matches.shift(), + month: matches.shift(), + day: matches.shift(), + hour: matches.shift(), + minute: matches.shift(), + second: matches.shift() + }; + }, + + /** + * Format a Date Object. Returns 'Y-m-d H:i:s' format. + * + * @props http://stackoverflow.com/questions/10073699/pad-a-number-with-leading-zeros-in-javascript#comment33639551_10073699 + * + * @param {Date} date A Date object. + * @returns {string} A formatted date String. + */ + formatDate: function formatDate( date ) { + var formattedDate, + yearLength = 4, + nonYearLength = 2, + monthOffset = 1; + + formattedDate = ( '0000' + date.getFullYear() ).substr( -yearLength, yearLength ); + formattedDate += '-' + ( '00' + ( date.getMonth() + monthOffset ) ).substr( -nonYearLength, nonYearLength ); + formattedDate += '-' + ( '00' + date.getDate() ).substr( -nonYearLength, nonYearLength ); + formattedDate += ' ' + ( '00' + date.getHours() ).substr( -nonYearLength, nonYearLength ); + formattedDate += ':' + ( '00' + date.getMinutes() ).substr( -nonYearLength, nonYearLength ); + formattedDate += ':' + ( '00' + date.getSeconds() ).substr( -nonYearLength, nonYearLength ); + + return formattedDate; + }, + + /** + * Populate inputs from the setting value, if none of them are currently focused. + * + * @returns {boolean} Whether the inputs were populated. + */ + populateInputs: function populateInputs() { + var snapshot = this, parsed; + + if ( snapshot.schedule.inputs.is( ':focus' ) || '0000-00-00 00:00:00' === snapshot.data.publishDate ) { + return false; } - component.resetSavedStateQuietly(); - - // Trigger an event for plugins to use. - api.trigger( 'customize-snapshots-update', { - previewUrl: url, - customizeUrl: customizeUrl, - uuid: component.data.uuid - } ); - } ); - - request.fail( function( response ) { - var id = 'snapshot-dialog-error', - snapshotDialogShareError = wp.template( id ), - messages = component.data.i18n.errorMsg, - invalidityCount = 0, - dialogElement; - - if ( response.setting_validities ) { - invalidityCount = _.size( response.setting_validities, function( validity ) { - return true !== validity; - } ); + + parsed = snapshot.parseDateTime( snapshot.data.publishDate ); + if ( ! parsed ) { + return false; } - /* - * Short-circuit if there are setting validation errors, since the error messages - * will be displayed with the controls themselves. Eventually, once we have - * a global notification area in the Customizer, we can eliminate this - * short-circuit and instead display the messages in there. - * See https://core.trac.wordpress.org/ticket/35210 - */ - if ( invalidityCount > 0 ) { + snapshot.schedule.inputs.each( function() { + var input = $( this ), + fieldName = input.data( 'date-input' ); + + if ( ! $( this ).is( 'select' ) && '' === $( this ).val() ) { + $( this ).val( parsed[fieldName] ); + } + } ); + return true; + }, + + /** + * Populate setting value from the inputs. + * + * @returns {void} + */ + populateSetting: function populateSetting() { + var snapshot = this, + date = snapshot.getDateFromInputs(), + scheduled, editControlSettings; + + editControlSettings = _.extend( {}, snapshot.editControlSettings.get() ); + + if ( ! date || ! snapshot.data.currentUserCanPublish ) { + editControlSettings.title = snapshot.snapshotTitle.val(); + snapshot.editControlSettings.set( editControlSettings ); return; } - if ( response.errors ) { - messages += ' ' + _.pluck( response.errors, 'message' ).join( ' ' ); - } + date.setSeconds( 0 ); + scheduled = snapshot.formatDate( date ) !== snapshot.data.publishDate; - // Insert the snapshot dialog error template. - dialogElement = $( '#' + id ); - if ( ! dialogElement.length ) { - dialogElement = $( snapshotDialogShareError( { - title: component.data.i18n.errorTitle, - message: messages - } ) ); - $( 'body' ).append( dialogElement ); - } + editControlSettings.title = snapshot.snapshotTitle.val(); + editControlSettings.date = snapshot.formatDate( date ); - // Open the dialog. - dialogElement.dialog( { - autoOpen: true, - modal: true - } ); - } ); - }; - - /** - * Get date from inputs. - * - * @returns {Date|null} Date created from inputs or null if invalid date. - */ - component.getDateFromInputs = function getDateFromInputs() { - var template = component.schedule.container, - monthOffset = 1, - date; - - date = new Date( - parseInt( template.find( '[data-date-input="year"]' ).val(), 10 ), - parseInt( template.find( '[data-date-input="month"]' ).val(), 10 ) - monthOffset, - parseInt( template.find( '[data-date-input="day"]' ).val(), 10 ), - parseInt( template.find( '[data-date-input="hour"]' ).val(), 10 ), - parseInt( template.find( '[data-date-input="minute"]' ).val(), 10 ) - ); - - if ( isNaN( date.valueOf() ) ) { - return null; - } + snapshot.editControlSettings.set( editControlSettings ); - date.setSeconds( 0 ); + if ( 'future' === snapshot.statusButton.value.get() ) { + snapshot.updateCountdown(); + } - return date; - }; + snapshot.editContainer.find( '.reset-time' ).toggle( scheduled ); + }, + + /** + * Check if the schedule date is in the future. + * + * @returns {boolean} True if future date. + */ + isFutureDate: function isFutureDate() { + var snapshot = this, + date = snapshot.getDateFromInputs(), + millisecondsDivider = 1000, + remainingTime; + + if ( ! date ) { + return false; + } - /** - * Parse datetime string. - * - * @param {string} datetime Date/Time string. - * @returns {object|null} Returns object containing date components or null if parse error. - */ - component.parseDateTime = function parseDateTime( datetime ) { - var matches = datetime.match( /^(\d\d\d\d)-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)$/ ); + remainingTime = snapshot.dateValueOf( date ); + remainingTime -= snapshot.dateValueOf( snapshot.getCurrentTime() ); + remainingTime = Math.ceil( remainingTime / millisecondsDivider ); + return 0 < remainingTime; + }, + + /** + * Get current date/time in the site's timezone. + * + * Same functionality as the `current_time( 'mysql', false )` function in PHP. + * + * @returns {string} Current datetime string. + */ + getCurrentTime: function getCurrentTime() { + var snapshot = this, + currentDate = new Date( snapshot.data.initialServerDate ), + currentTimestamp = snapshot.dateValueOf(), + timestampDifferential; + + timestampDifferential = currentTimestamp - snapshot.data.initialClientTimestamp; + timestampDifferential += snapshot.data.initialClientTimestamp - snapshot.data.initialServerTimestamp; + currentDate.setTime( currentDate.getTime() + timestampDifferential ); + + return snapshot.formatDate( currentDate ); + }, + + /** + * Get the primitive value of a Date object. + * + * @param {string|Date} dateString The post status for the snapshot. + * @returns {object|string} The primitive value or date object. + */ + dateValueOf: function dateValueOf( dateString ) { + var date; + + if ( 'string' === typeof dateString ) { + date = new Date( dateString ); + } else if ( dateString instanceof Date ) { + date = dateString; + } else { + date = new Date(); + } - if ( ! matches ) { - return null; - } + return date.valueOf(); + }, + + /** + * Amend the preview query so we can update the snapshot during `changeset_save`. + * + * @return {void} + */ + extendPreviewerQuery: function extendPreviewerQuery() { + var snapshot = this, originalQuery = api.previewer.query; + + api.previewer.query = function() { + var retval = originalQuery.apply( this, arguments ); + if ( ! _.isEmpty( snapshot.editControlSettings.get() ) ) { + retval.customize_changeset_title = snapshot.editControlSettings.get().title; + if ( snapshot.isFutureDate() ) { + retval.customize_changeset_date = snapshot.editControlSettings.get().date; + } + } + return retval; + }; + }, + + /** + * Add status button. + * + * @return {object} status button. + */ + addStatusButton: function addStatusButton() { + var snapshot = this, selectMenuButton, statusButton, selectedOption, buttonText, changesetStatus, selectedStatus; + changesetStatus = api.state( 'changesetStatus' ).get(); + statusButton = {}; + + selectedStatus = changesetStatus && 'auto-draft' !== changesetStatus ? changesetStatus : 'publish'; + + statusButton.value = new api.Value( selectedStatus ); + statusButton.disbleButton = new api.Value(); + statusButton.disableSelect = new api.Value(); + statusButton.buttonText = new api.Value(); + statusButton.needConfirm = false; + + statusButton.container = $( $.trim( wp.template( 'snapshot-status-button' )({ + selected: selectedStatus + }) ) ); + statusButton.button = statusButton.container.find( '.snapshot-status-button-overlay' ); + statusButton.select = statusButton.container.find( 'select' ); + statusButton.select.selectmenu({ + width: 'auto', + icons: { + button: 'dashicons dashicons-arrow-down' + }, + change: function( event, ui ) { + statusButton.value.set( ui.item.value ); + }, + select: function() { + if ( statusButton.hiddenButton ) { + statusButton.hiddenButton.text( statusButton.buttonText.get() ); + } + } + }); + + selectMenuButton = statusButton.container.find( '.ui-selectmenu-button' ); + statusButton.hiddenButton = selectMenuButton.find( '.ui-selectmenu-text' ); + statusButton.hiddenButton.addClass( 'button button-primary' ); + + statusButton.dropDown = selectMenuButton.find( '.ui-icon' ); + statusButton.dropDown.addClass( 'button button-primary' ); + + statusButton.updateButtonText = function( dataAttr ) { + buttonText = statusButton.button.data( dataAttr ); + statusButton.button.text( buttonText ); + statusButton.hiddenButton.text( buttonText ); + statusButton.buttonText.set( buttonText ); + }; + + statusButton.value.bind( function( status ) { + selectedOption = statusButton.select.find( 'option:selected' ); + statusButton.button.data( 'alt-text', selectedOption.data( 'alt-text' ) ); + statusButton.button.data( 'button-text', selectedOption.text() ); + statusButton.updateButtonText( 'button-text' ); + + if ( 'publish' === status ) { + snapshot.snapshotExpandButton.hide(); + statusButton.button.data( 'confirm-text', selectedOption.data( 'confirm-text' ) ); + statusButton.button.data( 'publish-text', selectedOption.data( 'publish-text' ) ); + statusButton.needConfirm = true; + } - matches.shift(); - - return { - year: matches.shift(), - month: matches.shift(), - day: matches.shift(), - hour: matches.shift(), - minute: matches.shift(), - second: matches.shift() - }; - }; - - /** - * Format a Date Object. Returns 'Y-m-d H:i:s' format. - * - * @props http://stackoverflow.com/questions/10073699/pad-a-number-with-leading-zeros-in-javascript#comment33639551_10073699 - * - * @param {Date} date A Date object. - * @returns {string} A formatted date String. - */ - component.formatDate = function formatDate( date ) { - var formattedDate, - yearLength = 4, - nonYearLength = 2, - monthOffset = 1; - - formattedDate = ( '0000' + date.getFullYear() ).substr( -yearLength, yearLength ); - formattedDate += '-' + ( '00' + ( date.getMonth() + monthOffset ) ).substr( -nonYearLength, nonYearLength ); - formattedDate += '-' + ( '00' + date.getDate() ).substr( -nonYearLength, nonYearLength ); - formattedDate += ' ' + ( '00' + date.getHours() ).substr( -nonYearLength, nonYearLength ); - formattedDate += ':' + ( '00' + date.getMinutes() ).substr( -nonYearLength, nonYearLength ); - formattedDate += ':' + ( '00' + date.getSeconds() ).substr( -nonYearLength, nonYearLength ); - - return formattedDate; - }; - - /** - * Populate inputs from the setting value, if none of them are currently focused. - * - * @returns {boolean} Whether the inputs were populated. - */ - component.populateInputs = function populateInputs() { - var parsed; - - if ( component.schedule.inputs.is( ':focus' ) || '0000-00-00 00:00:00' === component.data.publishDate ) { - return false; - } + if ( 'future' === status ) { + snapshot.snapshotEditContainerDisplayed.set( true ); + snapshot.snapshotExpandButton.show(); + if ( snapshot.isFutureDate() ) { + snapshot.countdown.show(); + snapshot.updateSnapshot( status ); + } + } else { + snapshot.updateSnapshot( status ); + snapshot.snapshotEditContainerDisplayed.set( false ); + snapshot.countdown.hide(); + } + } ); - parsed = component.parseDateTime( component.data.publishDate ); - if ( ! parsed ) { - return false; - } + statusButton.disbleButton.bind( function( disabled ) { + statusButton.button.prop( 'disabled', disabled ); + } ); - component.schedule.inputs.each( function() { - var input = $( this ), - fieldName = input.data( 'date-input' ); + statusButton.disableSelect.bind( function( disabled ) { + statusButton.select.selectmenu( disabled ? 'disable' : 'enable' ); + statusButton.dropDown.toggleClass( 'disabled', disabled ); + } ); - if ( ! $( this ).is( 'select' ) && '' === $( this ).val() ) { - $( this ).val( parsed[fieldName] ); - } - } ); - return true; - }; - - /** - * Populate setting value from the inputs. - * - * @returns {boolean} Whether the date inputs currently represent a valid date. - */ - component.populateSetting = function populateSetting() { - var date = component.getDateFromInputs(), - save = $( '#snapshot-save' ), - scheduled; - - if ( ! date ) { - return false; - } + statusButton.disable = function( disable ) { + statusButton.disableSelect.set( disable ); + statusButton.disbleButton.set( disable ); + }; - date.setSeconds( 0 ); - scheduled = component.formatDate( date ) !== component.data.publishDate; + statusButton.button.on( 'click', function( event ) { + event.preventDefault(); + snapshot.updateSnapshot( statusButton.value.get() ); + } ); - if ( save.length ) { + snapshot.publishButton.after( statusButton.container ); - // Change update button to schedule. - if ( component.isFutureDate() ) { - save.text( component.data.i18n.scheduleButton ); - } else if ( api.state( 'snapshot-exists' ).get() ) { - save.text( component.data.i18n.updateButton ); - } else { - save.text( component.data.i18n.saveButton ); - } + return statusButton; + }, - if ( scheduled || component.data.dirty ) { - save.prop( 'disabled', false ); - } else { - save.prop( 'disabled', true ); - } - } + /** + * Remove 'customize_changeset_status' if its already set. + * + * @return {void} + */ + prefilterAjax: function prefilterAjax() { + var removeParam, isSameStatus; - component.updateCountdown(); - component.schedule.container.find( '.reset-time' ).toggle( scheduled ); + if ( ! api.state.has( 'changesetStatus' ) ) { + return; + } - return true; - }; + removeParam = function( queryString, parameter ) { + var pars = queryString.split( /[&;]/g ); - /** - * Check if the schedule date is in the future. - * - * @returns {boolean} True if future date. - */ - component.isFutureDate = function isFutureDate() { - var date = component.getDateFromInputs(), - millisecondsDivider = 1000, - remainingTime; + _.each( pars, function( string, index ) { + if ( string && string.lastIndexOf( parameter, 0 ) !== -1 ) { + pars.splice( index, 1 ); + } + } ); - if ( ! date ) { - return false; - } + return pars.join( '&' ); + }; - remainingTime = component.dateValueOf( date ); - remainingTime -= component.dateValueOf( component.getCurrentTime() ); - remainingTime = Math.ceil( remainingTime / millisecondsDivider ); - - return 0 < remainingTime; - }; - - /** - * Get current date/time in the site's timezone. - * - * Same functionality as the `current_time( 'mysql', false )` function in PHP. - * - * @returns {string} Current datetime string. - */ - component.getCurrentTime = function getCurrentTime() { - var currentDate = new Date( component.data.initialServerDate ), - currentTimestamp = component.dateValueOf(), - timestampDifferential; - - timestampDifferential = currentTimestamp - component.data.initialClientTimestamp; - timestampDifferential += component.data.initialClientTimestamp - component.data.initialServerTimestamp; - currentDate.setTime( currentDate.getTime() + timestampDifferential ); - - return component.formatDate( currentDate ); - }; - - /** - * Get the primitive value of a Date object. - * - * @param {string|Date} dateString The post status for the snapshot. - * @returns {object|string} The primitive value or date object. - */ - component.dateValueOf = function( dateString ) { - var date; - - if ( 'string' === typeof dateString ) { - date = new Date( dateString ); - } else if ( dateString instanceof Date ) { - date = dateString; - } else { - date = new Date(); + $.ajaxPrefilter( function( options, originalOptions ) { + if ( ! originalOptions.data ) { + return; + } + isSameStatus = api.state( 'changesetStatus' ).get() === originalOptions.data.customize_changeset_status; + if ( 'customize_save' === originalOptions.data.action && isSameStatus && options.data ) { + options.data = removeParam( options.data, 'customize_changeset_status' ); + } + } ); } + } ); - return date.valueOf(); - }; - - component.init(); + if ( 'undefined' !== typeof _customizeSnapshotsSettings ) { + api.snapshots = new api.Snapshots( _customizeSnapshotsSettings ); + } -} )( wp.customize, jQuery ); +})( wp.customize, jQuery ); diff --git a/php/class-customize-snapshot-back-compat.php b/php/class-customize-snapshot-back-compat.php new file mode 100644 index 00000000..5de40fdd --- /dev/null +++ b/php/class-customize-snapshot-back-compat.php @@ -0,0 +1,321 @@ +snapshot_manager = $snapshot_manager; + $this->data = array(); + + if ( ! Customize_Snapshot_Manager_Back_Compat::is_valid_uuid( $uuid ) ) { + throw new Exception( __( 'You\'ve entered an invalid snapshot UUID.', 'customize-snapshots' ) ); + } + $this->uuid = $uuid; + $post = $this->post(); + if ( $post ) { + $this->data = $this->snapshot_manager->post_type->get_post_content( $post ); + } + parent::__construct( $snapshot_manager ); + } + + /** + * Get the snapshot uuid. + * + * @return string + */ + public function uuid() { + return $this->uuid; + } + + /** + * Get the underlying data for the snapshot. + * + * @return array + */ + public function data() { + return $this->data; + } + + /** + * Get the status of the snapshot. + * + * @return string|null + */ + public function status() { + $post = $this->post(); + return $post ? get_post_status( $post->ID ) : null; + } + + /** + * Get the snapshot post associated with the provided UUID, or null if it does not exist. + * + * @return \WP_Post|null Post or null. + */ + public function post() { + if ( ! $this->post_id ) { + $this->post_id = $this->snapshot_manager->post_type->find_post( $this->uuid ); + } + if ( $this->post_id ) { + return get_post( $this->post_id ); + } else { + return null; + } + } + + /** + * Return the Customizer settings corresponding to the data contained in the snapshot. + * + * @return \WP_Customize_Setting[] + */ + public function settings() { + $settings = array(); + $setting_ids = array_keys( $this->data ); + $this->snapshot_manager->customize_manager->add_dynamic_settings( $setting_ids ); + foreach ( $setting_ids as $setting_id ) { + $setting = $this->snapshot_manager->customize_manager->get_setting( $setting_id ); + if ( $setting ) { + $settings[] = $setting; + } + } + return $settings; + } + + /** + * Prepare snapshot data for saving. + * + * @see WP_Customize_Manager::set_post_value() + * @throws Exception When $settings_data is not an array of arrays. + * + * @param array $settings_data Settings data, mapping setting IDs to arrays containing `value` and optionally additional params. + * @param array $options { + * Additional options. + * + * @type bool $skip_validation Whether to skip validation. Optional, defaults to false. + * } + * @return array { + * Result. + * + * @type null|\WP_Error $error Error object if error. + * @type array $sanitized Sanitized values. + * @type array $validities Setting validities. + * } + */ + public function set( array $settings_data, array $options = array() ) { + $error = new \WP_Error(); + $result = array( + 'errors' => null, + 'sanitized' => array(), + 'validities' => array(), + ); + + $setting_ids = array_keys( $settings_data ); + $customize_manager = $this->snapshot_manager->customize_manager; + $customize_manager->add_dynamic_settings( $setting_ids ); + + // Check for recognized settings and authorized settings. + $unsanitized_values = array(); + $unrecognized_setting_ids = array(); + $unauthorized_setting_ids = array(); + foreach ( $settings_data as $setting_id => $setting_params ) { + if ( ! is_array( $setting_params ) ) { + throw new Exception( '$setting_params not an array' ); + } + $setting = $customize_manager->get_setting( $setting_id ); + if ( ! $setting ) { + $unrecognized_setting_ids[] = $setting_id; + } elseif ( ! current_user_can( $setting->capability ) ) { + $unauthorized_setting_ids[] = $setting_id; + } elseif ( array_key_exists( 'value', $setting_params ) ) { + $unsanitized_values[ $setting_id ] = $setting_params['value']; + } + } + + // Remove values that are unrecognized or unauthorized. + $unsanitized_values = wp_array_slice_assoc( + $unsanitized_values, + array_diff( + array_keys( $unsanitized_values ), + array_merge( $unrecognized_setting_ids, $unauthorized_setting_ids ) + ) + ); + + $invalid_setting_ids = array(); + if ( empty( $options['skip_validation'] ) ) { + // Validate. + if ( method_exists( $customize_manager, 'validate_setting_values' ) ) { + $result['validities'] = $customize_manager->validate_setting_values( $unsanitized_values ); + } else { + // @codeCoverageIgnoreStart + $result['validities'] = array_map( + function( $sanitized ) { + if ( is_null( $sanitized ) ) { + return new \WP_Error( 'invalid_value', __( 'Invalid value', 'customize-snapshots' ) ); + } else { + return true; + } + }, + $unsanitized_values + ); + // @codeCoverageIgnoreEnd + } + $invalid_setting_ids = array_keys( array_filter( $result['validities'], function( $validity ) { + return is_wp_error( $validity ); + } ) ); + } + + // Sanitize. + foreach ( $unsanitized_values as $setting_id => $unsanitized_value ) { + $setting = $customize_manager->get_setting( $setting_id ); + if ( $setting ) { + $result['sanitized'][ $setting_id ] = $setting->sanitize( $unsanitized_value ); + } else { + $unrecognized_setting_ids[] = $setting_id; + } + } + + // Add errors. + if ( ! empty( $unauthorized_setting_ids ) ) { + $error->add( + 'unauthorized_settings', + /* translators: %s is the list of unauthorized setting ids */ + sprintf( __( 'Unauthorized settings: %s', 'customize-snapshots' ), join( ',', $unauthorized_setting_ids ) ), + array( 'setting_ids' => $unauthorized_setting_ids ) + ); + } + if ( ! empty( $unrecognized_setting_ids ) ) { + $error->add( + 'unrecognized_settings', + /* translators: %s is the list of unrecognized setting ids */ + sprintf( __( 'Unrecognized settings: %s', 'customize-snapshots' ), join( ',', $unrecognized_setting_ids ) ), + array( 'setting_ids' => $unrecognized_setting_ids ) + ); + } + if ( 0 !== count( $invalid_setting_ids ) ) { + $code = 'invalid_values'; + $message = __( 'Invalid values', 'customize-snapshots' ); + $error->add( $code, $message, compact( 'invalid_setting_ids' ) ); + } + + if ( ! empty( $error->errors ) ) { + $result['errors'] = $error; + } else { + /* + * Note that somewhat unintuitively the unsanitized post values + * ($unsanitized_values) are stored as opposed to storing the + * sanitized ones ($result['sanitized']). It is still safe to do this + * because they have passed sanitization and validation here. The + * reason why we need to store the raw unsanitized values is so that + * the values can be re-populated into the post values for running + * through the sanitize, validate, and ultimately update logic. + * Once a value has gone through the sanitize logic, it may not be + * suitable for populating into a post value, especially widget + * instances which get exported with a JS value that has the instance + * data encoded, serialized, and hashed to prevent mutation. A + * sanitize filter for a widget instance will convert an encoded + * instance value into a regular instance array, and if this regular + * instance array is placed back into a post value, it will get + * rejected by the sanitize logic for not being an encoded value. + */ + foreach ( $settings_data as $setting_id => $setting_params ) { + if ( ! isset( $this->data[ $setting_id ] ) ) { + $this->data[ $setting_id ] = array(); + } + if ( ! array_key_exists( 'value', $setting_params ) ) { + $setting_params['value'] = null; + } + $this->data[ $setting_id ] = array_merge( $this->data[ $setting_id ], $setting_params ); + } + } + + return $result; + } + + /** + * Persist the data in the snapshot post content. + * + * @param array $args Args. + * @return true|\WP_Error + */ + public function save( array $args ) { + + /** + * Filter the snapshot's data before it's saved to 'post_content'. + * + * @param array $data Customizer snapshot data, with setting IDs mapped to an array + * containing a `value` array item and potentially other metadata. + * @param Customize_Snapshot $this Snapshot object. + */ + $this->data = apply_filters( 'customize_snapshot_save', $this->data, $this ); + + $result = $this->snapshot_manager->post_type->save( array_merge( + $args, + array( + 'uuid' => $this->uuid, + 'data' => $this->data, + 'theme' => $this->snapshot_manager->customize_manager->get_stylesheet(), + ) + ) ); + + if ( ! is_wp_error( $result ) ) { + $this->post_id = $result; + } + + return $result; + } + + /** + * Return whether the snapshot was saved (created/inserted) yet. + * + * @return bool + */ + public function saved() { + return ! is_null( $this->post() ); + } +} diff --git a/php/class-customize-snapshot-command.php b/php/class-customize-snapshot-command.php new file mode 100644 index 00000000..96726c78 --- /dev/null +++ b/php/class-customize-snapshot-command.php @@ -0,0 +1,58 @@ +plugin->compat ) { + \WP_CLI::error( __( 'You\'re using older WordPress version please upgrade 4.7 or above to migrate.', 'customize-snapshots' ) ); + return; + } + if ( $migrate_obj->is_migrated() ) { + \WP_CLI::success( __( 'Already migrated.', 'customize-snapshots' ) ); + return; + } + $dry_mode = isset( $assoc_args['dry-run'] ); + if ( ! $dry_mode ) { + $post_count = $migrate_obj->changeset_migrate(); + \WP_CLI::success( $post_count . ' ' . __( 'posts migrated.', 'customize-snapshots' ) ); + } else { + $ids = $migrate_obj->changeset_migrate( - 1, true ); + \WP_CLI::success( count( $ids ) . ' ' . __( 'posts migrated:', 'customize-snapshots' ) . ' ' . implode( ',', $ids ) ); + } + } +} + +if ( defined( 'WP_CLI' ) && WP_CLI ) { + \WP_CLI::add_command( 'snapshot', __NAMESPACE__ . '\\Customize_Snapshot_Command' ); +} diff --git a/php/class-customize-snapshot-manager-back-compat.php b/php/class-customize-snapshot-manager-back-compat.php new file mode 100644 index 00000000..219be0c4 --- /dev/null +++ b/php/class-customize-snapshot-manager-back-compat.php @@ -0,0 +1,1011 @@ +post_type = new Post_Type_Back_Compat( $this ); + + add_filter( 'customize_refresh_nonces', array( $this, 'filter_customize_refresh_nonces' ) ); + add_action( 'template_redirect', array( $this, 'show_theme_switch_error' ) ); + add_action( 'customize_save_after', array( $this, 'publish_snapshot_with_customize_save_after' ) ); + add_action( 'transition_post_status', array( $this, 'save_settings_with_publish_snapshot' ), 10, 3 ); + add_action( 'wp_ajax_' . self::AJAX_ACTION, array( $this, 'handle_update_snapshot_request' ) ); + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_frontend_scripts' ) ); + add_action( 'customize_preview_init', array( $this, 'customize_preview_init' ) ); + add_action( 'customize_save', array( $this, 'check_customize_publish_authorization' ), 10, 0 ); + $this->hooks(); + if ( $this->read_current_snapshot_uuid() ) { + $this->load_snapshot(); + } elseif ( is_customize_preview() && isset( $_REQUEST['wp_customize_preview_ajax'] ) && 'true' === $_REQUEST['wp_customize_preview_ajax'] ) { + add_action( 'wp_loaded', array( $this, 'setup_preview_ajax_requests' ), 12 ); + } + } + + /** + * Get the Customize_Snapshot instance. + * + * @return Customize_Snapshot_Back_Compat + */ + public function snapshot() { + return $this->snapshot; + } + + /** + * Ensure Customizer manager is instantiated. + * + * @global \WP_Customize_Manager $wp_customize + */ + public function ensure_customize_manager() { + global $wp_customize; + if ( empty( $wp_customize ) || ! ( $wp_customize instanceof \WP_Customize_Manager ) ) { + require_once( ABSPATH . WPINC . '/class-wp-customize-manager.php' ); + $wp_customize = new \WP_Customize_Manager(); // WPCS: override ok. + } + $this->customize_manager = $wp_customize; + } + + /** + * Include the snapshot nonce in the Customizer nonces. + * + * @param array $nonces Nonces. + * @return array Nonces. + */ + public function filter_customize_refresh_nonces( $nonces ) { + $nonces['snapshot'] = wp_create_nonce( self::AJAX_ACTION ); + return $nonces; + } + + /** + * Enqueue styles & scripts for the Customizer. + * + * @action customize_controls_enqueue_scripts + * @global \WP_Customize_Manager $wp_customize + */ + public function enqueue_controls_scripts() { + + // Prevent loading the Snapshot interface if the theme is not active. + if ( ! $this->is_theme_active() ) { + return; + } + + wp_enqueue_style( 'customize-snapshots' ); + wp_enqueue_script( 'customize-snapshots-compat' ); + + if ( $this->snapshot ) { + $post = $this->snapshot->post(); + $this->override_post_date_default_data( $post ); + } + + // Script data array. + $exports = apply_filters( 'customize_snapshots_export_data', array( + 'action' => self::AJAX_ACTION, + 'uuid' => $this->snapshot ? $this->snapshot->uuid() : self::generate_uuid(), + 'editLink' => isset( $post ) ? get_edit_post_link( $post, 'raw' ) : '', + 'publishDate' => isset( $post->post_date ) ? $post->post_date : '', + 'title' => isset( $post->post_title ) ? $post->post_title : '', + 'postStatus' => isset( $post->post_status ) ? $post->post_status : '', + 'currentUserCanPublish' => current_user_can( 'customize_publish' ), + 'initialServerDate' => current_time( 'mysql', false ), + 'initialServerTimestamp' => floor( microtime( true ) * 1000 ), + 'i18n' => array( + 'saveButton' => __( 'Save', 'customize-snapshots' ), + 'updateButton' => __( 'Update', 'customize-snapshots' ), + 'scheduleButton' => __( 'Schedule', 'customize-snapshots' ), + 'submit' => __( 'Submit', 'customize-snapshots' ), + 'submitted' => __( 'Submitted', 'customize-snapshots' ), + 'publish' => __( 'Publish', 'customize-snapshots' ), + 'published' => __( 'Published', 'customize-snapshots' ), + 'permsMsg' => array( + 'save' => __( 'You do not have permission to publish changes, but you can create a snapshot by clicking the "Save" button.', 'customize-snapshots' ), + 'update' => __( 'You do not have permission to publish changes, but you can modify this snapshot by clicking the "Update" button.', 'customize-snapshots' ), + ), + 'errorMsg' => __( 'The snapshot could not be saved.', 'customize-snapshots' ), + 'errorTitle' => __( 'Error', 'customize-snapshots' ), + 'collapseSnapshotScheduling' => __( 'Collapse snapshot scheduling', 'customize-snapshots' ), + 'expandSnapshotScheduling' => __( 'Expand snapshot scheduling', 'customize-snapshots' ), + ), + 'snapshotExists' => ( $this->snapshot && $this->snapshot->saved() ), + ) ); + + wp_localize_script( 'customize-snapshots-compat', '_customizeSnapshotsCompatSettings', $exports ); + } + + /** + * Load snapshot. + */ + public function load_snapshot() { + $this->ensure_customize_manager(); + $this->snapshot = new Customize_Snapshot_Back_Compat( $this, $this->current_snapshot_uuid ); + + if ( ! $this->should_import_and_preview_snapshot( $this->snapshot ) ) { + return; + } + + $this->add_widget_setting_preview_filters(); + $this->add_nav_menu_setting_preview_filters(); + + /* + * Populate post values. + * + * Note we have to defer until setup_theme since the transaction + * can be set beforehand, and wp_magic_quotes() would not have + * been called yet, resulting in a $_POST['customized'] that is + * double-escaped. Note that this happens at priority 1, which + * is immediately after Customize_Snapshot_Manager::store_customized_post_data + * which happens at setup_theme priority 0, so that the initial + * POST data can be preserved. + */ + if ( did_action( 'setup_theme' ) ) { + $this->import_snapshot_data(); + } else { + add_action( 'setup_theme', array( $this, 'import_snapshot_data' ) ); + } + + // Block the robots. + add_action( 'wp_head', 'wp_no_robots' ); + + // Preview post values. + if ( did_action( 'wp_loaded' ) ) { + $this->preview_snapshot_settings(); + } else { + add_action( 'wp_loaded', array( $this, 'preview_snapshot_settings' ), 11 ); + } + } + + /** + * Populate post values and $_POST['customized'] wth the snapshot's data. + * + * Plugins used to have to dynamically register settings by inspecting the + * $_POST['customized'] var and manually re-parse and inspect to see if it + * contains settings that wouldn't be registered otherwise. This ensures + * that these plugins will continue to work. + * + * Note that this can't be called prior to the setup_theme action or else + * magic quotes may end up getting added twice. + * + * @see Customize_Snapshot_Manager::should_import_and_preview_snapshot() + */ + public function import_snapshot_data() { + /* + * We don't merge the snapshot data with any existing existing unsanitized + * post values since should_import_and_preview_snapshot returns false if + * there is any existing data in the Customizer state. This is to prevent + * clobbering existing values (or previewing non-snapshotted values on frontend). + * Note that wp.customize.Snapshots.extendPreviewerQuery() will extend the + * previewer data to include the current snapshot UUID. + */ + $snapshot_values = array_filter( + wp_list_pluck( $this->snapshot->data(), 'value' ), + function( $value ) { + return ! is_null( $value ); + } + ); + + // Populate input vars for back-compat. + $_POST['customized'] = wp_slash( wp_json_encode( $snapshot_values ) ); + // @codingStandardsIgnoreStart + $_REQUEST['customized'] = $_POST['customized']; + // @codingStandardsIgnoreEnd + + foreach ( $snapshot_values as $setting_id => $value ) { + $this->customize_manager->set_post_value( $setting_id, $value ); + } + } + + /** + * Determine whether the current snapshot can be previewed. + * + * @param Customize_Snapshot_Back_Compat $snapshot Snapshot to check. + * @return true|\WP_Error Returns true if previewable, or `WP_Error` if cannot. + */ + public function should_import_and_preview_snapshot( Customize_Snapshot_Back_Compat $snapshot ) { + global $pagenow; + + // Ignore if in the admin, but not Admin Ajax or Customizer. + if ( is_admin() && ! in_array( $pagenow, array( 'admin-ajax.php', 'customize.php' ), true ) ) { + return false; + } + + if ( is_wp_error( $this->get_theme_switch_error( $snapshot ) ) ) { + return false; + } + + // Abort if doing customize_save. + if ( $this->doing_customize_save_ajax() ) { + return false; + } + + // Abort if the snapshot was already published. + if ( $snapshot->saved() && 'publish' === get_post_status( $snapshot->post() ) ) { + return false; + } + + /* + * Prevent clobbering existing values (or previewing non-snapshotted values on frontend). + * Note that wp.customize.Snapshots.extendPreviewerQuery() will extend the + * previewer data to include the current snapshot UUID. + */ + if ( $this->customize_manager && count( $this->customize_manager->unsanitized_post_values() ) > 0 ) { + return false; + } + + return true; + } + + /** + * Redirect when preview is not allowed for the current theme. + * + * @param Customize_Snapshot $snapshot Snapshot to check. + * @return \WP_Error|null + */ + public function get_theme_switch_error( Customize_Snapshot $snapshot ) { + + // Loading a snapshot into the context of a theme switch is not supported. + if ( ! $this->is_theme_active() ) { + return new \WP_Error( 'snapshot_theme_switch', __( 'Snapshot cannot be previewed when previewing a theme switch.', 'customize-snapshots' ) ); + } + + $snapshot_post = $snapshot->post(); + if ( ! $snapshot_post ) { + return null; + } + + $snapshot_theme = get_post_meta( $snapshot_post->ID, '_snapshot_theme', true ); + if ( ! empty( $snapshot_theme ) && get_stylesheet() !== $snapshot_theme ) { + return new \WP_Error( 'snapshot_theme_switched', __( 'Snapshot requested was made for a different theme and cannot be previewed with the current theme.', 'customize-snapshots' ) ); + } + + return null; + } + + /** + * Is previewing settings. + * + * Plugins and themes may currently only use `is_customize_preview()` to + * decide whether or not they can store a value in the object cache. For + * example, see `Twenty_Eleven_Ephemera_Widget::widget()`. However, when + * viewing a snapshot on the frontend, the `is_customize_preview()` method + * will return `false`. Plugins and themes that store values in the object + * cache must either skip doing this when `$this->previewing` is `true`, + * or include the `$this->current_snapshot_uuid` (`current_snapshot_uuid()`) + * in the cache key when it is `true`. Note that if the `customize_preview_init` action + * was done, this means that the settings have been previewed in the regular + * Customizer preview. + * + * @see Twenty_Eleven_Ephemera_Widget::widget() + * @see WP_Customize_Manager::is_previewing_settings() + * @see is_previewing_settings() + * @see current_snapshot_uuid()() + * @see WP_Customize_Manager::customize_preview_init() + * @see Customize_Snapshot_Manager::$previewing_settings + * + * @return bool Whether previewing settings. + */ + public function is_previewing_settings() { + return $this->previewing_settings || did_action( 'customize_preview_init' ); + } + + /** + * Preview the snapshot settings. + * + * Note that this happens at `wp_loaded` action with priority 11 so that we + * can look at whether the `customize_preview_init` action was done. + */ + public function preview_snapshot_settings() { + if ( $this->is_previewing_settings() ) { + return; + } + $this->previewing_settings = true; + + /* + * Note that we need to preview the settings outside the Customizer preview + * and in the Customizer pane itself so we can load a previous snapshot + * into the Customizer. We have to prevent the previews from being added + * in the case of a customize_save action because then update_option() + * may short-circuit because it will detect that there are no changes to + * make. + */ + foreach ( $this->snapshot->settings() as $setting ) { + $setting->preview(); + $setting->dirty = true; + } + } + + /** + * Add filters for previewing widgets on the frontend. + */ + function add_widget_setting_preview_filters() { + /* + * Add WP_Customize_Widget component hooks which were short-circuited in 4.5 (r36611 for #35895). + * See https://core.trac.wordpress.org/ticket/35895 + */ + if ( isset( $this->customize_manager->widgets ) && ! current_user_can( 'edit_theme_options' ) ) { + $hooks = array( + 'customize_dynamic_setting_args' => array( + 'callback' => array( $this->customize_manager->widgets, 'filter_customize_dynamic_setting_args' ), + 'priority' => 10, + ), + 'widgets_init' => array( + 'callback' => array( $this->customize_manager->widgets, 'register_settings' ), + 'priority' => 95, + ), + 'customize_register' => array( + 'callback' => array( $this->customize_manager->widgets, 'schedule_customize_register' ), + 'priority' => 1, + ), + ); + foreach ( $hooks as $hook_name => $hook_args ) { + // Note that add_action()/has_action() are just aliases for add_filter()/has_filter(). + if ( ! has_filter( $hook_name, $hook_args['callback'] ) ) { + add_filter( $hook_name, $hook_args['callback'], $hook_args['priority'], PHP_INT_MAX ); + } + } + } + + /* + * Disable routine which fails because \WP_Customize_Manager::setup_theme() is + * never called in a frontend preview context, whereby the original_stylesheet + * is never set and so \WP_Customize_Manager::is_theme_active() will thus + * always return true because get_stylesheet() !== null. + * + * The action being removed is responsible for adding an option_sidebar_widgets + * filter \WP_Customize_Widgets::filter_option_sidebars_widgets_for_theme_switch() + * which causes the sidebars_widgets to be overridden with a global variable. + */ + if ( ! is_admin() ) { + remove_action( 'wp_loaded', array( $this->customize_manager->widgets, 'override_sidebars_widgets_for_theme_switch' ) ); + } + } + + /** + * Add filters for previewing nav menus on the frontend. + */ + public function add_nav_menu_setting_preview_filters() { + if ( isset( $this->customize_manager->nav_menus ) && ! current_user_can( 'edit_theme_options' ) ) { + $hooks = array( + 'customize_register' => array( + 'callback' => array( $this->customize_manager->nav_menus, 'customize_register' ), + 'priority' => 11, + ), + 'customize_dynamic_setting_args' => array( + 'callback' => array( $this->customize_manager->nav_menus, 'filter_dynamic_setting_args' ), + 'priority' => 10, + ), + 'customize_dynamic_setting_class' => array( + 'callback' => array( $this->customize_manager->nav_menus, 'filter_dynamic_setting_class' ), + 'priority' => 10, + ), + 'wp_nav_menu_args' => array( + 'callback' => array( $this->customize_manager->nav_menus, 'filter_wp_nav_menu_args' ), + 'priority' => 1000, + ), + 'wp_nav_menu' => array( + 'callback' => array( $this->customize_manager->nav_menus, 'filter_wp_nav_menu' ), + 'priority' => 10, + ), + ); + foreach ( $hooks as $hook_name => $hook_args ) { + // Note that add_action()/has_action() are just aliases for add_filter()/has_filter(). + if ( ! has_filter( $hook_name, $hook_args['callback'] ) ) { + add_filter( $hook_name, $hook_args['callback'], $hook_args['priority'], PHP_INT_MAX ); + } + } + } + + if ( isset( $this->customize_manager->nav_menus ) ) { + add_action( 'customize_register', array( $this, 'preview_early_nav_menus_in_customizer' ), 9 ); + } + } + + /** + * Preview nav menu settings early so that the sections and controls for snapshot values will be added properly. + * + * This must happen at `customize_register` priority prior to 11 which is when `WP_Customize_Nav_Menus::customize_register()` runs. + * This is only relevant when accessing the Customizer app (customize.php), as this is where sections/controls matter. + * + * @see \WP_Customize_Nav_Menus::customize_register() + */ + public function preview_early_nav_menus_in_customizer() { + if ( ! is_admin() ) { + return; + } + $this->customize_manager->add_dynamic_settings( array_keys( $this->snapshot()->data() ) ); + foreach ( $this->snapshot->settings() as $setting ) { + $is_nav_menu_setting = ( + $setting instanceof \WP_Customize_Nav_Menu_Setting + || + $setting instanceof \WP_Customize_Nav_Menu_Item_Setting + || + preg_match( '/^nav_menu_locations\[/', $setting->id ) + ); + if ( $is_nav_menu_setting ) { + $setting->preview(); + + /* + * The following is redundant because it will be done later in + * Customize_Snapshot_Manager::preview_snapshot_settings(). + * Also note that the $setting instance here will likely be + * blown away inside of WP_Customize_Nav_Menus::customize_register(), + * when add_setting is called there. What matters here is that + * preview() is called on the setting _before_ the logic inside + * WP_Customize_Nav_Menus::customize_register() runs, so that + * the nav menu sections will be created. + */ + $setting->dirty = true; + } + } + } + + /** + * Setup previewing of Ajax requests in the Customizer preview. + * + * @global \WP_Customize_Manager $wp_customize + */ + public function setup_preview_ajax_requests() { + global $wp_customize, $pagenow; + + /* + * When making admin-ajax requests from the frontend, settings won't be + * previewed because is_admin() and the call to preview will be + * short-circuited in \WP_Customize_Manager::wp_loaded(). + */ + if ( ! did_action( 'customize_preview_init' ) ) { + $wp_customize->customize_preview_init(); + } + + // Note that using $pagenow is easier to test vs DOING_AJAX. + if ( ! empty( $pagenow ) && 'admin-ajax.php' === $pagenow ) { + $this->override_request_method(); + } else { + add_action( 'parse_request', array( $this, 'override_request_method' ), 5 ); + } + + $wp_customize->remove_preview_signature(); + } + + /** + * Attempt to convert the current request environment into another environment. + * + * @global \WP $wp + * + * @return bool Whether the override was applied. + */ + public function override_request_method() { + global $wp; + + // Skip of X-HTTP-Method-Override request header is not present. + if ( ! isset( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ) ) { + return false; + } + + // Skip if REST API request since it has built-in support for overriding the request method. + if ( ! empty( $wp ) && ! empty( $wp->query_vars['rest_route'] ) ) { + return false; + } + + // Skip if the request method is not GET or POST, or the override is the same as the original. + $original_request_method = $_SERVER['REQUEST_METHOD']; + $override_request_method = strtoupper( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ); + if ( ! in_array( $override_request_method, array( 'GET', 'POST' ), true ) || $original_request_method === $override_request_method ) { + return false; + } + + // Convert a POST request into a GET request. + if ( 'GET' === $override_request_method && 'POST' === $original_request_method ) { + $_SERVER['REQUEST_METHOD'] = $override_request_method; + $_GET = array_merge( $_GET, $_POST ); + $_SERVER['QUERY_STRING'] = build_query( array_map( 'rawurlencode', wp_unslash( $_GET ) ) ); + return true; + } + + return false; + } + + /** + * Check whether customize_publish capability is granted in customize_save. + */ + public function check_customize_publish_authorization() { + if ( $this->doing_customize_save_ajax() && ! current_user_can( 'customize_publish' ) ) { + wp_send_json_error( array( + 'error' => 'customize_publish_unauthorized', + ) ); + } + } + + /** + * Show the theme switch error if there is one. + */ + public function show_theme_switch_error() { + if ( empty( $this->snapshot ) ) { + return; + } + $error = $this->get_theme_switch_error( $this->snapshot ); + if ( is_wp_error( $error ) ) { + wp_die( esc_html( $error->get_error_message() ) ); + } + } + + /** + * Publish the snapshot snapshots via AJAX. + * + * Fires at `customize_save_after` to update and publish the snapshot. + * The logic in here is the inverse of save_settings_with_publish_snapshot. + * + * @see Customize_Snapshot_Manager::save_settings_with_publish_snapshot() + * + * @return bool Whether the snapshot was saved successfully. + */ + public function publish_snapshot_with_customize_save_after() { + $that = $this; + + if ( ! $this->snapshot || ! $this->doing_customize_save_ajax() ) { + return false; + } + + // This should never be reached due to Customize_Snapshot_Manager::check_customize_publish_authorization(). + if ( ! current_user_can( 'customize_publish' ) ) { + return false; + } + + $settings_data = array_map( + function( $value ) { + return compact( 'value' ); + }, + $this->customize_manager->unsanitized_post_values() + ); + $result = $this->snapshot->set( $settings_data, array( 'skip_validation' => true ) ); + if ( ! empty( $result['errors'] ) ) { + add_filter( 'customize_save_response', function( $response ) use ( $result, $that ) { + $response['snapshot_errors'] = $that->prepare_errors_for_response( $result['errors'] ); + return $response; + } ); + return false; + } + + if ( ! $this->snapshot->post() || 'publish' !== $this->snapshot->post()->post_status ) { + $args = array( + 'status' => 'publish', + ); + + // Ensure a scheduled Snapshot is published. + if ( $this->snapshot->post() && 'future' === $this->snapshot->post()->post_status ) { + $args['edit_date'] = true; + $args['date_gmt'] = current_time( 'mysql', true ); + } + + if ( isset( $_POST['title'] ) && '' !== trim( $_POST['title'] ) ) { + $args['post_title'] = sanitize_text_field( wp_unslash( $_POST['title'] ) ); + } + + $r = $this->snapshot->save( $args ); + if ( is_wp_error( $r ) ) { + add_filter( 'customize_save_response', function( $response ) use ( $r, $that ) { + $response['snapshot_errors'] = $that->prepare_errors_for_response( $r ); + return $response; + } ); + return false; + } + } + + // Send the new UUID to the client for the next snapshot. + $class = __CLASS__; // For PHP 5.3. + add_filter( 'customize_save_response', function( $data ) use ( $class ) { + $data['new_customize_snapshot_uuid'] = $class::generate_uuid(); + return $data; + } ); + return true; + } + + /** + * Publish snapshot changes when snapshot post is being published. + * + * The logic in here is the inverse of to publish_snapshot_with_customize_save_after. + * + * The meat of the logic that manipulates the post_content and validates the settings + * needs to be done in wp_insert_post_data filter in like a + * filter_insert_post_data_to_validate_published_snapshot method? This would + * have the benefit of reducing one wp_insert_post() call. + * + * @todo Consider using wp_insert_post_data to prevent double calls to wp_insert_post(). + * @see Customize_Snapshot_Manager::publish_snapshot_with_customize_save_after() + * + * @param string $new_status New status. + * @param string $old_status Old status. + * @param \WP_Post $post Post object. + * @return bool Whether the settings were saved. + */ + public function save_settings_with_publish_snapshot( $new_status, $old_status, $post ) { + + // Abort if not transitioning a snapshot post to publish from a non-publish status. + if ( $this->get_post_type() !== $post->post_type || 'publish' !== $new_status || $new_status === $old_status ) { + return false; + } + + $this->ensure_customize_manager(); + + if ( $this->doing_customize_save_ajax() ) { + // Short circuit because customize_save ajax call is changing status. + return false; + } + + if ( ! did_action( 'customize_register' ) ) { + /* + * When running from CLI or Cron, we have to remove the action because + * it will get added with a default priority of 10, after themes and plugins + * have already done add_action( 'customize_register' ), resulting in them + * being called first at the priority 10. So we manually call the + * prerequisite function WP_Customize_Manager::register_controls() and + * remove it from being called when the customize_register action fires. + */ + remove_action( 'customize_register', array( $this->customize_manager, 'register_controls' ) ); + $this->customize_manager->register_controls(); + + /* + * Unfortunate hack to prevent \WP_Customize_Widgets::customize_register() + * from calling preview() on settings. This needs to be cleaned up in core. + * It is important for previewing to be prevented because if an option has + * a filter it will short-circuit when an update is attempted since it + * detects that there is no change to be put into the DB. + * See: https://github.com/xwp/wordpress-develop/blob/e8c58c47db1421a1d0b2afa9ad4b9eb9e1e338e0/src/wp-includes/class-wp-customize-widgets.php#L208-L217 + */ + if ( ! defined( 'DOING_AJAX' ) ) { + define( 'DOING_AJAX', true ); + } + $_REQUEST['action'] = 'customize_save'; + + /** This action is documented in wp-includes/class-wp-customize-manager.php */ + do_action( 'customize_register', $this->customize_manager ); + + // undefine( 'DOING_AJAX' )... just kidding. This is the end of the unfortunate hack and it should be fixed in Core. + unset( $_REQUEST['action'] ); + } + $snapshot_content = $this->post_type->get_post_content( $post ); + + if ( method_exists( $this->customize_manager, 'validate_setting_values' ) ) { + /** This action is documented in wp-includes/class-wp-customize-manager.php */ + do_action( 'customize_save_validation_before', $this->customize_manager ); + } + + $setting_ids = array_keys( $snapshot_content ); + $this->customize_manager->add_dynamic_settings( $setting_ids ); + + /** This action is documented in wp-includes/class-wp-customize-manager.php */ + do_action( 'customize_save', $this->customize_manager ); + + /** + * Settings to save. + * + * @var \WP_Customize_Setting[] + */ + $settings = array(); + + $publish_error_count = 0; + foreach ( $snapshot_content as $setting_id => &$setting_params ) { + + // Missing value error. + if ( ! isset( $setting_params['value'] ) || is_null( $setting_params['value'] ) ) { + if ( ! is_array( $setting_params ) ) { + if ( ! empty( $setting_params ) ) { + $setting_params = array( 'value' => $setting_params ); + } else { + $setting_params = array(); + } + } + $setting_params['publish_error'] = 'null_value'; + $publish_error_count += 1; + continue; + } + + // Unrecognized setting error. + $this->customize_manager->set_post_value( $setting_id, $setting_params['value'] ); + $setting = $this->customize_manager->get_setting( $setting_id ); + if ( ! ( $setting instanceof \WP_Customize_Setting ) ) { + $setting_params['publish_error'] = 'unrecognized_setting'; + $publish_error_count += 1; + continue; + } + + // Validate setting value. + if ( method_exists( $setting, 'validate' ) ) { + $validity = $setting->validate( $setting_params['value'] ); + if ( is_wp_error( $validity ) ) { + $setting_params['publish_error'] = $validity->get_error_code(); + $publish_error_count += 1; + continue; + } + } + + // Validate sanitized setting value. + $sanitized_value = $setting->sanitize( $setting_params['value'] ); + if ( is_null( $sanitized_value ) || is_wp_error( $sanitized_value ) ) { + $setting_params['publish_error'] = is_wp_error( $sanitized_value ) ? $sanitized_value->get_error_code() : 'invalid_value'; + $publish_error_count += 1; + continue; + } + + $settings[] = $setting; + unset( $setting_params['publish_error'] ); + } // End foreach(). + + // Handle error scenarios. + if ( $publish_error_count > 0 ) { + $update_setting_args = array( + 'ID' => $post->ID, + 'post_content' => Customize_Snapshot_Manager::encode_json( $snapshot_content ), + 'post_status' => 'pending', + ); + wp_update_post( wp_slash( $update_setting_args ) ); + update_post_meta( $post->ID, 'snapshot_error_on_publish', $publish_error_count ); + + add_filter( 'redirect_post_location', function( $location ) { + $location = add_query_arg( 'snapshot_error_on_publish', '1', $location ); + return $location; + } ); + return false; + } + + /* + * Change all setting capabilities temporarily to 'exist' to allow them to + * be saved regardless of current user, such as when WP-Cron is publishing + * the snapshot post if it was scheduled. It is safe to do this because + * a setting can only be written into a snapshot by users who have the + * capability, so after it has been added to a snapshot it is good to commit. + */ + $existing_caps = wp_list_pluck( $settings, 'capability' ); + foreach ( $settings as $setting ) { + $setting->capability = 'exist'; + } + + // Persist the settings in the DB. + foreach ( $settings as $setting ) { + $setting->save(); + } + + // Restore setting capabilities. + foreach ( $existing_caps as $setting_id => $existing_cap ) { + $settings[ $setting_id ]->capability = $existing_cap; + } + + /** This action is documented in wp-includes/class-wp-customize-manager.php */ + do_action( 'customize_save_after', $this->customize_manager ); + + // Remove any previous error on setting. + delete_post_meta( $post->ID, 'snapshot_error_on_publish' ); + + return true; + } + + /** + * Update snapshots via AJAX. + */ + public function handle_update_snapshot_request() { + if ( ! check_ajax_referer( self::AJAX_ACTION, 'nonce', false ) ) { + status_header( 400 ); + wp_send_json_error( 'bad_nonce' ); + } elseif ( ! current_user_can( 'customize' ) ) { + status_header( 403 ); + wp_send_json_error( 'customize_not_allowed' ); + } elseif ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== $_SERVER['REQUEST_METHOD'] ) { // WPCS: input var ok. + status_header( 405 ); + wp_send_json_error( 'bad_method' ); + } elseif ( empty( $this->current_snapshot_uuid ) ) { + status_header( 400 ); + wp_send_json_error( 'invalid_customize_snapshot_uuid' ); + } elseif ( 0 === count( $this->customize_manager->unsanitized_post_values() ) ) { + status_header( 400 ); + wp_send_json_error( 'missing_snapshot_customized' ); + } + + if ( isset( $_POST['status'] ) ) { // WPCS: input var ok. + $status = sanitize_key( $_POST['status'] ); + } else { + $status = 'draft'; + } + if ( ! in_array( $status, array( 'draft', 'pending', 'future' ), true ) ) { + status_header( 400 ); + wp_send_json_error( 'bad_status' ); + } + if ( 'future' === $status && ! current_user_can( 'customize_publish' ) ) { + status_header( 400 ); + wp_send_json_error( 'customize_not_allowed' ); + } + $publish_date = isset( $_POST['date'] ) ? $_POST['date'] : ''; + if ( 'future' === $status ) { + $publish_date_obj = new \DateTime( $publish_date ); + $current_date = new \DateTime( current_time( 'mysql' ) ); + if ( empty( $publish_date ) || ! $publish_date_obj || $current_date > $publish_date_obj ) { + status_header( 400 ); + wp_send_json_error( 'bad_schedule_time' ); + } + } + + // Prevent attempting to modify a "locked" snapshot (a published one). + $post = $this->snapshot->post(); + if ( $post && 'publish' === $post->post_status ) { + wp_send_json_error( array( + 'errors' => array( + 'already_published' => array( + 'message' => __( 'The snapshot has already published so it is locked.', 'customize-snapshots' ), + ), + ), + ) ); + } + + /** + * Add any additional checks before saving snapshot. + * + * @param Customize_Snapshot $snapshot Snapshot to be saved. + * @param Customize_Snapshot_Manager $snapshot_manager Snapshot manager. + */ + do_action( 'customize_snapshot_save_before', $this->snapshot, $this ); + + // Set the snapshot UUID. + $post_type = get_post_type_object( $this->get_post_type() ); + $authorized = ( $post ? + current_user_can( $post_type->cap->edit_post, $post->ID ) : + current_user_can( 'customize' ) + ); + if ( ! $authorized ) { + status_header( 403 ); + wp_send_json_error( 'unauthorized' ); + } + + $data = array( + 'errors' => null, + ); + $settings_data = array_map( + function( $value ) { + return compact( 'value' ); + }, + $this->customize_manager->unsanitized_post_values() + ); + $r = $this->snapshot->set( $settings_data ); + if ( method_exists( $this->customize_manager, 'prepare_setting_validity_for_js' ) ) { + $data['setting_validities'] = array_map( + array( $this->customize_manager, 'prepare_setting_validity_for_js' ), + $r['validities'] + ); + } + + if ( $r['errors'] ) { + $data['errors'] = $this->prepare_errors_for_response( $r['errors'] ); + wp_send_json_error( $data ); + } + $args = array( + 'status' => $status, + ); + if ( isset( $_POST['title'] ) && '' !== trim( $_POST['title'] ) ) { + $args['post_title'] = sanitize_text_field( wp_unslash( $_POST['title'] ) ); + } + + if ( isset( $publish_date_obj ) && 'future' === $status ) { + $args['date_gmt'] = get_gmt_from_date( $publish_date_obj->format( 'Y-m-d H:i:s' ) ); + } + $r = $this->snapshot->save( $args ); + + $post = $this->snapshot->post(); + if ( $post ) { + $data['edit_link'] = get_edit_post_link( $post, 'raw' ); + $data['snapshot_publish_date'] = $post->post_date; + $data['title'] = $post->post_title; + } + + if ( is_wp_error( $r ) ) { + $data['errors'] = $this->prepare_errors_for_response( $r ); + wp_send_json_error( $data ); + } + + /** This filter is documented in wp-includes/class-wp-customize-manager.php */ + $data = apply_filters( 'customize_save_response', $data, $this->customize_manager ); + wp_send_json_success( $data ); + } + + /** + * Set up Customizer preview. + */ + public function customize_preview_init() { + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_preview_scripts' ) ); + } + + /** + * Enqueue Customizer preview scripts. + * + * @global \WP_Customize_Manager $wp_customize + */ + public function enqueue_preview_scripts() { + global $wp_customize; + + $handle = 'customize-snapshots-preview'; + wp_enqueue_script( $handle ); + wp_enqueue_style( $handle ); + + $exports = array( + 'home_url' => wp_parse_url( home_url( '/' ) ), + 'rest_api_url' => wp_parse_url( rest_url( '/' ) ), + 'admin_ajax_url' => wp_parse_url( admin_url( 'admin-ajax.php' ) ), + 'initial_dirty_settings' => array_keys( $wp_customize->unsanitized_post_values() ), + ); + wp_add_inline_script( + $handle, + sprintf( 'CustomizeSnapshotsPreview.init( %s )', wp_json_encode( $exports ) ), + 'after' + ); + } + + /** + * Enqueue Customizer frontend scripts. + */ + public function enqueue_frontend_scripts() { + if ( ! $this->snapshot || is_customize_preview() ) { + return; + } + $handle = 'customize-snapshots-frontend'; + wp_enqueue_script( $handle ); + + $exports = array( + 'uuid' => $this->snapshot ? $this->snapshot->uuid() : null, + 'home_url' => wp_parse_url( home_url( '/' ) ), + 'l10n' => array( + 'restoreSessionPrompt' => __( 'It seems you may have inadvertently navigated away from previewing a customized state. Would you like to restore the snapshot context?', 'customize-snapshots' ), + ), + ); + wp_add_inline_script( + $handle, + sprintf( 'CustomizeSnapshotsFrontend.init( %s )', wp_json_encode( $exports ) ), + 'after' + ); + } + + /** + * Underscore (JS) templates. + */ + public function render_templates() { + $this->add_edit_box_template(); + ?> + + + + + + + + + + post_type = new Post_Type( $this ); - add_action( 'init', array( $this->post_type, 'register' ) ); - - add_action( 'template_redirect', array( $this, 'show_theme_switch_error' ) ); - + function hooks() { + add_action( 'init', array( $this->post_type, 'init' ) ); add_action( 'customize_controls_enqueue_scripts', array( $this, 'enqueue_controls_scripts' ) ); - add_action( 'customize_preview_init', array( $this, 'customize_preview_init' ) ); - add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_frontend_scripts' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_scripts' ) ); add_action( 'customize_controls_init', array( $this, 'add_snapshot_uuid_to_return_url' ) ); add_action( 'customize_controls_print_footer_scripts', array( $this, 'render_templates' ) ); - add_action( 'customize_save', array( $this, 'check_customize_publish_authorization' ), 10, 0 ); - add_filter( 'customize_refresh_nonces', array( $this, 'filter_customize_refresh_nonces' ) ); add_action( 'admin_bar_menu', array( $this, 'customize_menu' ), 41 ); add_action( 'admin_bar_menu', array( $this, 'remove_all_non_snapshot_admin_bar_links' ), 100000 ); add_action( 'wp_before_admin_bar_render', array( $this, 'print_admin_bar_styles' ) ); add_filter( 'removable_query_args', array( $this, 'filter_removable_query_args' ) ); - add_filter( 'wp_insert_post_data', array( $this, 'prepare_snapshot_post_content_for_publish' ) ); - add_action( 'customize_save_after', array( $this, 'publish_snapshot_with_customize_save_after' ) ); - add_action( 'transition_post_status', array( $this, 'save_settings_with_publish_snapshot' ), 10, 3 ); - add_action( 'wp_ajax_' . self::AJAX_ACTION, array( $this, 'handle_update_snapshot_request' ) ); - - if ( $this->read_current_snapshot_uuid() ) { - $this->load_snapshot(); - } elseif ( is_customize_preview() && isset( $_REQUEST['wp_customize_preview_ajax'] ) && 'true' === $_REQUEST['wp_customize_preview_ajax'] ) { - add_action( 'wp_loaded', array( $this, 'setup_preview_ajax_requests' ), 12 ); - } } /** - * Read the current snapshot UUID from the request. - * - * @returns bool Whether a valid snapshot was read. + * Init. */ - public function read_current_snapshot_uuid() { - if ( isset( $_REQUEST['customize_snapshot_uuid'] ) ) { // WPCS: input var ok. - $uuid = sanitize_key( wp_unslash( $_REQUEST['customize_snapshot_uuid'] ) ); // WPCS: input var ok. - if ( static::is_valid_uuid( $uuid ) ) { - $this->current_snapshot_uuid = $uuid; - return true; - } + function init() { + $this->post_type = new Post_Type( $this ); + $this->hooks(); + add_filter( 'customize_save_response', array( $this, 'add_snapshot_var_to_customize_save' ), 10, 2 ); + if ( $this->read_current_snapshot_uuid() ) { + $this->load_snapshot(); } - $this->current_snapshot_uuid = null; - return false; } /** - * Load snapshot. + * Load Snapshot. */ public function load_snapshot() { $this->ensure_customize_manager(); - $this->snapshot = new Customize_Snapshot( $this, $this->current_snapshot_uuid ); - - if ( ! $this->should_import_and_preview_snapshot( $this->snapshot ) ) { - return; - } - - $this->add_widget_setting_preview_filters(); - $this->add_nav_menu_setting_preview_filters(); - - /* - * Populate post values. - * - * Note we have to defer until setup_theme since the transaction - * can be set beforehand, and wp_magic_quotes() would not have - * been called yet, resulting in a $_POST['customized'] that is - * double-escaped. Note that this happens at priority 1, which - * is immediately after Customize_Snapshot_Manager::store_customized_post_data - * which happens at setup_theme priority 0, so that the initial - * POST data can be preserved. - */ - if ( did_action( 'setup_theme' ) ) { - $this->import_snapshot_data(); - } else { - add_action( 'setup_theme', array( $this, 'import_snapshot_data' ) ); - } - - // Block the robots. - add_action( 'wp_head', 'wp_no_robots' ); - - // Preview post values. - if ( did_action( 'wp_loaded' ) ) { - $this->preview_snapshot_settings(); - } else { - add_action( 'wp_loaded', array( $this, 'preview_snapshot_settings' ), 11 ); - } + $this->snapshot = new Customize_Snapshot( $this ); } /** - * Setup previewing of Ajax requests in the Customizer preview. + * Add extra changeset variable to publish date. * - * @global \WP_Customize_Manager $wp_customize + * @param array $response Ajax response. + * @param \WP_Customize_Manager $customize_manager customize manager object. + * + * @return array Response. */ - public function setup_preview_ajax_requests() { - global $wp_customize, $pagenow; - - /* - * When making admin-ajax requests from the frontend, settings won't be - * previewed because is_admin() and the call to preview will be - * short-circuited in \WP_Customize_Manager::wp_loaded(). - */ - if ( ! did_action( 'customize_preview_init' ) ) { - $wp_customize->customize_preview_init(); - } - - // Note that using $pagenow is easier to test vs DOING_AJAX. - if ( ! empty( $pagenow ) && 'admin-ajax.php' === $pagenow ) { - $this->override_request_method(); - } else { - add_action( 'parse_request', array( $this, 'override_request_method' ), 5 ); - } - - $wp_customize->remove_preview_signature(); + public function add_snapshot_var_to_customize_save( $response, $customize_manager ) { + $changeset_post = get_post( $customize_manager->changeset_post_id() ); + $response['edit_link'] = $this->snapshot->get_edit_link( $changeset_post->ID ); + $response['publish_date'] = $changeset_post->post_date; + $response['title'] = $changeset_post->post_title; + return $response; } /** - * Attempt to convert the current request environment into another environment. - * - * @global \WP $wp + * Read the current snapshot UUID from the request. * - * @return bool Whether the override was applied. + * @returns bool Whether a valid snapshot was read. */ - public function override_request_method() { - global $wp; - - // Skip of X-HTTP-Method-Override request header is not present. - if ( ! isset( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ) ) { - return false; - } - - // Skip if REST API request since it has built-in support for overriding the request method. - if ( ! empty( $wp ) && ! empty( $wp->query_vars['rest_route'] ) ) { - return false; - } - - // Skip if the request method is not GET or POST, or the override is the same as the original. - $original_request_method = $_SERVER['REQUEST_METHOD']; - $override_request_method = strtoupper( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ); - if ( ! in_array( $override_request_method, array( 'GET', 'POST' ), true ) || $original_request_method === $override_request_method ) { - return false; + public function read_current_snapshot_uuid() { + $customize_arg = $this->get_customize_uuid_param(); + $frontend_arg = $this->get_front_uuid_param(); + if ( isset( $_REQUEST[ $customize_arg ] ) ) { + $uuid = $_REQUEST[ $customize_arg ]; // WPCS: input var ok. + } elseif ( isset( $_REQUEST[ $frontend_arg ] ) ) { + $uuid = $_REQUEST[ $frontend_arg ]; // WPCS: input var ok. } - // Convert a POST request into a GET request. - if ( 'GET' === $override_request_method && 'POST' === $original_request_method ) { - $_SERVER['REQUEST_METHOD'] = $override_request_method; - $_GET = array_merge( $_GET, $_POST ); - $_SERVER['QUERY_STRING'] = build_query( array_map( 'rawurlencode', wp_unslash( $_GET ) ) ); - return true; + if ( isset( $uuid ) ) { + $uuid = sanitize_key( wp_unslash( $uuid ) ); + if ( static::is_valid_uuid( $uuid ) ) { + $this->current_snapshot_uuid = $uuid; + return true; + } } - + $this->current_snapshot_uuid = null; return false; } @@ -266,7 +186,11 @@ public function ensure_customize_manager() { global $wp_customize; if ( empty( $wp_customize ) || ! ( $wp_customize instanceof \WP_Customize_Manager ) ) { require_once( ABSPATH . WPINC . '/class-wp-customize-manager.php' ); - $wp_customize = new \WP_Customize_Manager(); // WPCS: override ok. + if ( null !== $this->current_snapshot_uuid ) { + $wp_customize = new \WP_Customize_Manager( array( 'changeset_uuid' => $this->current_snapshot_uuid ) ); // WPCS: override ok. + } else { + $wp_customize = new \WP_Customize_Manager(); // WPCS: override ok. + } } $this->customize_manager = $wp_customize; @@ -276,6 +200,8 @@ public function ensure_customize_manager() { * Is previewing another theme. * * @return bool Whether theme is active. + * + * @todo move to back compat? */ public function is_theme_active() { if ( empty( $this->customize_manager ) ) { @@ -284,268 +210,12 @@ public function is_theme_active() { return $this->customize_manager->get_stylesheet() === $this->original_stylesheet; } - /** - * Determine whether the current snapshot can be previewed. - * - * @param Customize_Snapshot $snapshot Snapshot to check. - * @return true|\WP_Error Returns true if previewable, or `WP_Error` if cannot. - */ - public function should_import_and_preview_snapshot( Customize_Snapshot $snapshot ) { - global $pagenow; - - // Ignore if in the admin, but not Admin Ajax or Customizer. - if ( is_admin() && ! in_array( $pagenow, array( 'admin-ajax.php', 'customize.php' ), true ) ) { - return false; - } - - if ( is_wp_error( $this->get_theme_switch_error( $snapshot ) ) ) { - return false; - } - - // Abort if doing customize_save. - if ( $this->doing_customize_save_ajax() ) { - return false; - } - - // Abort if the snapshot was already published. - if ( $snapshot->saved() && 'publish' === get_post_status( $snapshot->post() ) ) { - return false; - } - - /* - * Prevent clobbering existing values (or previewing non-snapshotted values on frontend). - * Note that wp.customize.Snapshots.extendPreviewerQuery() will extend the - * previewer data to include the current snapshot UUID. - */ - if ( $this->customize_manager && count( $this->customize_manager->unsanitized_post_values() ) > 0 ) { - return false; - } - - return true; - } - - /** - * Populate post values and $_POST['customized'] wth the snapshot's data. - * - * Plugins used to have to dynamically register settings by inspecting the - * $_POST['customized'] var and manually re-parse and inspect to see if it - * contains settings that wouldn't be registered otherwise. This ensures - * that these plugins will continue to work. - * - * Note that this can't be called prior to the setup_theme action or else - * magic quotes may end up getting added twice. - * - * @see Customize_Snapshot_Manager::should_import_and_preview_snapshot() - */ - public function import_snapshot_data() { - /* - * We don't merge the snapshot data with any existing existing unsanitized - * post values since should_import_and_preview_snapshot returns false if - * there is any existing data in the Customizer state. This is to prevent - * clobbering existing values (or previewing non-snapshotted values on frontend). - * Note that wp.customize.Snapshots.extendPreviewerQuery() will extend the - * previewer data to include the current snapshot UUID. - */ - $snapshot_values = array_filter( - wp_list_pluck( $this->snapshot->data(), 'value' ), - function( $value ) { - return ! is_null( $value ); - } - ); - - // Populate input vars for back-compat. - $_POST['customized'] = wp_slash( wp_json_encode( $snapshot_values ) ); - // @codingStandardsIgnoreStart - $_REQUEST['customized'] = $_POST['customized']; - // @codingStandardsIgnoreEnd - - foreach ( $snapshot_values as $setting_id => $value ) { - $this->customize_manager->set_post_value( $setting_id, $value ); - } - } - - /** - * Is previewing settings. - * - * Plugins and themes may currently only use `is_customize_preview()` to - * decide whether or not they can store a value in the object cache. For - * example, see `Twenty_Eleven_Ephemera_Widget::widget()`. However, when - * viewing a snapshot on the frontend, the `is_customize_preview()` method - * will return `false`. Plugins and themes that store values in the object - * cache must either skip doing this when `$this->previewing` is `true`, - * or include the `$this->current_snapshot_uuid` (`current_snapshot_uuid()`) - * in the cache key when it is `true`. Note that if the `customize_preview_init` action - * was done, this means that the settings have been previewed in the regular - * Customizer preview. - * - * @see Twenty_Eleven_Ephemera_Widget::widget() - * @see WP_Customize_Manager::is_previewing_settings() - * @see is_previewing_settings() - * @see current_snapshot_uuid()() - * @see WP_Customize_Manager::customize_preview_init() - * @see Customize_Snapshot_Manager::$previewing_settings - * - * @return bool Whether previewing settings. - */ - public function is_previewing_settings() { - return $this->previewing_settings || did_action( 'customize_preview_init' ); - } - - /** - * Preview the snapshot settings. - * - * Note that this happens at `wp_loaded` action with priority 11 so that we - * can look at whether the `customize_preview_init` action was done. - */ - public function preview_snapshot_settings() { - if ( $this->is_previewing_settings() ) { - return; - } - $this->previewing_settings = true; - - /* - * Note that we need to preview the settings outside the Customizer preview - * and in the Customizer pane itself so we can load a previous snapshot - * into the Customizer. We have to prevent the previews from being added - * in the case of a customize_save action because then update_option() - * may short-circuit because it will detect that there are no changes to - * make. - */ - foreach ( $this->snapshot->settings() as $setting ) { - $setting->preview(); - $setting->dirty = true; - } - } - - /** - * Add filters for previewing widgets on the frontend. - */ - function add_widget_setting_preview_filters() { - /* - * Add WP_Customize_Widget component hooks which were short-circuited in 4.5 (r36611 for #35895). - * See https://core.trac.wordpress.org/ticket/35895 - */ - if ( isset( $this->customize_manager->widgets ) && ! current_user_can( 'edit_theme_options' ) ) { - $hooks = array( - 'customize_dynamic_setting_args' => array( - 'callback' => array( $this->customize_manager->widgets, 'filter_customize_dynamic_setting_args' ), - 'priority' => 10, - ), - 'widgets_init' => array( - 'callback' => array( $this->customize_manager->widgets, 'register_settings' ), - 'priority' => 95, - ), - 'customize_register' => array( - 'callback' => array( $this->customize_manager->widgets, 'schedule_customize_register' ), - 'priority' => 1, - ), - ); - foreach ( $hooks as $hook_name => $hook_args ) { - // Note that add_action()/has_action() are just aliases for add_filter()/has_filter(). - if ( ! has_filter( $hook_name, $hook_args['callback'] ) ) { - add_filter( $hook_name, $hook_args['callback'], $hook_args['priority'], PHP_INT_MAX ); - } - } - } - - /* - * Disable routine which fails because \WP_Customize_Manager::setup_theme() is - * never called in a frontend preview context, whereby the original_stylesheet - * is never set and so \WP_Customize_Manager::is_theme_active() will thus - * always return true because get_stylesheet() !== null. - * - * The action being removed is responsible for adding an option_sidebar_widgets - * filter \WP_Customize_Widgets::filter_option_sidebars_widgets_for_theme_switch() - * which causes the sidebars_widgets to be overridden with a global variable. - */ - if ( ! is_admin() ) { - remove_action( 'wp_loaded', array( $this->customize_manager->widgets, 'override_sidebars_widgets_for_theme_switch' ) ); - } - } - - /** - * Add filters for previewing nav menus on the frontend. - */ - public function add_nav_menu_setting_preview_filters() { - if ( isset( $this->customize_manager->nav_menus ) && ! current_user_can( 'edit_theme_options' ) ) { - $hooks = array( - 'customize_register' => array( - 'callback' => array( $this->customize_manager->nav_menus, 'customize_register' ), - 'priority' => 11, - ), - 'customize_dynamic_setting_args' => array( - 'callback' => array( $this->customize_manager->nav_menus, 'filter_dynamic_setting_args' ), - 'priority' => 10, - ), - 'customize_dynamic_setting_class' => array( - 'callback' => array( $this->customize_manager->nav_menus, 'filter_dynamic_setting_class' ), - 'priority' => 10, - ), - 'wp_nav_menu_args' => array( - 'callback' => array( $this->customize_manager->nav_menus, 'filter_wp_nav_menu_args' ), - 'priority' => 1000, - ), - 'wp_nav_menu' => array( - 'callback' => array( $this->customize_manager->nav_menus, 'filter_wp_nav_menu' ), - 'priority' => 10, - ), - ); - foreach ( $hooks as $hook_name => $hook_args ) { - // Note that add_action()/has_action() are just aliases for add_filter()/has_filter(). - if ( ! has_filter( $hook_name, $hook_args['callback'] ) ) { - add_filter( $hook_name, $hook_args['callback'], $hook_args['priority'], PHP_INT_MAX ); - } - } - } - - if ( isset( $this->customize_manager->nav_menus ) ) { - add_action( 'customize_register', array( $this, 'preview_early_nav_menus_in_customizer' ), 9 ); - } - } - - /** - * Preview nav menu settings early so that the sections and controls for snapshot values will be added properly. - * - * This must happen at `customize_register` priority prior to 11 which is when `WP_Customize_Nav_Menus::customize_register()` runs. - * This is only relevant when accessing the Customizer app (customize.php), as this is where sections/controls matter. - * - * @see \WP_Customize_Nav_Menus::customize_register() - */ - public function preview_early_nav_menus_in_customizer() { - if ( ! is_admin() ) { - return; - } - $this->customize_manager->add_dynamic_settings( array_keys( $this->snapshot()->data() ) ); - foreach ( $this->snapshot->settings() as $setting ) { - $is_nav_menu_setting = ( - $setting instanceof \WP_Customize_Nav_Menu_Setting - || - $setting instanceof \WP_Customize_Nav_Menu_Item_Setting - || - preg_match( '/^nav_menu_locations\[/', $setting->id ) - ); - if ( $is_nav_menu_setting ) { - $setting->preview(); - - /* - * The following is redundant because it will be done later in - * Customize_Snapshot_Manager::preview_snapshot_settings(). - * Also note that the $setting instance here will likely be - * blown away inside of WP_Customize_Nav_Menus::customize_register(), - * when add_setting is called there. What matters here is that - * preview() is called on the setting _before_ the logic inside - * WP_Customize_Nav_Menus::customize_register() runs, so that - * the nav menu sections will be created. - */ - $setting->dirty = true; - } - } - } - /** * Add snapshot UUID the Customizer return URL. * * If the Customizer was loaded with a snapshot UUID, let the return URL include this snapshot. + * + * @todo move to back compat? */ public function add_snapshot_uuid_to_return_url() { $should_add_snapshot_uuid = ( @@ -556,64 +226,15 @@ public function add_snapshot_uuid_to_return_url() { false === strpos( $this->customize_manager->get_return_url(), '/wp-admin/' ) ); if ( $should_add_snapshot_uuid ) { + $args_name = $this->get_front_uuid_param(); $args = array( - 'customize_snapshot_uuid' => $this->current_snapshot_uuid, + $args_name => $this->current_snapshot_uuid, ); $return_url = add_query_arg( array_map( 'rawurlencode', $args ), $this->customize_manager->get_return_url() ); $this->customize_manager->set_return_url( $return_url ); } } - /** - * Show the theme switch error if there is one. - */ - public function show_theme_switch_error() { - if ( empty( $this->snapshot ) ) { - return; - } - $error = $this->get_theme_switch_error( $this->snapshot ); - if ( is_wp_error( $error ) ) { - wp_die( esc_html( $error->get_error_message() ) ); - } - } - - /** - * Redirect when preview is not allowed for the current theme. - * - * @param Customize_Snapshot $snapshot Snapshot to check. - * @return \WP_Error|null - */ - public function get_theme_switch_error( Customize_Snapshot $snapshot ) { - - // Loading a snapshot into the context of a theme switch is not supported. - if ( ! $this->is_theme_active() ) { - return new \WP_Error( 'snapshot_theme_switch', __( 'Snapshot cannot be previewed when previewing a theme switch.', 'customize-snapshots' ) ); - } - - $snapshot_post = $snapshot->post(); - if ( ! $snapshot_post ) { - return null; - } - - $snapshot_theme = get_post_meta( $snapshot_post->ID, '_snapshot_theme', true ); - if ( ! empty( $snapshot_theme ) && get_stylesheet() !== $snapshot_theme ) { - return new \WP_Error( 'snapshot_theme_switched', __( 'Snapshot requested was made for a different theme and cannot be previewed with the current theme.', 'customize-snapshots' ) ); - } - - return null; - } - - /** - * Check whether customize_publish capability is granted in customize_save. - */ - public function check_customize_publish_authorization() { - if ( $this->doing_customize_save_ajax() && ! current_user_can( 'customize_publish' ) ) { - wp_send_json_error( array( - 'error' => 'customize_publish_unauthorized', - ) ); - } - } - /** * Encode JSON with pretty formatting. * @@ -622,9 +243,9 @@ public function check_customize_publish_authorization() { */ static public function encode_json( $value ) { $flags = 0; - if ( defined( '\JSON_PRETTY_PRINT' ) ) { - $flags |= \JSON_PRETTY_PRINT; - } + + $flags |= \JSON_PRETTY_PRINT; + if ( defined( '\JSON_UNESCAPED_SLASHES' ) ) { $flags |= \JSON_UNESCAPED_SLASHES; } @@ -646,17 +267,23 @@ public function enqueue_controls_scripts() { wp_enqueue_style( 'customize-snapshots' ); wp_enqueue_script( 'customize-snapshots' ); + + $post = null; + if ( $this->snapshot ) { - $post = $this->snapshot->post(); - $this->override_post_date_default_data( $post ); + $post_id = $this->customize_manager->changeset_post_id(); + $post = get_post( $post_id ); + if ( $post instanceof \WP_Post ) { + $this->override_post_date_default_data( $post ); + $edit_link = $this->snapshot->get_edit_link( $post ); + } } // Script data array. $exports = apply_filters( 'customize_snapshots_export_data', array( - 'action' => self::AJAX_ACTION, - 'uuid' => $this->snapshot ? $this->snapshot->uuid() : self::generate_uuid(), - 'editLink' => isset( $post ) ? get_edit_post_link( $post, 'raw' ) : '', + 'editLink' => isset( $edit_link ) ? $edit_link : '', 'publishDate' => isset( $post->post_date ) ? $post->post_date : '', + 'title' => isset( $post->post_title ) ? $post->post_title : '', 'postStatus' => isset( $post->post_status ) ? $post->post_status : '', 'currentUserCanPublish' => current_user_can( 'customize_publish' ), 'initialServerDate' => current_time( 'mysql', false ), @@ -664,96 +291,46 @@ public function enqueue_controls_scripts() { 'i18n' => array( 'saveButton' => __( 'Save', 'customize-snapshots' ), 'updateButton' => __( 'Update', 'customize-snapshots' ), - 'scheduleButton' => __( 'Schedule', 'customize-snapshots' ), 'submit' => __( 'Submit', 'customize-snapshots' ), 'submitted' => __( 'Submitted', 'customize-snapshots' ), - 'publish' => __( 'Publish', 'customize-snapshots' ), - 'published' => __( 'Published', 'customize-snapshots' ), 'permsMsg' => array( 'save' => __( 'You do not have permission to publish changes, but you can create a snapshot by clicking the "Save" button.', 'customize-snapshots' ), 'update' => __( 'You do not have permission to publish changes, but you can modify this snapshot by clicking the "Update" button.', 'customize-snapshots' ), ), + 'aysMsg' => __( 'Changes that you made may not be saved.', 'customize-snapshots' ), 'errorMsg' => __( 'The snapshot could not be saved.', 'customize-snapshots' ), 'errorTitle' => __( 'Error', 'customize-snapshots' ), 'collapseSnapshotScheduling' => __( 'Collapse snapshot scheduling', 'customize-snapshots' ), 'expandSnapshotScheduling' => __( 'Expand snapshot scheduling', 'customize-snapshots' ), ), - 'snapshotExists' => ( $this->snapshot && $this->snapshot->saved() ), ) ); - // Export data to JS. - wp_scripts()->add_data( - $this->plugin->slug, - 'data', - sprintf( 'var _customizeSnapshots = %s;', wp_json_encode( $exports ) ) - ); - } - - /** - * Set up Customizer preview. - */ - public function customize_preview_init() { - add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_preview_scripts' ) ); + wp_localize_script( 'customize-snapshots', '_customizeSnapshotsSettings', $exports ); } /** - * Enqueue Customizer preview scripts. + * Enqueue admin scripts. * - * @global \WP_Customize_Manager $wp_customize - */ - public function enqueue_preview_scripts() { - global $wp_customize; - - $handle = 'customize-snapshots-preview'; - wp_enqueue_script( $handle ); - wp_enqueue_style( $handle ); - - $exports = array( - 'home_url' => wp_parse_url( home_url( '/' ) ), - 'rest_api_url' => wp_parse_url( rest_url( '/' ) ), - 'admin_ajax_url' => wp_parse_url( admin_url( 'admin-ajax.php' ) ), - 'initial_dirty_settings' => array_keys( $wp_customize->unsanitized_post_values() ), - ); - wp_add_inline_script( - $handle, - sprintf( 'CustomizeSnapshotsPreview.init( %s )', wp_json_encode( $exports ) ), - 'after' - ); - } - - /** - * Enqueue Customizer frontend scripts. - */ - public function enqueue_frontend_scripts() { - if ( ! $this->snapshot || is_customize_preview() ) { - return; - } - $handle = 'customize-snapshots-frontend'; - wp_enqueue_script( $handle ); - - $exports = array( - 'uuid' => $this->snapshot ? $this->snapshot->uuid() : null, - 'home_url' => wp_parse_url( home_url( '/' ) ), - 'l10n' => array( - 'restoreSessionPrompt' => __( 'It seems you may have inadvertently navigated away from previewing a customized state. Would you like to restore the snapshot context?', 'customize-snapshots' ), - ), - ); - wp_add_inline_script( - $handle, - sprintf( 'CustomizeSnapshotsFrontend.init( %s )', wp_json_encode( $exports ) ), - 'after' - ); - } - - /** - * Include the snapshot nonce in the Customizer nonces. + * These files control the behavior and styling of links to remove settings. + * Published snapshots can't be edited, so these files are not needed on those pages. * - * @param array $nonces Nonces. - * @return array Nonces. + * @param String $hook Current page in admin. */ - public function filter_customize_refresh_nonces( $nonces ) { - $nonces['snapshot'] = wp_create_nonce( self::AJAX_ACTION ); - return $nonces; + public function enqueue_admin_scripts( $hook ) { + global $post; + $handle = 'customize-snapshots-admin'; + if ( ( 'post.php' === $hook ) && isset( $post->post_type ) && ( $this->get_post_type() === $post->post_type ) && ( 'publish' !== $post->post_status ) ) { + wp_enqueue_script( $handle ); + wp_enqueue_style( $handle ); + $exports = array( + 'deleteInputName' => $this->get_post_type() . '_remove_settings[]', + ); + wp_add_inline_script( + $handle, + sprintf( 'CustomizeSnapshotsAdmin.init( %s )', wp_json_encode( $exports ) ), + 'after' + ); + } } /** @@ -765,74 +342,6 @@ public function snapshot() { return $this->snapshot; } - /** - * Publish the snapshot snapshots via AJAX. - * - * Fires at `customize_save_after` to update and publish the snapshot. - * The logic in here is the inverse of save_settings_with_publish_snapshot. - * - * @see Customize_Snapshot_Manager::save_settings_with_publish_snapshot() - * - * @return bool Whether the snapshot was saved successfully. - */ - public function publish_snapshot_with_customize_save_after() { - $that = $this; - - if ( ! $this->snapshot || ! $this->doing_customize_save_ajax() ) { - return false; - } - - // This should never be reached due to Customize_Snapshot_Manager::check_customize_publish_authorization(). - if ( ! current_user_can( 'customize_publish' ) ) { - return false; - } - - $settings_data = array_map( - function( $value ) { - return compact( 'value' ); - }, - $this->customize_manager->unsanitized_post_values() - ); - $result = $this->snapshot->set( $settings_data ); - if ( ! empty( $result['errors'] ) ) { - add_filter( 'customize_save_response', function( $response ) use ( $result, $that ) { - $response['snapshot_errors'] = $that->prepare_errors_for_response( $result['errors'] ); - return $response; - } ); - return false; - } - - if ( ! $this->snapshot->post() || 'publish' !== $this->snapshot->post()->post_status ) { - $args = array( - 'status' => 'publish', - ); - - // Ensure a scheduled Snapshot is published. - if ( $this->snapshot->post() && 'future' === $this->snapshot->post()->post_status ) { - $args['edit_date'] = true; - $args['post_date'] = current_time( 'mysql', false ); - $args['post_date_gmt'] = current_time( 'mysql', true ); - } - - $r = $this->snapshot->save( $args ); - if ( is_wp_error( $r ) ) { - add_filter( 'customize_save_response', function( $response ) use ( $r, $that ) { - $response['snapshot_errors'] = $that->prepare_errors_for_response( $r ); - return $response; - } ); - return false; - } - } - - // Send the new UUID to the client for the next snapshot. - $class = __CLASS__; // For PHP 5.3. - add_filter( 'customize_save_response', function( $data ) use ( $class ) { - $data['new_customize_snapshot_uuid'] = $class::generate_uuid(); - return $data; - } ); - return true; - } - /** * Prepare snapshot post content for publishing. * @@ -847,7 +356,7 @@ public function prepare_snapshot_post_content_for_publish( $data ) { $is_publishing_snapshot = ( isset( $data['post_type'] ) && - Post_Type::SLUG === $data['post_type'] + $this->get_post_type() === $data['post_type'] && 'publish' === $data['post_status'] && @@ -890,300 +399,6 @@ public function filter_removable_query_args( $query_args ) { return $query_args; } - /** - * Publish snapshot changes when snapshot post is being published. - * - * The logic in here is the inverse of to publish_snapshot_with_customize_save_after. - * - * The meat of the logic that manipulates the post_content and validates the settings - * needs to be done in wp_insert_post_data filter in like a - * filter_insert_post_data_to_validate_published_snapshot method? This would - * have the benefit of reducing one wp_insert_post() call. - * - * @todo Consider using wp_insert_post_data to prevent double calls to wp_insert_post(). - * @see Customize_Snapshot_Manager::publish_snapshot_with_customize_save_after() - * - * @param string $new_status New status. - * @param string $old_status Old status. - * @param \WP_Post $post Post object. - * @return bool Whether the settings were saved. - */ - public function save_settings_with_publish_snapshot( $new_status, $old_status, $post ) { - - // Abort if not transitioning a snapshot post to publish from a non-publish status. - if ( Post_Type::SLUG !== $post->post_type || 'publish' !== $new_status || $new_status === $old_status ) { - return false; - } - - $this->ensure_customize_manager(); - - if ( $this->doing_customize_save_ajax() ) { - // Short circuit because customize_save ajax call is changing status. - return false; - } - - if ( ! did_action( 'customize_register' ) ) { - /* - * When running from CLI or Cron, we have to remove the action because - * it will get added with a default priority of 10, after themes and plugins - * have already done add_action( 'customize_register' ), resulting in them - * being called first at the priority 10. So we manually call the - * prerequisite function WP_Customize_Manager::register_controls() and - * remove it from being called when the customize_register action fires. - */ - remove_action( 'customize_register', array( $this->customize_manager, 'register_controls' ) ); - $this->customize_manager->register_controls(); - - /* - * Unfortunate hack to prevent \WP_Customize_Widgets::customize_register() - * from calling preview() on settings. This needs to be cleaned up in core. - * It is important for previewing to be prevented because if an option has - * a filter it will short-circuit when an update is attempted since it - * detects that there is no change to be put into the DB. - * See: https://github.com/xwp/wordpress-develop/blob/e8c58c47db1421a1d0b2afa9ad4b9eb9e1e338e0/src/wp-includes/class-wp-customize-widgets.php#L208-L217 - */ - if ( ! defined( 'DOING_AJAX' ) ) { - define( 'DOING_AJAX', true ); - } - $_REQUEST['action'] = 'customize_save'; - - /** This action is documented in wp-includes/class-wp-customize-manager.php */ - do_action( 'customize_register', $this->customize_manager ); - - // undefine( 'DOING_AJAX' )... just kidding. This is the end of the unfortunate hack and it should be fixed in Core. - unset( $_REQUEST['action'] ); - } - $snapshot_content = $this->post_type->get_post_content( $post ); - - if ( method_exists( $this->customize_manager, 'validate_setting_values' ) ) { - /** This action is documented in wp-includes/class-wp-customize-manager.php */ - do_action( 'customize_save_validation_before', $this->customize_manager ); - } - - $setting_ids = array_keys( $snapshot_content ); - $this->customize_manager->add_dynamic_settings( $setting_ids ); - - /** This action is documented in wp-includes/class-wp-customize-manager.php */ - do_action( 'customize_save', $this->customize_manager ); - - /** - * Settings to save. - * - * @var \WP_Customize_Setting[] - */ - $settings = array(); - - $publish_error_count = 0; - foreach ( $snapshot_content as $setting_id => &$setting_params ) { - - // Missing value error. - if ( ! isset( $setting_params['value'] ) || is_null( $setting_params['value'] ) ) { - if ( ! is_array( $setting_params ) ) { - if ( ! empty( $setting_params ) ) { - $setting_params = array( 'value' => $setting_params ); - } else { - $setting_params = array(); - } - } - $setting_params['publish_error'] = 'null_value'; - $publish_error_count += 1; - continue; - } - - // Unrecognized setting error. - $this->customize_manager->set_post_value( $setting_id, $setting_params['value'] ); - $setting = $this->customize_manager->get_setting( $setting_id ); - if ( ! ( $setting instanceof \WP_Customize_Setting ) ) { - $setting_params['publish_error'] = 'unrecognized_setting'; - $publish_error_count += 1; - continue; - } - - // Validate setting value. - if ( method_exists( $setting, 'validate' ) ) { - $validity = $setting->validate( $setting_params['value'] ); - if ( is_wp_error( $validity ) ) { - $setting_params['publish_error'] = $validity->get_error_code(); - $publish_error_count += 1; - continue; - } - } - - // Validate sanitized setting value. - $sanitized_value = $setting->sanitize( $setting_params['value'] ); - if ( is_null( $sanitized_value ) || is_wp_error( $sanitized_value ) ) { - $setting_params['publish_error'] = is_wp_error( $sanitized_value ) ? $sanitized_value->get_error_code() : 'invalid_value'; - $publish_error_count += 1; - continue; - } - - $settings[] = $setting; - unset( $setting_params['publish_error'] ); - } - - // Handle error scenarios. - if ( $publish_error_count > 0 ) { - $update_setting_args = array( - 'ID' => $post->ID, - 'post_content' => Customize_Snapshot_Manager::encode_json( $snapshot_content ), - 'post_status' => 'pending', - ); - wp_update_post( wp_slash( $update_setting_args ) ); - update_post_meta( $post->ID, 'snapshot_error_on_publish', $publish_error_count ); - - add_filter( 'redirect_post_location', function( $location ) { - $location = add_query_arg( 'snapshot_error_on_publish', '1', $location ); - return $location; - } ); - return false; - } - - /* - * Change all setting capabilities temporarily to 'exist' to allow them to - * be saved regardless of current user, such as when WP-Cron is publishing - * the snapshot post if it was scheduled. It is safe to do this because - * a setting can only be written into a snapshot by users who have the - * capability, so after it has been added to a snapshot it is good to commit. - */ - $existing_caps = wp_list_pluck( $settings, 'capability' ); - foreach ( $settings as $setting ) { - $setting->capability = 'exist'; - } - - // Persist the settings in the DB. - foreach ( $settings as $setting ) { - $setting->save(); - } - - // Restore setting capabilities. - foreach ( $existing_caps as $setting_id => $existing_cap ) { - $settings[ $setting_id ]->capability = $existing_cap; - } - - /** This action is documented in wp-includes/class-wp-customize-manager.php */ - do_action( 'customize_save_after', $this->customize_manager ); - - // Remove any previous error on setting. - delete_post_meta( $post->ID, 'snapshot_error_on_publish' ); - - return true; - } - - /** - * Update snapshots via AJAX. - */ - public function handle_update_snapshot_request() { - if ( ! check_ajax_referer( self::AJAX_ACTION, 'nonce', false ) ) { - status_header( 400 ); - wp_send_json_error( 'bad_nonce' ); - } elseif ( ! current_user_can( 'customize' ) ) { - status_header( 403 ); - wp_send_json_error( 'customize_not_allowed' ); - } elseif ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== $_SERVER['REQUEST_METHOD'] ) { // WPCS: input var ok. - status_header( 405 ); - wp_send_json_error( 'bad_method' ); - } elseif ( empty( $this->current_snapshot_uuid ) ) { - status_header( 400 ); - wp_send_json_error( 'invalid_customize_snapshot_uuid' ); - } elseif ( 0 === count( $this->customize_manager->unsanitized_post_values() ) ) { - status_header( 400 ); - wp_send_json_error( 'missing_snapshot_customized' ); - } - - if ( isset( $_POST['status'] ) ) { // WPCS: input var ok. - $status = sanitize_key( $_POST['status'] ); - } else { - $status = 'draft'; - } - if ( ! in_array( $status, array( 'draft', 'pending', 'future' ), true ) ) { - status_header( 400 ); - wp_send_json_error( 'bad_status' ); - } - if ( 'future' === $status && ! current_user_can( 'customize_publish' ) ) { - status_header( 400 ); - wp_send_json_error( 'customize_not_allowed' ); - } - $publish_date = isset( $_POST['publish_date'] ) ? $_POST['publish_date'] : ''; - if ( 'future' === $status ) { - $publish_date_obj = new \DateTime( $publish_date ); - $current_date = new \DateTime(); - if ( empty( $publish_date ) || ! $publish_date_obj || $publish_date > $current_date ) { - status_header( 400 ); - wp_send_json_error( 'bad_schedule_time' ); - } - } - - // Prevent attempting to modify a "locked" snapshot (a published one). - $post = $this->snapshot->post(); - if ( $post && 'publish' === $post->post_status ) { - wp_send_json_error( array( - 'errors' => array( - 'already_published' => array( - 'message' => __( 'The snapshot has already published so it is locked.', 'customize-snapshots' ), - ), - ), - ) ); - } - - // Set the snapshot UUID. - $post_type = get_post_type_object( Post_Type::SLUG ); - $authorized = ( $post ? - current_user_can( $post_type->cap->edit_post, $post->ID ) : - current_user_can( 'customize' ) - ); - if ( ! $authorized ) { - status_header( 403 ); - wp_send_json_error( 'unauthorized' ); - } - - $data = array( - 'errors' => null, - ); - $settings_data = array_map( - function( $value ) { - return compact( 'value' ); - }, - $this->customize_manager->unsanitized_post_values() - ); - $r = $this->snapshot->set( $settings_data ); - if ( method_exists( $this->customize_manager, 'prepare_setting_validity_for_js' ) ) { - $data['setting_validities'] = array_map( - array( $this->customize_manager, 'prepare_setting_validity_for_js' ), - $r['validities'] - ); - } - - if ( $r['errors'] ) { - $data['errors'] = $this->prepare_errors_for_response( $r['errors'] ); - wp_send_json_error( $data ); - } - $args = array( - 'status' => $status, - ); - $args['edit_date'] = current_time( 'mysql' ); - - if ( isset( $publish_date_obj ) && 'future' === $status ) { - $args['post_date'] = $publish_date_obj->format( 'Y-m-d H:i:s' ); - $args['post_date_gmt'] = '0000-00-00 00:00:00'; - } else { - $args['post_date_gmt'] = $args['post_date'] = '0000-00-00 00:00:00'; - } - $r = $this->snapshot->save( $args ); - - $post = $this->snapshot->post(); - if ( $post ) { - $data['edit_link'] = get_edit_post_link( $post, 'raw' ); - $data['snapshot_publish_date'] = $post->post_date; - } - - if ( is_wp_error( $r ) ) { - $data['errors'] = $this->prepare_errors_for_response( $r ); - wp_send_json_error( $data ); - } - - wp_send_json_success( $data ); - } - /** * Prepare a WP_Error for sending to JS. * @@ -1280,12 +495,12 @@ public function replace_customize_link( $wp_admin_bar ) { return; } - // Remove customize_snapshot_uuuid query param from url param to be previewed in Customizer. + // Remove customize_snapshot_uuid query param from url param to be previewed in Customizer. $preview_url_query_params = array(); $preview_url_parsed = wp_parse_url( $customize_node->href ); parse_str( $preview_url_parsed['query'], $preview_url_query_params ); if ( ! empty( $preview_url_query_params['url'] ) ) { - $preview_url_query_params['url'] = remove_query_arg( array( 'customize_snapshot_uuid' ), $preview_url_query_params['url'] ); + $preview_url_query_params['url'] = remove_query_arg( array( $this->get_front_uuid_param() ), $preview_url_query_params['url'] ); $customize_node->href = preg_replace( '/(?<=\?).*?(?=#|$)/', build_query( $preview_url_query_params ), @@ -1295,7 +510,7 @@ public function replace_customize_link( $wp_admin_bar ) { // Add customize_snapshot_uuid param as param to customize.php itself. $customize_node->href = add_query_arg( - array( 'customize_snapshot_uuid' => $this->current_snapshot_uuid ), + array( $this->get_customize_uuid_param() => $this->current_snapshot_uuid ), $customize_node->href ); @@ -1335,7 +550,7 @@ public function add_post_edit_screen_link( $wp_admin_bar ) { $wp_admin_bar->add_menu( array( 'id' => 'inspect-customize-snapshot', 'title' => __( 'Inspect Snapshot', 'customize-snapshots' ), - 'href' => get_edit_post_link( $post->ID, 'raw' ), + 'href' => $this->snapshot->get_edit_link( $post ), 'meta' => array( 'class' => 'ab-item ab-customize-snapshots-item', ), @@ -1354,7 +569,7 @@ public function add_snapshot_exit_link( $wp_admin_bar ) { $wp_admin_bar->add_menu( array( 'id' => 'exit-customize-snapshot', 'title' => __( 'Exit Snapshot Preview', 'customize-snapshots' ), - 'href' => remove_query_arg( 'customize_snapshot_uuid' ), + 'href' => remove_query_arg( $this->get_front_uuid_param() ), 'meta' => array( 'class' => 'ab-item ab-customize-snapshots-item', ), @@ -1395,9 +610,7 @@ public function remove_all_non_snapshot_admin_bar_links( $wp_admin_bar ) { * Underscore (JS) templates for dialog windows. */ public function render_templates() { - $data = $this->get_month_choices(); - - $description = __( 'Schedule your changes to publish (go live) at a future date.', 'customize-snapshots' ); + $this->add_edit_box_template(); ?> - + + + + + + + + + -
%s %s ', esc_html__( 'Click', 'customize-snapshots' ), esc_html__( 'Customize snapshot migration complete!', 'customize-snapshots' ), esc_html__( 'here', 'customize-snapshots' ), esc_html__( 'to start migration.', 'customize-snapshots' ) ); ?> +
+'; + /* translators: 1 is the theme the snapshot was created for */ echo sprintf( esc_html__( 'This snapshot was made when a different theme was active (%1$s), so currently it cannot be edited.', 'customize-snapshots' ), esc_html( $snapshot_theme ) ); echo '
'; } elseif ( 'publish' !== $post->post_status ) { echo ''; $args = array( - 'customize_snapshot_uuid' => $post->post_name, + static::CUSTOMIZE_UUID_PARAM_NAME => $post->post_name, ); $customize_url = add_query_arg( array_map( 'rawurlencode', $args ), wp_customize_url() ); echo sprintf( @@ -351,6 +350,7 @@ public function render_data_metabox( $post ) { echo '
' . esc_html( $setting_id ) . '
';
+ echo '' . esc_html__( 'Remove setting', 'customize-snapshots' ) . '';
// Show error message when there was a publishing error.
if ( isset( $setting_params['publish_error'] ) ) {
@@ -411,7 +412,7 @@ public function render_data_metabox( $post ) {
echo $preview; // WPCS: xss ok.
echo '%s