WordPress.org

Making WordPress.org

Changeset 4280


Ignore:
Timestamp:
10/21/2016 04:10:02 PM (4 years ago)
Author:
iandunn
Message:

WordCamp Site Cloner: Convert to REST/Backbone to improve scalability

See #1112
Props prettyboymp

Location:
sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-site-cloner
Files:
2 added
2 deleted
6 edited

Legend:

Unmodified
Added
Removed
  • sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-site-cloner/includes/site-control.php

    r1715 r4280  
    11<?php
    22
     3/**
     4 * Custom Customizer Control for Search WordCamp sites to clone
     5 */
     6
    37namespace WordCamp\Site_Cloner;
    4 
    58defined( 'WPINC' ) or die();
    69
    7 /**
    8  * Custom Customizer Control for a WordCamp site
    9  */
    1010class Site_Control extends \WP_Customize_Control {
    11     public $site_id, $site_name, $screenshot_url, $theme_slug;
    12     public $settings = 'wcsc_source_site_id';
    13     public $section  = 'wcsc_sites';
     11    public function __construct( $manager, $id, $args = array() ) {
     12        parent::__construct( $manager, $id, $args );
     13
     14        $this->capability = 'edit_theme_options';
     15        $this->section    = 'wcsc_sites';
     16    }
    1417
    1518    /**
     
    1720     */
    1821    public function enqueue() {
     22        add_action( 'customize_controls_print_footer_scripts', array( $this, 'print_view_templates' ) );
     23
    1924        wp_enqueue_style(  'wordcamp-site-cloner' );
    2025        wp_enqueue_script( 'wordcamp-site-cloner' );
     
    2530     */
    2631    public function render_content() {
    27         $preview_url = add_query_arg(
    28             array(
    29                 'theme'               => rawurlencode( $this->theme_slug ),
    30                 'wcsc_source_site_id' => rawurlencode( $this->site_id ),
    31             ),
    32             admin_url( 'customize.php' )
    33         );
     32        require_once( dirname( __DIR__ ) . '/templates/site-control.php' );
     33    }
    3434
    35         require( dirname( __DIR__ ) . '/templates/site-control.php' );
     35    /**
     36     * Render the control's Underscores templates
     37     */
     38    public function print_view_templates() {
     39        require_once( dirname( __DIR__ ) . '/templates/site-option.php'  );
     40        require_once( dirname( __DIR__ ) . '/templates/site-filters.php' );
    3641    }
    3742}
  • sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-site-cloner/includes/source-site-id-setting.php

    r1715 r4280  
    22
    33namespace WordCamp\Site_Cloner;
    4 
    54defined( 'WPINC' ) or die();
    65
     
    1615    public $default           = 0;
    1716    public $sanitize_callback = 'absint';
     17
    1818    protected $preview_source_site_id;
    1919
     
    2929        add_filter( 'get_post_metadata',       array( $this, 'preview_jetpack_postmeta' ), 10, 4 );
    3030        add_filter( 'safecss_skip_stylesheet', array( $this, 'preview_skip_stylesheet'  ) );
     31
     32        // Disable the current site's Custom CSS from being output
     33        remove_action( 'wp_head', array( 'Jetpack_Custom_CSS', 'link_tag' ), 101 );
    3134    }
    3235
     
    110113     *
    111114     * @param int $source_site_id
     115     *
     116     * @return null
    112117     */
    113118    protected function update( $source_site_id ) {
  • sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-site-cloner/templates/site-control.php

    r1715 r4280  
    1 <?php defined( 'WPINC' ) or die(); ?>
     1<?php
    22
    3 <div id="wcsc-site-<?php echo esc_attr( $this->site_id ); ?>" class="wcscSite" data-preview-url="<?php echo esc_url( $preview_url ); ?>">
    4     <div class="wcsc-site-screenshot">
    5         <img src="<?php echo esc_url( $this->screenshot_url ); ?>" alt="<?php echo esc_attr( $this->site_name ); ?>" />
    6     </div>
     3/**
     4 * Top level template for the output of the Site Cloner Customizer Control
     5 */
    76
    8     <h3 class="wcsc-site-name">
    9         <?php echo esc_html( $this->site_name ); ?>
     7namespace WordCamp\Site_Cloner;
     8defined( 'WPINC' ) or die();
     9
     10?>
     11
     12<div id="wcsc-cloner">
     13    <h3>
     14        <?php esc_html_e( 'WordCamp Sites', 'wordcamporg' ); ?>
     15        <span id="wcsc-sites-count" class="title-count wcsc-sites-count"></span>
    1016    </h3>
    1117
    12     <span id="live-preview-label-<?php echo esc_attr( $this->site_id ); ?>" class="wcsc-live-preview-label">
    13         <?php _e( 'Live Preview', 'wordcamporg' ); ?>
    14     </span>
     18    <div class="filters"></div>
     19
     20    <div class="wcsc-search">
     21        <ul id="wcsc-results"></ul>
     22    </div>
    1523</div>
  • sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-site-cloner/wordcamp-site-cloner.css

    r1715 r4280  
    1 .control-section-wcsc-sites {
    2     padding: 0 8px;
     1
     2.wcsc-filter {
     3    margin-bottom: 10px;
    34}
    45
    5     #wcsc-sites {
    6         overflow: auto;
    7     }
    8 
    9         .wcscSite {
     6        .wcsc-site {
    107            position: relative;
    118            cursor: pointer;
    129            border: 1px solid #DEDEDE;
    1310            box-shadow: 0 1px 1px -1px rgba( 0, 0, 0, 0.1 );
     11            margin-top: 5px;
    1412        }
    1513
     
    1816            }
    1917
    20                 .wcscSite:hover .wcsc-site-screenshot {
     18                .wcsc-site:hover .wcsc-site-screenshot {
    2119                    opacity: 0.4;
    2220                }
    2321
    24             .wcsc-live-preview-label {
     22            .wcsc-live-preview-label,
     23            .wcsc-previewing-label {
    2524                opacity: 0;
    2625                position: absolute;
     
    3938            }
    4039
    41                 .wcscSite:hover .wcsc-live-preview-label {
     40                .wcsc-site:hover .wcsc-live-preview-label,
     41                .wcsc-site .wcsc-previewing-label {
    4242                    opacity: 1;
    4343                }
  • sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-site-cloner/wordcamp-site-cloner.js

    r2933 r4280  
    1 ( function( wp, $ ) {
     1( function( wp, $, Backbone, win, settings ) {
    22    'use strict';
    33
     
    66    }
    77
    8     var api = wp.customize;
     8    wp.customize.WordCamp = wp.customize.WordCamp || {};
     9
     10    var api  = wp.customize,
     11        wcsc = api.WordCamp.SiteCloner = {
     12            models      : {},
     13            views       : {},
     14            collections : {},
     15            routers     : {},
     16            settings    : {}
     17        };
     18
     19    wcsc.settings = settings || {};
     20
     21    // Model for a single site
     22    wcsc.models.Site = Backbone.Model.extend( {
     23        idAttribute : 'site_id',
     24
     25        defaults : {
     26            'active' : false
     27        }
     28    } );
     29
     30    // Model representing the filter state for searching/filtering sites
     31    wcsc.models.SearchFilter = Backbone.Model.extend( {
     32        's'                : '',
     33        'theme_slug'       : '',
     34        'year'             : '',
     35        'css_preprocessor' : ''
     36    } );
     37
     38    // Top level view for the Site Cloner Control
     39    wcsc.views.SiteSearch = Backbone.View.extend( {
     40        el : '#wcsc-cloner .wcsc-search',
     41
     42        // Index of the currently viewed page of results
     43        page : 0,
     44
     45        initialize : function( options ) {
     46            // Update scroller position
     47            _.bindAll( this, 'scroller' );
     48
     49            // Container that will be scrolled within
     50            this.$container = $( '#wcsc-cloner' ).parents( 'ul.accordion-section-content' );
     51            // Bind scrolling within the container to check for infinite scroll
     52            this.$container.bind( 'scroll', _.throttle( this.scroller, 300 ) );
     53
     54            // The model and view for filtering the site results
     55            this.filterView = new wcsc.views.SearchFilters( {
     56                model  : this.collection.searchFilter,
     57                parent : this
     58            } );
     59
     60            // View for listing the matching sites
     61            this.resultsView = new wcsc.views.SearchResults( {
     62                collection : this.collection,
     63                parent     : this
     64            } );
     65        },
     66
     67        render : function() {
     68            this.filterView.render();
     69            this.resultsView.render();
     70
     71            this.$el.empty().append( this.resultsView.el );
     72        },
     73
     74        /**
     75         * Checks if a user has reached the bottom of the list and triggers a scroll event to show more sites if
     76         * needed.
     77         */
     78        scroller : function() {
     79            var visibleBottom, threshold, elementHeight, containerHeight, scrollTop;
     80
     81            scrollTop       = this.$container.scrollTop();
     82            containerHeight = this.$container.innerHeight();
     83            elementHeight   = this.$container.get( 0 ).scrollHeight;
     84
     85            visibleBottom = scrollTop + containerHeight;
     86            threshold     = Math.round( elementHeight * 0.9 );
     87
     88            if ( visibleBottom > threshold ) {
     89                this.trigger( 'wcsc:scroll' );
     90            }
     91        }
     92    } );
     93
     94    // Collection representing the list of cloneable sites
     95    wcsc.collections.Sites = Backbone.Collection.extend( {
     96        model : wcsc.models.Site,
     97        url   : wcsc.settings.apiUrl,
     98
     99        initialize : function( options ) {
     100            this.searchFilter = options.searchFilter || {};
     101
     102            this.listenTo( this.searchFilter, 'change', this.applyFilter );
     103        },
     104
     105        // Filter this collection by the updated searchFilter attributes
     106        applyFilter : function() {
     107            var filters       = this.searchFilter.toJSON(),
     108                activeFilters = _.pick( filters, _.identity ),
     109                term          = '',
     110                sites;
     111
     112            // Nothing actually changed, so don't update the collection
     113            if ( _.isEmpty( this.searchFilter.changedAttributes() ) ) {
     114                return;
     115            }
     116
     117            // No active filters. Reset to the full list and bail
     118            if ( _.isEmpty( activeFilters ) ) {
     119                this.resetCanonical();
     120                return;
     121            }
     122
     123            this.resetCanonical( { silent: true } );
     124
     125            // Remove the search query restriction since we already filtered by word matches above
     126            if ( activeFilters.s ) {
     127                term = activeFilters.s;
     128
     129                delete activeFilters.s;
     130            }
     131
     132            sites = this.where( activeFilters );
     133
     134            if ( term ) {
     135                sites = this.filterBySearch( sites, term );
     136            }
     137
     138            this.reset( sites );
     139        },
     140
     141        // Internal method for filtering sites by search terms
     142        filterBySearch : function( sites, term ) {
     143            var match, name;
     144
     145            // Escape the term string for RegExp meta characters
     146            term = term.replace( /[-\/\\^$*+?.()|[\]{}]/g, '\\$&' );
     147
     148            // Consider spaces as word delimiters and match the whole string
     149            // so matching terms can be combined
     150            term  = term.replace( / /g, ')(?=.*' );
     151            match = new RegExp( '^(?=.*' + term + ').+', 'i' );
     152
     153            return _.filter( sites, function( site ) {
     154                name = site.get( 'name' ).replace( /(<([^>]+)>)/ig, '' );
     155
     156                return match.test( name );
     157            } );
     158        },
     159
     160        paginate : function( pageIndex ) {
     161            var collection = this;
     162
     163            pageIndex  = pageIndex || 0;
     164
     165            collection = _( collection.rest( 20 * pageIndex ) );
     166            collection = _( collection.first( 20 ) );
     167
     168            return collection;
     169        },
     170
     171        // Resets the site collection dataset to the canonical list originally pulled from the api
     172        resetCanonical : function( options ) {
     173            var activeSite,
     174                activeSiteId = api( 'wcsc_source_site_id' ).get();
     175
     176            options = options || {};
     177
     178            this.reset( wcsc.settings.siteData, options );
     179
     180            // Restore the currently active site
     181            if ( activeSiteId ) {
     182                activeSite = this.find( { site_id : activeSiteId } );
     183
     184                if ( 'undefined' !== typeof activeSite ) {
     185                    activeSite.set( { active: true } );
     186                }
     187            }
     188        }
     189    } );
     190
     191    // View for a single site
     192    wcsc.views.Site = Backbone.View.extend( {
     193        className : 'wcsc-site',
     194        html      : wp.template( 'wcsc-site-option' ),
     195        touchDrag : false,
     196
     197        attributes : function() {
     198            return {
     199                'id'           : 'wcsc-site-' + this.model.get( 'site_id' ),
     200                'data-site-id' : this.model.get( 'site_id' )
     201            }
     202        },
     203
     204        events : {
     205            'click'     : 'preview',
     206            'keydown'   : 'preview',
     207            'touchend'  : 'preview',
     208            'touchmove' : 'preventPreview'
     209        },
     210
     211        initialize : function( options ) {
     212            this.parent = options.parent;
     213
     214            this.listenTo( this.model, 'change', this.render );
     215            this.render();
     216        },
     217
     218        render : function() {
     219            this.$el.html( this.html( this.model.toJSON() ) );
     220        },
     221
     222        preventPreview : function() {
     223            this.touchDrag = true;
     224        },
     225
     226        preview : function( event ) {
     227            event = event || window.event;
     228
     229            // Ignore touches caused by scrolling
     230            if ( this.touchDrag === true ) {
     231                this.touchDrag = false;
     232            }
     233
     234            event.preventDefault();
     235
     236            this.$el.trigger( 'wcsc:previewSite', this.model );
     237        }
     238    } );
     239
     240    // View for the site results list
     241    wcsc.views.SearchResults = Backbone.View.extend( {
     242        className: 'wcsc-results',
     243
     244        initialize : function( options ) {
     245            var self = this;
     246
     247            this.parent     = options.parent;
     248            this.$siteCount = $( '#wcsc-sites-count' );
     249
     250            // Re-render the view whenever a collection change is complete
     251            this.listenTo( this.collection, 'reset', function() {
     252                self.parent.page = 0;
     253                self.render( this );
     254            } );
     255
     256            this.listenTo( this.parent, 'wcsc:scroll', function() {
     257                self.renderSites( self.parent.page );
     258            } );
     259        },
     260
     261        render : function() {
     262            this.$el.empty();
     263            this.renderSites( this.parent.page );
     264            this.$siteCount.text( this.collection.length );
     265        },
     266
     267        renderSites : function( page ) {
     268            var self = this;
     269
     270            // Get a collection of just the requested page
     271            this.instance = this.collection.paginate( page );
     272
     273            if ( this.instance.size() === 0 ) {
     274                this.parent.trigger( 'wcsc:end' );
     275                return;
     276            }
     277
     278            this.instance.each( function( site ) {
     279                var siteView = new wcsc.views.Site( {
     280                    model  : site,
     281                    parent : self
     282                } );
     283
     284                siteView.render();
     285
     286                self.$el.append( siteView.el );
     287            } );
     288
     289            this.parent.page++;
     290        }
     291
     292    } );
     293
     294    // View for the search and dropdown filters
     295    wcsc.views.SearchFilters = Backbone.View.extend( {
     296        el        : '#wcsc-cloner .filters',
     297        className : 'wscs-filters',
     298        html      : wp.template( 'wcsc-site-filters' ),
     299
     300        events : {
     301            "input #wcsc-filter-search-input" : "search",
     302            "keyup #wcsc-filter-search-input" : "search",
     303            "change select"                   : "applyFilter"
     304        },
     305
     306        initialize : function( options ) {
     307            this.parent = options.parent;
     308        },
     309
     310        render : function() {
     311            var data = {};
     312
     313            data.themeOptions        = wcsc.settings.themes;
     314            data.yearOptions         = _.uniq( this.parent.collection.pluck( 'year' ) ).sort();
     315            data.preprocessorOptions = _.uniq( this.parent.collection.pluck( 'css_preprocessor' ) ).sort();
     316
     317            this.$el.html( this.html( data ) );
     318
     319            this.$searchInput        = $( '#wcsc-filter-search-input' );
     320            this.$themeFilter        = $( '#wcsc-filter-theme_slug' );
     321            this.$yearFilter         = $( '#wcsc-filter-year' );
     322            this.$preprocessorFilter = $( '#wcsc-filter-css_preprocessor' );
     323        },
     324
     325        search : function( event ) {
     326            // Clear on escape.
     327            if ( event.type === 'keyup' && event.which === 27 ) {
     328                event.target.value = '';
     329            }
     330
     331            /**
     332             * Since doSearch is debounced, it will only run when user input comes to a rest
     333             */
     334            this.doSearch( event );
     335        },
     336
     337        doSearch : _.debounce( function( event ) {
     338            this.model.set( 's', event.target.value );
     339        }, 500 ),
     340
     341        applyFilter : function( event ) {
     342            var $target = $( event.target ),
     343                value   = $target.val(),
     344                filter  = $target.data( 'filter' );
     345
     346            this.model.set( filter, value );
     347        },
     348
     349        // Set the inputs to the set of filters as triggered by the router on initial load
     350        setInputs : function( filters ) {
     351            this.model.set( filters, { silent : true } );
     352
     353            this.$searchInput.val(        this.model.get( 's'                ) );
     354            this.$themeFilter.val(        this.model.get( 'theme_slug'       ) );
     355            this.$yearFilter.val(         this.model.get( 'year'             ) );
     356            this.$preprocessorFilter.val( this.model.get( 'css_preprocessor' ) );
     357
     358            this.model.trigger( 'change', this.model );
     359        }
     360    } );
    9361
    10362    /**
    11      * The Clone Another WordCamp panel
     363     * Sets up a listener to store the user's selected filters and search, so that a user's position can be
     364     * restored as well as possible after a theme changes causes a full refresh.
    12365     */
    13     api.panelConstructor.wcscPanel = api.Panel.extend( {
    14         /**
    15          * Initialize the panel after it's loaded
    16          *
    17          * Ideally, the Previewer would be set to the requested site ID during the initial PHP request, rather than
    18          * loading the host site in the Previewer, and then refreshing it to use the requested site. That became a
    19          * rabbit hole, though, so it's done this way instead.
    20          */
     366    wcsc.routers.FilterState = Backbone.Router.extend( {
     367        routes : {
     368            'wcsc?*filters' : 'applyFilters'
     369        },
     370
     371        initialize : function( options ) {
     372            this.parent = options.parent;
     373
     374            // Any time the collection is reset, we need to update the displayed route
     375            this.listenTo( this.parent.view.collection, 'reset', this.updateLocation );
     376        },
     377
     378        // Applies the filters set in the query string to the view
     379        applyFilters : function( queryString ) {
     380            var filters = deserializeQueryString( queryString );
     381
     382            this.parent.view.filterView.setInputs( filters );
     383        },
     384
     385        updateLocation : function() {
     386            var filters       = this.parent.view.collection.searchFilter.toJSON(),
     387                activeFilters = _.pick( filters, _.identity ),
     388                queryString   = $.param( activeFilters );
     389
     390            this.navigate( 'wcsc?' + queryString );
     391        }
     392    } );
     393
     394    // Customizer Control wrapping the site search applet
     395    api.controlConstructor.wcscSearch = api.Control.extend( {
    21396        ready : function() {
    22             var urlParams = getUrlParams( window.location.href );
    23 
     397            var filter = new wcsc.models.SearchFilter(); // Top level model representing the current filter applied to the collection
     398
     399            this.siteCollection = new wcsc.collections.Sites( { searchFilter : filter } );
     400
     401            // Fill the site collection and setup search when complete
     402            this.siteCollection.fetch( {
     403                success : this.setupSearch.bind( this )
     404            } );
     405        },
     406
     407        // Initialize the site search instance for cloning other sites
     408        setupSearch : function() {
     409            var currentSite,
     410                control   = this,
     411                urlParams = getUrlParams( win.location.href );
     412
     413            // Set a canonical array of all sites prior to filtering
     414            wcsc.settings.siteData = this.siteCollection.toJSON();
     415
     416            // If the wcsc_source_site_id is set, it;s most likely from a user previewing a site, so bring them back
    24417            if ( urlParams.hasOwnProperty( 'wcsc_source_site_id' ) ) {
    25                 this.expand();
    26                 api( 'wcsc_source_site_id' ).set( urlParams.wcsc_source_site_id );
    27             }
    28         }
    29     } );
    30 
    31     /**
    32      * Custom control representing a site that can be previewed/imported
    33      */
    34     api.controlConstructor.wcscSite = api.Control.extend( {
    35         /**
    36          * Initialize the control after it's loaded
    37          */
    38         ready : function() {
    39             this.container.on( 'click', '.wcscSite', this.previewSite );
    40         },
    41 
    42         /**
    43          * Preview the selected site
    44          *
    45          * If the site is using a different theme, then reload the entire Customizer with the theme URL parameter
    46          * set, so that the Theme Switcher will handle previewing the new theme for us. Otherwise just set the ID
    47          * to refresh the Previewer with the current theme and the new site's CSS, etc.
    48          *
    49          * @param {object} event
    50          */
    51         previewSite : function( event ) {
    52             var previewUrl       = $( this ).data( 'preview-url' ),
    53                 previewUrlParams = getUrlParams( previewUrl );
    54 
    55             if ( api( 'wcsc_source_site_id' ).get() == previewUrlParams.wcsc_source_site_id ) {
     418                api.section( this.section() ).expand();
     419
     420                currentSite = this.siteCollection.find( { site_id : urlParams.wcsc_source_site_id } );
     421
     422                if ( currentSite ) {
     423                    this.setActiveSite( currentSite );
     424                }
     425            }
     426
     427            $( '#wcsc-cloner' ).on( 'wcsc:previewSite', '.wcsc-site', function( event, site ) {
     428                control.previewSite( site );
     429            } );
     430
     431            // Setup the top level Site Search View
     432            this.view = new wcsc.views.SiteSearch( {
     433                parent     : this,
     434                collection : this.siteCollection
     435            } );
     436
     437            this.view.render();
     438
     439            // Initialize the router to allow state to be restored after a full refresh
     440            wcsc.router = new wcsc.routers.FilterState( { parent : this } );
     441            Backbone.history.start();
     442        },
     443
     444        previewSite : function( site ) {
     445            var queryString, routerFragment;
     446
     447            if ( api( 'wcsc_source_site_id' ).get() == site.get( 'site_id' ) ) {
     448                // We're already previewing this site
    56449                return;
    57450            }
    58451
    59             if ( api.settings.theme.stylesheet === previewUrlParams.theme ) {
    60                 api( 'wcsc_source_site_id' ).set( previewUrlParams.wcsc_source_site_id );
     452            if ( api.settings.theme.stylesheet === site.get( 'theme_slug' ) ) {
     453                this.setActiveSite( site );
    61454            } else {
    62                 window.parent.location = previewUrl;
    63             }
     455                // We have to do a full refresh when changing themes or other controls won't correlate to the current theme.
     456                queryString = $.param( {
     457                    'theme'               : site.get( 'theme_slug' ),
     458                    'wcsc_source_site_id' : site.get( 'site_id' )
     459                } );
     460
     461                routerFragment      = Backbone.history.getFragment();
     462                win.parent.location = wcsc.settings.customizerUrl + '?' + queryString + '#' + routerFragment;
     463            }
     464        },
     465
     466        // Set the active site and update the model to reflect the change
     467        setActiveSite : function( site ) {
     468            var site_id = site.get( 'site_id' );
     469
     470            this.siteCollection.each( function( _site ) {
     471                _site.set( { active : false } );
     472            } );
     473
     474            site.set( { active : true } );
     475            api( 'wcsc_source_site_id' ).set( site.get( 'site_id' ) );
    64476        }
    65477    } );
     
    75487     */
    76488    function getUrlParams( url ) {
    77         var match, questionMarkIndex, query,
    78             urlParams = {},
    79             pl        = /\+/g,  // Regex for replacing addition symbol with a space
    80             search    = /([^&=]+)=?([^&]*)/g,
    81             decode    = function ( s ) {
    82                 return decodeURIComponent( s.replace( pl, " " ) );
    83             };
     489        var questionMarkIndex, query, hashIndex;
     490
     491        // Strip hash first
     492        hashIndex = url.indexOf( '#' );
     493
     494        if ( hashIndex > -1 ) {
     495            url = url.substring( 0, hashIndex );
     496        }
    84497
    85498        questionMarkIndex = url.indexOf( '?' );
    86499
    87500        if ( -1 === questionMarkIndex ) {
    88             return urlParams;
     501            return {};
    89502        } else {
    90503            query = url.substring( questionMarkIndex + 1 );
    91504        }
    92505
    93         while ( match = search.exec( query ) ) {
     506        return deserializeQueryString( query );
     507    }
     508
     509    /**
     510     * Deserialize a query string into an object
     511     *
     512     * @param queryString
     513     * @returns {object}
     514     */
     515    function deserializeQueryString( queryString ) {
     516        var match,
     517            urlParams = {},
     518            pl        = /\+/g,  // Regex for replacing addition symbol with a space
     519            search    = /([^&=]+)=?([^&]*)/g,
     520            decode    = function( s ) {
     521                return decodeURIComponent( s.replace( pl, " " ) );
     522            };
     523
     524        while ( match = search.exec( queryString ) ) {
    94525            urlParams[ decode( match[ 1 ] ) ] = decode( match[ 2 ] );
    95526        }
     
    97528        return urlParams;
    98529    }
    99 } )( window.wp, jQuery );
     530})( wp, jQuery, Backbone, window, _wcscSettings );
  • sites/trunk/wordcamp.org/public_html/wp-content/plugins/wordcamp-site-cloner/wordcamp-site-cloner.php

    r2933 r4280  
    11<?php
    2 
    3 namespace WordCamp\Site_Cloner;
    4 
    5 defined( 'WPINC' ) or die();
    62
    73/*
    84Plugin Name: WordCamp Site Cloner
    95Description: Allows organizers to clone another WordCamp's theme and custom CSS as a starting point for their site.
    10 Version:     0.1
     6Version:     0.2
    117Author:      WordCamp.org
    128Author URI:  http://wordcamp.org
     
    1410*/
    1511
    16 // todo if Jetpack_Custom_CSS:get_css is callable, register these, otherwise fatal errors
    17 
    18 add_action( 'plugins_loaded',        __NAMESPACE__ . '\get_wordcamp_sites' );
    19 add_action( 'admin_enqueue_scripts', __NAMESPACE__ . '\register_scripts' );
    20 add_action( 'admin_menu',            __NAMESPACE__ . '\add_submenu_page' );
    21 add_action( 'customize_register',    __NAMESPACE__ . '\register_customizer_components' );
     12namespace WordCamp\Site_Cloner;
     13defined( 'WPINC' ) or die();
     14
     15const PRIME_SITES_CRON_ACTION      = 'wcsc_prime_sites';
     16const WORDCAMP_SITES_TRANSIENT_KEY = 'wcsc_sites';
     17
     18/**
     19 * Initialization
     20 */
     21function initialize() {
     22    // We rely on the Custom CSS module being available
     23    if ( ! class_exists( '\Jetpack' ) ) {
     24        return;
     25    }
     26
     27    add_action( 'admin_enqueue_scripts', __NAMESPACE__ . '\register_scripts'               );
     28    add_action( 'admin_menu',            __NAMESPACE__ . '\add_submenu_page'               );
     29    add_action( 'customize_register',    __NAMESPACE__ . '\register_customizer_components' );
     30    add_action( 'rest_api_init',         __NAMESPACE__ . '\register_api_endpoints'         );
     31    add_action( PRIME_SITES_CRON_ACTION, __NAMESPACE__ . '\prime_wordcamp_sites'           );
     32
     33    if ( ! wp_next_scheduled( PRIME_SITES_CRON_ACTION ) ) {
     34        wp_schedule_event( time(), 'daily', PRIME_SITES_CRON_ACTION );
     35    }
     36}
     37add_action( 'plugins_loaded', __NAMESPACE__ . '\initialize' ); // After Jetpack has loaded
    2238
    2339/**
     
    2945        plugin_dir_url( __FILE__ ) . 'wordcamp-site-cloner.css',
    3046        array(),
    31         1
     47        2
    3248    );
    3349
     
    3551        'wordcamp-site-cloner',
    3652        plugin_dir_url( __FILE__ ) . 'wordcamp-site-cloner.js',
    37         array( 'jquery', 'customize-controls' ),
    38         1,
     53        array( 'jquery', 'customize-controls', 'wp-backbone' ),
     54        2,
    3955        true
    4056    );
     57
     58    wp_localize_script(
     59        'wordcamp-site-cloner',
     60        '_wcscSettings',
     61        array(
     62            'apiUrl'        => get_rest_url( null, '/wordcamp-site-cloner/v1/sites/' ),
     63            'customizerUrl' => admin_url( 'customize.php' ),
     64            'themes'        => get_available_themes(),
     65        )
     66    );
     67}
     68
     69/**
     70 * Get all of the available themes
     71 *
     72 * @return array
     73 */
     74function get_available_themes() {
     75    /** @var \WP_Theme $theme */
     76    $available_themes = array();
     77    $raw_themes       = wp_get_themes( array( 'allowed' => true ) );
     78
     79    foreach ( $raw_themes as $theme ) {
     80        $theme_name         = $theme->display( 'Name' );
     81        $available_themes[] = array(
     82            'slug' => $theme->get_stylesheet(),
     83            'name' => $theme_name ?: $theme->get_stylesheet()
     84        );
     85    }
     86
     87    return $available_themes;
    4188}
    4289
     
    53100        __( 'Clone Another WordCamp', 'wordcamporg' ),
    54101        'switch_themes',
    55         'customize.php?autofocus[panel]=wordcamp_site_cloner'
     102        'customize.php?autofocus[section]=wcsc_sites'
    56103    );
    57104}
     
    64111function register_customizer_components( $wp_customize ) {
    65112    require_once( __DIR__ . '/includes/source-site-id-setting.php' );
    66     require_once( __DIR__ . '/includes/sites-section.php' );
    67113    require_once( __DIR__ . '/includes/site-control.php' );
    68 
    69     $wp_customize->register_control_type( __NAMESPACE__ . '\Site_Control' );
    70114
    71115    $wp_customize->add_setting( new Source_Site_ID_Setting(
    72116        $wp_customize,
    73117        'wcsc_source_site_id',
    74         array()
     118        array( 'capability' => 'switch_themes' )
    75119    ) );
    76120
    77     $wp_customize->add_panel(
    78         'wordcamp_site_cloner',
    79         array(
    80             'type'        => 'wcscPanel',
    81             'title'       => __( 'Clone Another WordCamp', 'wordcamporg' ),
    82             'description' => __( "Clone another WordCamp's theme and custom CSS as a starting point for your site.", 'wordcamporg' ),
    83         )
    84     );
    85 
    86     $wp_customize->add_section( new Sites_Section(
    87         $wp_customize,
     121    $wp_customize->add_section(
    88122        'wcsc_sites',
    89123        array(
    90             'panel' => 'wordcamp_site_cloner',
    91             'title' => __( 'WordCamp Sites', 'wordcamporg' ),
     124            'title'      => __( 'Clone Another WordCamp', 'wordcamporg' ),
     125            'capability' => 'switch_themes'
     126        )
     127    );
     128
     129    $wp_customize->add_control( new Site_Control(
     130        $wp_customize,
     131        'wcsc_site_search',
     132        array(
     133            'type'     => 'wcscSearch',
     134            'label'    => __( 'Search', 'wordcamporg' ),
     135            'settings' => 'wcsc_source_site_id',
     136            'section'  => 'wcsc_sites'
    92137        )
    93138    ) );
    94 
    95     foreach( get_wordcamp_sites() as $wordcamp ) {
    96         if ( get_current_blog_id() == $wordcamp['site_id'] ) {
    97             continue;
    98         }
    99 
    100         $wp_customize->add_control( new Site_Control(
    101             $wp_customize,
    102             'wcsc_site_id_' . $wordcamp['site_id'],
    103             array(
    104                 'type'           => 'wcscSite',                      // todo should be able to set this in control instead of here, but if do that then control contents aren't rendered
    105                 'site_id'        => $wordcamp['site_id'],
    106                 'site_name'      => $wordcamp['name'],
    107                 'theme_slug'     => $wordcamp['theme_slug'],
    108                 'screenshot_url' => $wordcamp['screenshot_url'],
    109             )
    110         ) );
    111     }
    112 }
    113 
    114 /**
    115  * Get required data for relevant WordCamp sites
    116  *
    117  * This isn't actually used until register_customizer_components(), but it's called during `plugins_loaded` in
    118  * order to prime the cache. That has to be done before `setup_theme`, because the Theme Switcher will override
    119  * the current theme when `?theme=` is present in the URL parameters, and it's safer to just avoid that than to
    120  * muck with the internals and try to reverse it on the fly.
     139}
     140
     141/**
     142 * Register the REST API endpoint for the Customizer to use to retriever the site list
     143 */
     144function register_api_endpoints() {
     145    if ( ! current_user_can( 'switch_themes' ) ) {
     146        return;
     147
     148        // todo - use `permission_callback` instead
     149    }
     150
     151    register_rest_route(
     152        'wordcamp-site-cloner/v1',
     153        '/sites',
     154        array(
     155            'methods'  => 'GET',
     156            'callback' => __NAMESPACE__ . '\sites_endpoint',
     157        )
     158    );
     159}
     160
     161/**
     162 * Handle the response for the Sites endpoint
     163 *
     164 * This always pulls cached data, because Central needs to be the site generating it. See get_wordcamp_sites().
    121165 *
    122166 * @return array
    123167 */
     168function sites_endpoint() {
     169    $sites        = array();
     170    $cached_sites = get_site_transient( WORDCAMP_SITES_TRANSIENT_KEY );
     171
     172    if ( $cached_sites ) {
     173        unset( $cached_sites[ get_current_blog_id() ] );
     174
     175        $sites = array_values( $cached_sites );
     176    }
     177
     178    return $sites;
     179}
     180
     181/**
     182 * Prime the cache of cloneable WordCamp sites
     183 *
     184 * This is called via WP Cron.
     185 *
     186 * @todo - Reintroduce batching from `1112.3.diff` to get more than 500 sites
     187 */
     188function prime_wordcamp_sites() {
     189    // This only needs to run on a single site, then the whole network can use the cached result
     190    if ( ! is_main_site() ) {
     191        return;
     192    }
     193
     194    // Keep the cache longer than needed, just to be sure that it doesn't expire before the cron job runs again
     195    set_site_transient( WORDCAMP_SITES_TRANSIENT_KEY, get_wordcamp_sites(), DAY_IN_SECONDS * 2 );
     196}
     197
     198/**
     199 * Get WordCamp sites that are suitable for cloning
     200 *
     201 * @return array
     202 */
    124203function get_wordcamp_sites() {
    125     require_once( WP_PLUGIN_DIR . '/wcpt/wcpt-wordcamp/wordcamp-loader.php' );
    126 
    127     // plugins_loaded is runs on every screen, but we only need this when loading the Customizer and Previewer
    128     if ( 'customize.php' != basename( $_SERVER['SCRIPT_NAME'] ) && empty( $_REQUEST['wp_customize'] ) ) {
     204    /*
     205     * The post statuses that \WordCamp_Loader::get_public_post_statuses() returns are only created on Central,
     206     * because the plugin isn't active on any other sites.
     207     */
     208    if ( ! is_main_site() ) {
    129209        return array();
    130210    }
    131211
    132     $transient_key = 'wcsc_sites';
    133 
    134     if ( $sites = get_site_transient( $transient_key ) ) {
    135         return $sites;
     212    if ( ! \Jetpack::is_module_active( 'custom-css' ) ) {
     213        \Jetpack::activate_module( 'custom-css', false, false );
    136214    }
    137215
    138216    switch_to_blog( BLOG_ID_CURRENT_SITE ); // central.wordcamp.org
    139217
    140     $sites = array();
    141     $wordcamps = get_posts( array(
    142         'post_type'      => 'wordcamp',
     218    $wordcamp_query = new \WP_Query( array(
     219        'post_type'      => WCPT_POST_TYPE_ID,
    143220        'post_status'    => \WordCamp_Loader::get_public_post_statuses(),
    144         'posts_per_page' => 125, // todo temporary workaround until able to add filters to make hundreds of sites manageable
     221        'posts_per_page' => 500,
    145222        'meta_key'       => 'Start Date (YYYY-mm-dd)',
    146223        'orderby'        => 'meta_value_num',
     224        'order'          => 'DESC',
    147225
    148226        'meta_query' => array(
    149227            array(
     228                // New sites won't have finished designs, so ignore them
    150229                'key'     => 'Start Date (YYYY-mm-dd)',
    151230                'value'   => strtotime( 'now - 1 month' ),
    152231                'compare' => '<'
    153             ),
     232            )
    154233        ),
    155234    ) );
    156235
    157     foreach( $wordcamps as $wordcamp ) {
    158         $site_id  = get_wordcamp_site_id( $wordcamp );
    159         $site_url = get_post_meta( $wordcamp->ID, 'URL', true );
    160 
    161         if ( ! $site_id || ! $site_url ) {
     236    $sites = get_filtered_wordcamp_sites( $wordcamp_query->get_posts() );
     237
     238    uasort( $sites, __NAMESPACE__ . '\sort_sites_by_year' );
     239
     240    restore_current_blog();
     241
     242    return $sites;
     243}
     244
     245/**
     246 * Filter out sites that aren't relevant to the Cloner
     247 *
     248 * @param array $wordcamps
     249 *
     250 * @return array
     251 */
     252function get_filtered_wordcamp_sites( $wordcamps ) {
     253    $sites = array();
     254
     255    foreach ( $wordcamps as $wordcamp ) {
     256        $site_id    = get_wordcamp_site_id( $wordcamp );
     257        $site_url   = get_post_meta( $wordcamp->ID, 'URL',                     true );
     258        $start_date = get_post_meta( $wordcamp->ID, 'Start Date (YYYY-mm-dd)', true );
     259
     260        if ( ! $site_id || ! $site_url || ! $start_date ) {
    162261            continue;
    163262        }
     
    165264        switch_to_blog( $site_id );
    166265
    167         $sites[] = array(
    168             'site_id'        => $site_id,
    169             'name'           => get_wordcamp_name(),
    170             'theme_slug'     => get_stylesheet(),
    171             'screenshot_url' => get_screenshot_url( $site_url ),
    172         );
     266        /*
     267         * Sites with Coming Soon enabled probably don't have a finished design yet, so there's no point in
     268         * cloning it.
     269         */
     270        if ( ! coming_soon_plugin_enabled() ) {
     271            $preprocessor = \Jetpack_Custom_CSS::get_preprocessor();
     272            $preprocessor = isset( $preprocessor[ 'name' ] ) ? $preprocessor[ 'name' ] : 'none';
     273
     274            $sites[ $site_id ] = array(
     275                'site_id'          => $site_id,
     276                'name'             => get_wordcamp_name(),
     277                'theme_slug'       => get_stylesheet(),
     278                'screenshot_url'   => get_screenshot_url( $site_url ),
     279                'year'             => date( 'Y', $start_date ),
     280                'css_preprocessor' => $preprocessor,
     281            );
     282        }
    173283
    174284        restore_current_blog();
    175285    }
    176286
    177     restore_current_blog();
    178 
    179     set_site_transient( $transient_key, $sites, DAY_IN_SECONDS );
    180 
    181287    return $sites;
     288}
     289
     290/**
     291 * Determine if the Coming Soon plugin is enabled for the current site
     292 *
     293 * @return bool
     294 */
     295function coming_soon_plugin_enabled() {
     296    global $WCCSP_Settings;
     297    $enabled = false;
     298
     299    if ( ! is_callable( 'WCCSP_Settings::get_settings' ) ) {
     300        return $enabled;
     301    }
     302
     303    // We may need to instantiate the class if this is the first time calling this function
     304    if ( ! is_a( $WCCSP_Settings, 'WCCSP_Settings' ) ) {
     305        $WCCSP_Settings = new \WCCSP_Settings();
     306    }
     307
     308    $settings = $WCCSP_Settings->get_settings();
     309
     310    if ( isset( $settings[ 'enabled' ] ) && 'on' === $settings[ 'enabled' ] ) {
     311        $enabled = true;
     312    }
     313
     314    return $enabled;
    182315}
    183316
     
    196329    return apply_filters( 'wcsc_site_screenshot_url', $screenshot_url );
    197330}
     331
     332/**
     333 * Sort arrays by the year
     334 *
     335 * @param array $site_a
     336 * @param array $site_b
     337 *
     338 * @return int
     339 */
     340function sort_sites_by_year( $site_a, $site_b ) {
     341    if ( $site_a[ 'year' ] === $site_b[ 'year' ] ) {
     342        return 0;
     343    }
     344
     345    return ( $site_a[ 'year' ] < $site_b[ 'year' ] ? 1 : -1 );
     346}
Note: See TracChangeset for help on using the changeset viewer.