WordPress.org

Make WordPress Core

Changeset 38991


Ignore:
Timestamp:
10/28/16 02:56:16 (13 months ago)
Author:
westonruter
Message:

Customize: Introduce starter content and site freshness state.

A theme can opt-in for tailored starter content to apply to the customizer when previewing the theme on a fresh install, when fresh_site is at its initial 1 value. Starter content is staged in the customizer and does not go live unless the changes are published. Initial starter content is added to Twenty Seventeen.

  • The fresh_site flag is cleared when a published post or page is saved, when widgets are modified, or when the customizer state is saved.
  • Starter content is registered via starter-content theme support, where the argument is an array containing widgets, posts, nav_menus, options, and theme_mods. Posts/pages in starter content are created with the auto-draft status, re-using the page/post stubs feature added to nav menus and the static front page controls.
  • A get_theme_starter_content filter allows for plugins to extend a theme's starter content.
  • Starter content in themes can/should re-use existing starter content items in core by using named placeholders.
  • Import theme starter content into customized state when fresh site.
  • Prevent original_title differences from causing refreshes if title is present.
  • Ensure nav menu item url is set according to object when previewing.
  • Make sure initial saved state is false if there are dirty settings without an existing changeset.
  • Ensure dirty settings are cleaned upon changeset publishing.

Props helen, westonruter, ocean90.
Fixes #38114, #38533.

Location:
trunk/src
Files:
9 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-admin/includes/schema.php

    r38814 r38991  
    517517    'medium_large_size_w' => 768, 
    518518    'medium_large_size_h' => 0, 
     519 
     520    // 4.7.0 
     521    'fresh_site' => 1, 
    519522    ); 
    520523 
  • trunk/src/wp-admin/js/customize-controls.js

    r38977 r38991  
    147147 
    148148            // Skip including settings that have already been included in the changeset, if only requesting unsaved. 
    149             if ( ( options && options.unsaved ) && ( _.isUndefined( settingRevision ) || settingRevision <= api._lastSavedRevision ) ) { 
     149            if ( api.state( 'changesetStatus' ).get() && ( options && options.unsaved ) && ( _.isUndefined( settingRevision ) || settingRevision <= api._lastSavedRevision ) ) { 
    150150                return; 
    151151            } 
     
    48814881 
    48824882                submit = function () { 
    4883                     var request, query, settingInvalidities = {}; 
     4883                    var request, query, settingInvalidities = {}, latestRevision = api._latestRevision; 
    48844884 
    48854885                    /* 
     
    49854985                        api.state( 'changesetStatus' ).set( response.changeset_status ); 
    49864986                        if ( 'publish' === response.changeset_status ) { 
     4987 
     4988                            // Mark all published as clean if they haven't been modified during the request. 
     4989                            api.each( function( setting ) { 
     4990                                /* 
     4991                                 * Note that the setting revision will be undefined in the case of setting 
     4992                                 * values that are marked as dirty when the customizer is loaded, such as 
     4993                                 * when applying starter content. All other dirty settings will have an 
     4994                                 * associated revision due to their modification triggering a change event. 
     4995                                 */ 
     4996                                if ( setting._dirty && ( _.isUndefined( api._latestSettingRevisions[ setting.id ] ) || api._latestSettingRevisions[ setting.id ] <= latestRevision ) ) { 
     4997                                    setting._dirty = false; 
     4998                                } 
     4999                            } ); 
     5000 
    49875001                            api.state( 'changesetStatus' ).set( '' ); 
    49885002                            api.settings.changeset.uuid = response.next_changeset_uuid; 
     
    51535167 
    51545168            // Set default states. 
     5169            changesetStatus( api.settings.changeset.status ); 
    51555170            saved( true ); 
     5171            if ( '' === changesetStatus() ) { // Handle case for loading starter content. 
     5172                api.each( function( setting ) { 
     5173                    if ( setting._dirty ) { 
     5174                        saved( false ); 
     5175                    } 
     5176                } ); 
     5177            } 
    51565178            saving( false ); 
    51575179            activated( api.settings.theme.active ); 
     
    51625184            previewerAlive( true ); 
    51635185            editShortcutVisibility( 'initial' ); 
    5164             changesetStatus( api.settings.changeset.status ); 
    51655186 
    51665187            api.bind( 'change', function() { 
  • trunk/src/wp-content/themes/twentyseventeen/functions.php

    r38867 r38991  
    103103     */ 
    104104    add_editor_style( array( 'assets/css/editor-style.css', twentyseventeen_fonts_url() ) ); 
     105 
     106    add_theme_support( 'starter-content', array( 
     107        'widgets' => array( 
     108            'sidebar-1' => array( 
     109                'text_business_info', 
     110                'search', 
     111                'text_credits', 
     112            ), 
     113 
     114            'sidebar-2' => array( 
     115                'text_business_info', 
     116            ), 
     117 
     118            'sidebar-3' => array( 
     119                'text_credits', 
     120            ), 
     121        ), 
     122 
     123        'posts' => array( 
     124            'home', 
     125            'about-us', 
     126            'contact-us', 
     127            'blog', 
     128            'homepage-section', 
     129        ), 
     130 
     131        'options' => array( 
     132            'show_on_front' => 'page', 
     133            'page_on_front' => '{{home}}', 
     134            'page_for_posts' => '{{blog}}', 
     135        ), 
     136 
     137        'theme_mods' => array( 
     138            'panel_1' => '{{homepage-section}}', 
     139            'panel_2' => '{{about-us}}', 
     140            'panel_3' => '{{blog}}', 
     141            'panel_4' => '{{contact-us}}', 
     142        ), 
     143 
     144        'nav_menus' => array( 
     145            'top' => array( 
     146                'name' => __( 'Top' ), 
     147                'items' => array( 
     148                    'page_home', 
     149                    'page_about', 
     150                    'page_blog', 
     151                    'page_contact', 
     152                ), 
     153            ), 
     154            'social' => array( 
     155                'name' => __( 'Social' ), 
     156                'items' => array( 
     157                    'link_yelp', 
     158                    'link_facebook', 
     159                    'link_twitter', 
     160                    'link_instagram', 
     161                    'link_email', 
     162                ), 
     163            ), 
     164        ), 
     165    ) ); 
    105166} 
    106167add_action( 'after_setup_theme', 'twentyseventeen_setup' ); 
  • trunk/src/wp-includes/class-wp-customize-manager.php

    r38989 r38991  
    532532        } 
    533533 
     534        // Import theme starter content for fresh installs when landing in the customizer and no existing changeset loaded. 
     535        if ( get_option( 'fresh_site' ) && 'customize.php' === $pagenow && ! $this->changeset_post_id() ) { 
     536            add_action( 'after_setup_theme', array( $this, 'import_theme_starter_content' ), 100 ); 
     537        } 
     538 
    534539        $this->start_previewing_theme(); 
    535540    } 
     
    886891        } 
    887892        return $this->_changeset_data; 
     893    } 
     894 
     895    /** 
     896     * Import theme starter content into post values. 
     897     * 
     898     * @since 4.7.0 
     899     * @access public 
     900     * 
     901     * @param array $starter_content Starter content. Defaults to `get_theme_starter_content()`. 
     902     */ 
     903    function import_theme_starter_content( $starter_content = array() ) { 
     904        if ( empty( $starter_content ) ) { 
     905            $starter_content = get_theme_starter_content(); 
     906        } 
     907 
     908        $sidebars_widgets = isset( $starter_content['widgets'] ) && ! empty( $this->widgets ) ? $starter_content['widgets'] : array(); 
     909        $posts = isset( $starter_content['posts'] ) && ! empty( $this->nav_menus ) ? $starter_content['posts'] : array(); 
     910        $options = isset( $starter_content['options'] ) ? $starter_content['options'] : array(); 
     911        $nav_menus = isset( $starter_content['nav_menus'] ) && ! empty( $this->nav_menus ) ? $starter_content['nav_menus'] : array(); 
     912        $theme_mods = isset( $starter_content['theme_mods'] ) ? $starter_content['theme_mods'] : array(); 
     913 
     914        // Widgets. 
     915        $max_widget_numbers = array(); 
     916        foreach ( $sidebars_widgets as $sidebar_id => $widgets ) { 
     917            $sidebar_widget_ids = array(); 
     918            foreach ( $widgets as $widget ) { 
     919                list( $id_base, $instance ) = $widget; 
     920 
     921                if ( ! isset( $max_widget_numbers[ $id_base ] ) ) { 
     922 
     923                    // When $settings is an array-like object, get an intrinsic array for use with array_keys(). 
     924                    $settings = get_option( "widget_{$id_base}", array() ); 
     925                    if ( $settings instanceof ArrayObject || $settings instanceof ArrayIterator ) { 
     926                        $settings = $settings->getArrayCopy(); 
     927                    } 
     928 
     929                    // Find the max widget number for this type. 
     930                    $widget_numbers = array_keys( $settings ); 
     931                    $widget_numbers[] = 1; 
     932                    $max_widget_numbers[ $id_base ] = call_user_func_array( 'max', $widget_numbers ); 
     933                } 
     934                $max_widget_numbers[ $id_base ] += 1; 
     935 
     936                $widget_id = sprintf( '%s-%d', $id_base, $max_widget_numbers[ $id_base ] ); 
     937                $setting_id = sprintf( 'widget_%s[%d]', $id_base, $max_widget_numbers[ $id_base ] ); 
     938 
     939                $class = 'WP_Customize_Setting'; 
     940 
     941                /** This filter is documented in wp-includes/class-wp-customize-manager.php */ 
     942                $args = apply_filters( 'customize_dynamic_setting_args', false, $setting_id ); 
     943 
     944                if ( false !== $args ) { 
     945 
     946                    /** This filter is documented in wp-includes/class-wp-customize-manager.php */ 
     947                    $class = apply_filters( 'customize_dynamic_setting_class', $class, $setting_id, $args ); 
     948 
     949                    $setting = new $class( $this, $setting_id, $args ); 
     950                    $setting_value = call_user_func( $setting->sanitize_js_callback, $instance, $setting ); 
     951                    $this->set_post_value( $setting_id, $setting_value ); 
     952                    $sidebar_widget_ids[] = $widget_id; 
     953                } 
     954            } 
     955 
     956            $this->set_post_value( sprintf( 'sidebars_widgets[%s]', $sidebar_id ), $sidebar_widget_ids ); 
     957        } 
     958 
     959        // Posts & pages. 
     960        if ( ! empty( $posts ) ) { 
     961            foreach ( array_keys( $posts ) as $post_symbol ) { 
     962                $posts[ $post_symbol ]['ID'] = wp_insert_post( wp_slash( array_merge( 
     963                    $posts[ $post_symbol ], 
     964                    array( 'post_status' => 'auto-draft' ) 
     965                ) ) ); 
     966            } 
     967            $this->set_post_value( 'nav_menus_created_posts', wp_list_pluck( $posts, 'ID' ) ); // This is why nav_menus component is dependency for adding posts. 
     968        } 
     969 
     970        // Nav menus. 
     971        $placeholder_id = -1; 
     972        foreach ( $nav_menus as $nav_menu_location => $nav_menu ) { 
     973            $nav_menu_term_id = $placeholder_id--; 
     974            $nav_menu_setting_id = sprintf( 'nav_menu[%d]', $nav_menu_term_id ); 
     975            $this->set_post_value( $nav_menu_setting_id, array( 
     976                'name' => isset( $nav_menu['name'] ) ? $nav_menu['name'] : $nav_menu_location, 
     977            ) ); 
     978 
     979            // @todo Add support for menu_item_parent. 
     980            $position = 0; 
     981            foreach ( $nav_menu['items'] as $nav_menu_item ) { 
     982                $nav_menu_item_setting_id = sprintf( 'nav_menu_item[%d]', $placeholder_id-- ); 
     983                if ( ! isset( $nav_menu_item['position'] ) ) { 
     984                    $nav_menu_item['position'] = $position++; 
     985                } 
     986                $nav_menu_item['nav_menu_term_id'] = $nav_menu_term_id; 
     987 
     988                if ( isset( $nav_menu_item['object_id'] ) ) { 
     989                    if ( 'post_type' === $nav_menu_item['type'] && preg_match( '/^{{(?P<symbol>.+)}}$/', $nav_menu_item['object_id'], $matches ) && isset( $posts[ $matches['symbol'] ] ) ) { 
     990                        $nav_menu_item['object_id'] = $posts[ $matches['symbol'] ]['ID']; 
     991                        if ( empty( $nav_menu_item['title'] ) ) { 
     992                            $original_object = get_post( $nav_menu_item['object_id'] ); 
     993                            $nav_menu_item['title'] = $original_object->post_title; 
     994                        } 
     995                    } else { 
     996                        continue; 
     997                    } 
     998                } else { 
     999                    $nav_menu_item['object_id'] = 0; 
     1000                } 
     1001                $this->set_post_value( $nav_menu_item_setting_id, $nav_menu_item ); 
     1002            } 
     1003 
     1004            $this->set_post_value( sprintf( 'nav_menu_locations[%s]', $nav_menu_location ), $nav_menu_term_id ); 
     1005        } 
     1006 
     1007        // Options. 
     1008        foreach ( $options as $name => $value ) { 
     1009            if ( preg_match( '/^{{(?P<symbol>.+)}}$/', $value, $matches ) && isset( $posts[ $matches['symbol'] ] ) ) { 
     1010                $value = $posts[ $matches['symbol'] ]['ID']; 
     1011            } 
     1012            $this->set_post_value( $name, $value ); 
     1013        } 
     1014 
     1015        // Theme mods. 
     1016        foreach ( $theme_mods as $name => $value ) { 
     1017            if ( preg_match( '/^{{(?P<symbol>.+)}}$/', $value, $matches ) && isset( $posts[ $matches['symbol'] ] ) ) { 
     1018                $value = $posts[ $matches['symbol'] ]['ID']; 
     1019            } 
     1020            $this->set_post_value( $name, $value ); 
     1021        } 
    8881022    } 
    8891023 
  • trunk/src/wp-includes/customize/class-wp-customize-nav-menu-item-setting.php

    r38928 r38991  
    603603        } 
    604604 
     605        // Ensure nav menu item URL is set according to linked object. 
     606        if ( ! empty( $post->object_id ) ) { 
     607            if ( 'post_type' === $post->type ) { 
     608                $post->url = get_permalink( $post->object_id ); 
     609            } elseif ( 'post_type_archive' === $post->type && ! empty( $post->object ) ) { 
     610                $post->url = get_post_type_archive_link( $post->object ); 
     611            } elseif ( 'taxonomy' == $post->type && ! empty( $post->object ) ) { 
     612                $post->url = get_term_link( (int) $post->object, $post->object ); 
     613            } 
     614        } 
     615 
    605616        /** This filter is documented in wp-includes/nav-menu.php */ 
    606617        $post->attr_title = apply_filters( 'nav_menu_attr_title', $post->attr_title ); 
  • trunk/src/wp-includes/default-filters.php

    r38961 r38991  
    188188// Email filters 
    189189add_filter( 'wp_mail', 'wp_staticize_emoji_for_email' ); 
     190 
     191// Mark site as no longer fresh 
     192if ( get_option( 'fresh_site' ) ) { 
     193    foreach ( array( 'publish_post', 'publish_page', 'wp_ajax_save-widget', 'wp_ajax_widgets-order', 'customize_save_after' ) as $action ) { 
     194        add_action( $action, '_delete_option_fresh_site' ); 
     195    } 
     196} 
    190197 
    191198// Misc filters 
  • trunk/src/wp-includes/functions.php

    r38956 r38991  
    32423242 
    32433243/** 
     3244 * Delete the fresh site option. 
     3245 * 
     3246 * @since 4.7.0 
     3247 * @access private 
     3248 */ 
     3249function _delete_option_fresh_site() { 
     3250    update_option( 'fresh_site', 0 ); 
     3251} 
     3252 
     3253/** 
    32443254 * Set the localized direction for MCE plugin. 
    32453255 * 
  • trunk/src/wp-includes/js/customize-preview-nav-menus.js

    r38810 r38991  
    140140                    } 
    141141 
     142                    // Prevent original_title differences from causing refreshes if title is present. 
     143                    if ( newValue.title ) { 
     144                        delete _oldValue.original_title; 
     145                        delete _newValue.original_title; 
     146                    } 
     147 
    142148                    if ( _.isEqual( _oldValue, _newValue ) ) { 
    143149                        return false; 
  • trunk/src/wp-includes/theme.php

    r38985 r38991  
    17571757 
    17581758/** 
     1759 * Expand a theme's starter content configuration using core-provided data. 
     1760 * 
     1761 * @since 4.7.0 
     1762 * 
     1763 * @return array Array of starter content. 
     1764 */ 
     1765function get_theme_starter_content() { 
     1766    $theme_support = get_theme_support( 'starter-content' ); 
     1767    if ( ! empty( $theme_support ) ) { 
     1768        $config = $theme_support[0]; 
     1769    } else { 
     1770        $config = array(); 
     1771    } 
     1772 
     1773    $core_content = array ( 
     1774        'widgets' => array( 
     1775            'text_business_info' => array ( 'text', array ( 
     1776                'title' => __( 'Find Us' ), 
     1777                'text' => join( '', array ( 
     1778                    '<p><strong>' . __( 'Address' ) . '</strong><br />', 
     1779                    __( '123 Main Street' ) . '<br />' . __( 'New York, NY 10001' ) . '</p>', 
     1780                    '<p><strong>' . __( 'Hours' ) . '</strong><br />', 
     1781                    __( 'Monday&mdash;Friday: 9:00AM&ndash;5:00PM' ) . '<br />' . __( 'Saturday &amp; Sunday: 11:00AM&ndash;3:00PM' ) . '</p>' 
     1782                ) ), 
     1783            ) ), 
     1784            'search' => array ( 'search', array ( 
     1785                'title' => __( 'Site Search' ), 
     1786            ) ), 
     1787            'text_credits' => array ( 'text', array ( 
     1788                'title' => __( 'Site Credits' ), 
     1789                'text' => sprintf( __( 'This site was created on %s' ), get_date_from_gmt( current_time( 'mysql', 1 ), 'c' ) ), 
     1790            ) ), 
     1791        ), 
     1792        'nav_menus' => array ( 
     1793            'page_home' => array( 
     1794                'type' => 'post_type', 
     1795                'object' => 'page', 
     1796                'object_id' => '{{home}}', 
     1797            ), 
     1798            'page_about' => array( 
     1799                'type' => 'post_type', 
     1800                'object' => 'page', 
     1801                'object_id' => '{{about-us}}', 
     1802            ), 
     1803            'page_blog' => array( 
     1804                'type' => 'post_type', 
     1805                'object' => 'page', 
     1806                'object_id' => '{{blog}}', 
     1807            ), 
     1808            'page_contact' => array( 
     1809                'type' => 'post_type', 
     1810                'object' => 'page', 
     1811                'object_id' => '{{contact-us}}', 
     1812            ), 
     1813 
     1814            'link_yelp' => array( 
     1815                'title' => __( 'Yelp' ), 
     1816                'url' => 'https://www.yelp.com', 
     1817            ), 
     1818            'link_facebook' => array( 
     1819                'title' => __( 'Facebook' ), 
     1820                'url' => 'https://www.facebook.com/wordpress', 
     1821            ), 
     1822            'link_twitter' => array( 
     1823                'title' => __( 'Twitter' ), 
     1824                'url' => 'https://twitter.com/wordpress', 
     1825            ), 
     1826            'link_instagram' => array( 
     1827                'title' => __( 'Instagram' ), 
     1828                'url' => 'https://www.instagram.com/explore/tags/wordcamp/', 
     1829            ), 
     1830            'link_email' => array( 
     1831                'title' => __( 'Email' ), 
     1832                'url' => 'mailto:wordpress@example.com', 
     1833            ), 
     1834        ), 
     1835        'posts' => array( 
     1836            'home' => array( 
     1837                'post_type' => 'page', 
     1838                'post_title' => __( 'Homepage' ), 
     1839                'post_content' => __( 'Welcome home.' ), 
     1840            ), 
     1841            'about-us' => array( 
     1842                'post_type' => 'page', 
     1843                'post_title' => __( 'About Us' ), 
     1844                'post_content' => __( 'More than you ever wanted to know.' ), 
     1845            ), 
     1846            'contact-us' => array( 
     1847                'post_type' => 'page', 
     1848                'post_title' => __( 'Contact Us' ), 
     1849                'post_content' => __( 'Call us at 999-999-9999.' ), 
     1850            ), 
     1851            'blog' => array( 
     1852                'post_type' => 'page', 
     1853                'post_title' => __( 'Blog' ), 
     1854            ), 
     1855 
     1856            'homepage-section' => array( 
     1857                'post_type' => 'page', 
     1858                'post_title' => __( 'A homepage section' ), 
     1859                'post_content' => __( 'This is an example of a homepage section, which are managed in theme options.' ), 
     1860            ), 
     1861        ), 
     1862    ); 
     1863 
     1864    $content = array(); 
     1865 
     1866    foreach ( $config as $type => $args ) { 
     1867        switch( $type ) { 
     1868            // Use options and theme_mods as-is 
     1869            case 'options' : 
     1870            case 'theme_mods' : 
     1871                $content[ $type ] = $config[ $type ]; 
     1872                break; 
     1873 
     1874            // Widgets are an extra level down due to groupings 
     1875            case 'widgets' : 
     1876                foreach ( $config[ $type ] as $group => $items ) { 
     1877                    foreach ( $items as $id ) { 
     1878                        if ( ! empty( $core_content[ $type ] ) && ! empty( $core_content[ $type ][ $id ] ) ) { 
     1879                            $content[ $type ][ $group ][ $id ] = $core_content[ $type ][ $id ]; 
     1880                        } 
     1881                    } 
     1882                } 
     1883                break; 
     1884 
     1885            // And nav menus are yet another level down 
     1886            case 'nav_menus' : 
     1887                foreach ( $config[ $type ] as $group => $args2 ) { 
     1888                    // Menu groups need a name 
     1889                    if ( empty( $args['name'] ) ) { 
     1890                        $args2['name'] = $group; 
     1891                    } 
     1892 
     1893                    $content[ $type ][ $group ]['name'] = $args2['name']; 
     1894 
     1895                    // Do we need to check if this is empty? 
     1896                    foreach ( $args2['items'] as $id ) { 
     1897                        if ( ! empty( $core_content[ $type ] ) && ! empty( $core_content[ $type ][ $id ] ) ) { 
     1898                            $content[ $type ][ $group ]['items'][ $id ] = $core_content[ $type ][ $id ]; 
     1899                        } 
     1900                    } 
     1901                } 
     1902                break; 
     1903 
     1904 
     1905            // Everything else should map at the next level 
     1906            default : 
     1907                foreach( $config[ $type ] as $id ) { 
     1908                    if ( ! empty( $core_content[ $type ] ) && ! empty( $core_content[ $type ][ $id ] ) ) { 
     1909                        $content[ $type ][ $id ] = $core_content[ $type ][ $id ]; 
     1910                    } 
     1911                } 
     1912                break; 
     1913        } 
     1914    } 
     1915 
     1916    /** 
     1917     * Filters the expanded array of starter content. 
     1918     * 
     1919     * @since 4.7.0 
     1920     * 
     1921     * @param array $content Array of starter content. 
     1922     * @param array $config  Array of theme-specific starter content configuration. 
     1923     */ 
     1924    return apply_filters( 'get_theme_starter_content', $content, $config ); 
     1925} 
     1926 
     1927/** 
    17591928 * Registers theme support for a given feature. 
    17601929 * 
     
    17681937 * @since 4.1.0 The `title-tag` feature was added 
    17691938 * @since 4.5.0 The `customize-selective-refresh-widgets` feature was added 
     1939 * @since 4.7.0 The `starter-content` feature was added 
    17701940 * 
    17711941 * @global array $_wp_theme_features 
     
    17731943 * @param string $feature  The feature being added. Likely core values include 'post-formats', 
    17741944 *                         'post-thumbnails', 'html5', 'custom-logo', 'custom-header-uploads', 
    1775  *                         'custom-header', 'custom-background', 'title-tag', etc. 
     1945 *                         'custom-header', 'custom-background', 'title-tag', 'starter-content', etc. 
    17761946 * @param mixed  $args,... Optional extra arguments to pass along with certain features. 
    17771947 * @return void|bool False on failure, void otherwise. 
     
    22052375     * The dynamic portion of the hook name, `$feature`, refers to the specific theme 
    22062376     * feature. Possible values include 'post-formats', 'post-thumbnails', 'custom-background', 
    2207      * 'custom-header', 'menus', 'automatic-feed-links', 'html5', and `customize-selective-refresh-widgets`. 
     2377     * 'custom-header', 'menus', 'automatic-feed-links', 'html5', 
     2378     * 'starter-content', and 'customize-selective-refresh-widgets'. 
    22082379     * 
    22092380     * @since 3.4.0 
Note: See TracChangeset for help on using the changeset viewer.