Plugin Directory

Changeset 3750663


Ignore:
Timestamp:
10/16/2026 03:19:25 PM (13 days ago)
Author:
topher1kenobe
Message:

Make it work in Gutenberg

Location:
shy-posts
Files:
8 added
2 edited

Legend:

Unmodified
Added
Removed
  • shy-posts/trunk/readme.txt

    r2086988 r3750663  
    22Contributors: topher1kenobe
    33Tags: posts
    4 Requires at least: 3.0
    5 Tested up to: 5.2
    6 Stable tag: 1.3.3
     4Requires at least: 5.3
     5Requires PHP: 5.6
     6Tested up to: 7.1
     7Stable tag: 2.0.0
    78License: GPLv2 or later
    89License URI: http://www.gnu.org/licenses/gpl-2.0.html
    … …  
    1617This only works if your blog is on your homepage.
    1718
    18 Requires the Classic Editor.
     19Works in both the block editor (in the post status panel of the sidebar) and the Classic Editor (in the Publish box).
    1920
    2021== Installation ==
    … …  
    3435== Screenshots ==
    3536
    36 1. The checkbox in the Publish box on the Post editing page
     371. The checkbox in the Publish box on the Post editing page (Classic Editor)
    3738
    3839== Changelog ==
     40
     41= 2.0.0 =
     42* Block editor support: the checkbox now appears in the post status panel of the sidebar
     43* Register the shy_post meta for the REST API
     44* Keep the stored list in sync from post meta changes, so classic, block editor and deleted posts are all handled the same way
     45* Requires WordPress 5.3 or later
     46* Fix activation hook never firing, so existing shy posts are now picked up on activation
     47* Store shy post IDs in an option instead of a transient, so they can't be evicted by an object cache
     48* Rebuild the stored list from post meta automatically if it's missing (this also migrates from 1.3.x)
     49* Fix PHP notice when saving a post with the checkbox unchecked
     50* Fix malformed checkbox HTML
     51* Skip autosaves and revisions on save, and stop storing duplicate IDs
     52* Remove permanently deleted posts from the stored list
     53* Merge with existing post__not_in exclusions on the homepage instead of replacing them
     54* Remove unused composer.json
    3955
    4056= 1.3.3 =
  • shy-posts/trunk/shy-posts.php

    r2086988 r3750663  
    11<?php
    2 /*
    3 Plugin Name: Shy Posts
    4 Plugin URI: http://codeventure.net
    5 Description: Provides a mechanism for preventing posts from being rendered on the homepage loop
    6 Author: Topher
    7 Version: 1.3.3
    8 Author URI: http://codeventure.net
    9 */
    10 
    112/**
    12  * Provides a mechanism for preventing posts from being rendered on the homepage loop
     3 * Plugin Name: Shy Posts
     4 * Plugin URI: http://codeventure.net
     5 * Description: Provides a mechanism for preventing posts from being rendered on the homepage loop
     6 * Author: Topher
     7 * Version: 2.0.0
     8 * Author URI: http://codeventure.net
     9 * Text Domain: shy-posts
     10 * License: GPLv2 or later
    1311 *
    1412 * @package Shy_Posts
    … …  
    1715 */
    1816
     17// The main plugin file can't be renamed to class-shy-posts.php without deactivating the plugin on existing sites.
     18// phpcs:disable WordPress.Files.FileName
     19
     20/**
     21 * Build the list of shy post IDs on activation.
     22 * This must be registered when the file loads, not on plugins_loaded, or it never fires.
     23 *
     24 * @since Shy_Posts 2.0
     25 */
     26register_activation_hook( __FILE__, array( 'Shy_Posts', 'install' ) );
    1927
    2028/**
    2129 * Instantiate the Shy_Posts instance
     30 *
    2231 * @since Shy_Posts 1.0
    2332 */
    … …  
    3039 *
    3140 * @class Shy_Posts
    32  * @version 1.3.3
     41 * @version 2.0.0
    3342 * @since 1.0
    3443 * @package Shy_Posts
    … …  
    3847
    3948        /**
    40         * Holds array of post IDs that are shy.
    41         *
    42         * @access private
    43         * @since 1.2
    44         * @var array
    45         */
    46         private $shy_post_ids = NULL;
    47 
    48         /**
    49         * Transient name
    50         *
    51         * @access private
    52         * @since 1.2
    53         * @var string
    54         */
    55         private $shy_post_transient_name = 'shy_posts_transient';
    56 
    57         /**
    58         * Instance handle
    59         *
    60         * @static
    61         * @since 1.2
    62         * @var string
    63         */
    64     private static $__instance = null;
    65  
     49         * Holds array of post IDs that are shy. NULL until loaded.
     50         *
     51         * @access private
     52         * @since 1.2
     53         * @var array|null
     54         */
     55        private $shy_post_ids = null;
     56
     57        /**
     58         * Option name the shy post IDs are stored in
     59         *
     60         * @since 2.0
     61         * @var string
     62         */
     63        const OPTION_NAME = 'shy_posts_ids';
     64
     65        /**
     66         * Transient name used by versions before 2.0, deleted on rebuild
     67         *
     68         * @since 2.0
     69         * @var string
     70         */
     71        const LEGACY_TRANSIENT_NAME = 'shy_posts_transient';
     72
     73        /**
     74         * Post meta key that marks a post as shy
     75         *
     76         * @since 2.0
     77         * @var string
     78         */
     79        const META_KEY = 'shy_post';
     80
     81        /**
     82         * Plugin version, used to bust the editor script cache
     83         *
     84         * @since 2.0
     85         * @var string
     86         */
     87        const VERSION = '2.0.0';
     88
     89        /**
     90         * Instance handle
     91         *
     92         * @static
     93         * @since 1.2
     94         * @var Shy_Posts
     95         */
     96        private static $instance = null;
     97
    6698        /**
    6799         * Shy_Posts Constructor, actually contains nothing
    68100         *
    69          * @access public
    70          * @return void
    71          */
    72     private function __construct() {}
    73  
     101         * @access private
     102         * @return void
     103         */
     104        private function __construct() {}
     105
    74106        /**
    75107         * Instance initiator, runs setup etc.
    … …  
    78110         * @return self
    79111         */
    80     public static function instance() {
    81         if ( ! is_a( self::$__instance, __CLASS__ ) ) {
    82             self::$__instance = new self;
    83             self::$__instance->setup();
    84         }
    85  
    86         return self::$__instance;
    87     }
    88  
     112        public static function instance() {
     113                if ( ! is_a( self::$instance, __CLASS__ ) ) {
     114                        self::$instance = new self();
     115                        self::$instance->setup();
     116                }
     117
     118                return self::$instance;
     119        }
     120
    89121        /**
    90122         * Runs things that would normally be in __construct
    … …  
    93125         * @return void
    94126         */
    95     private function setup() {
    96                 // get the shy posts transient
    97                 $this->get_shy_post_ids();
    98 
    99                 register_activation_hook( __FILE__, array( 'Shy_Posts', 'install' ) );
    100 
    101                 // only do this in the admin area
     127        private function setup() {
     128                // The meta is exposed to the REST API for the block editor, so register it everywhere.
     129                add_action( 'init', array( $this, 'register_meta' ) );
     130
     131                // Keep the stored list in sync however the meta changes: classic editor, block editor (REST), or post deletion.
     132                add_action( 'added_post_meta', array( $this, 'meta_changed' ), 10, 4 );
     133                add_action( 'updated_post_meta', array( $this, 'meta_changed' ), 10, 4 );
     134                add_action( 'deleted_post_meta', array( $this, 'meta_deleted' ), 10, 3 );
     135
     136                // Only do this in the admin area.
    102137                if ( is_admin() ) {
    103138                        add_action( 'save_post', array( $this, 'save' ) );
    104139                        add_action( 'post_submitbox_misc_actions', array( $this, 'option_hide_in_publish' ) );
     140                        add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_block_editor_assets' ) );
    105141                        add_filter( 'is_protected_meta', array( $this, 'hide_field' ), 10, 2 );
    106142                        add_action( 'init', array( $this, 'load_textdomain' ) );
    107143                }
    108144
    109                 // only do this NOT in the admin area
    110                 if ( !is_admin() ) {
    111                         // filter the homepage loop
     145                // Only do this NOT in the admin area.
     146                if ( ! is_admin() ) {
     147                        // Filter the homepage loop.
    112148                        add_action( 'pre_get_posts', array( $this, 'exclude_shy_posts_from_homepage' ) );
    113149                }
    114     }
    115 
    116         /**
    117          * On activation, get all the post ids from post meta data and store them in the transient
     150        }
     151
     152        /**
     153         * On activation, rebuild the stored list of shy post IDs from post meta
    118154         *
    119155         * @static
    120156         * @return void
    121157         */
    122          static function install() {
     158        public static function install() {
     159                self::rebuild_shy_post_ids();
     160        }
     161
     162        /**
     163         * Query post meta for every shy post and store the IDs in the option.
     164         * Post meta is the source of truth; the option is just a fast lookup.
     165         *
     166         * @static
     167         * @since 2.0
     168         * @return array
     169         */
     170        public static function rebuild_shy_post_ids() {
    123171
    124172                $args = array(
    125                         'post_status' => 'publish'
    126                         ,'post_type' => 'post'
    127                         ,'no_found_rows' => true
    128                         ,'nopaging' => true
    129                         ,'fields' => 'ids'
    130                         ,'meta_query' => array(
     173                        'post_type'     => get_post_types(),
     174                        'post_status'   => 'any',
     175                        'no_found_rows' => true,
     176                        'nopaging'      => true,
     177                        'fields'        => 'ids',
     178                        // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Only runs on activation or when the stored list is missing.
     179                        'meta_query'    => array(
    131180                                array(
    132                                         'key' => 'shy_post',
    133                                         'value' => '1',
    134                                         'compare' => '='
    135                                 )
     181                                        'key'     => self::META_KEY,
     182                                        'value'   => '1',
     183                                        'compare' => '=',
     184                                ),
     185                        ),
     186                );
     187
     188                $shy_post_ids = array_map( 'intval', get_posts( $args ) );
     189
     190                update_option( self::OPTION_NAME, $shy_post_ids );
     191                delete_transient( self::LEGACY_TRANSIENT_NAME );
     192
     193                return $shy_post_ids;
     194        }
     195
     196        /**
     197         * Load plugin textdomain.
     198         *
     199         * @access public
     200         * @return void
     201         */
     202        public function load_textdomain() {
     203                load_plugin_textdomain( 'shy-posts', false, plugin_basename( __DIR__ ) );
     204        }
     205
     206        /**
     207         * Filter the post meta field so it doesn't appear in the Custom Fields list
     208         *
     209         * @access public
     210         * @param bool   $is_protected Whether the meta key is protected.
     211         * @param string $meta_key     Meta key being checked.
     212         * @return bool
     213         */
     214        public function hide_field( $is_protected, $meta_key ) {
     215                if ( self::META_KEY === $meta_key ) {
     216                        $is_protected = true;
     217                }
     218                return $is_protected;
     219        }
     220
     221        /**
     222         * Get shy post IDs, rebuilding the stored list if it's missing
     223         *
     224         * @access public
     225         * @return array
     226         */
     227        public function get_shy_post_ids() {
     228
     229                if ( is_array( $this->shy_post_ids ) ) {
     230                        return $this->shy_post_ids;
     231                }
     232
     233                $shy_post_ids = get_option( self::OPTION_NAME, false );
     234
     235                // Missing (fresh install, or upgrading from the transient) so build it from post meta.
     236                if ( ! is_array( $shy_post_ids ) ) {
     237                        $shy_post_ids = self::rebuild_shy_post_ids();
     238                }
     239
     240                $this->shy_post_ids = $shy_post_ids;
     241
     242                return $this->shy_post_ids;
     243        }
     244
     245        /**
     246         * Add or remove a post ID from the stored list
     247         *
     248         * @access private
     249         * @param int  $post_id Post to add or remove.
     250         * @param bool $is_shy  True to add the post, false to remove it.
     251         * @return void
     252         */
     253        private function update_shy_post_ids( $post_id, $is_shy ) {
     254
     255                $post_id      = (int) $post_id;
     256                $shy_post_ids = $this->get_shy_post_ids();
     257
     258                if ( $is_shy ) {
     259                        $shy_post_ids[] = $post_id;
     260                } else {
     261                        $shy_post_ids = array_diff( $shy_post_ids, array( $post_id ) );
     262                }
     263
     264                $this->shy_post_ids = array_values( array_unique( array_map( 'intval', $shy_post_ids ) ) );
     265
     266                // Save it back.
     267                update_option( self::OPTION_NAME, $this->shy_post_ids );
     268        }
     269
     270        /**
     271         * Places checkbox in the Publish meta box
     272         *
     273         * @access public
     274         * @return void
     275         */
     276        public function option_hide_in_publish() {
     277
     278                global $post;
     279
     280                // Use nonce for verification.
     281                wp_nonce_field( plugin_basename( __FILE__ ), 'shyposts_nonce' );
     282
     283                // Get the data.
     284                $value = get_post_meta( $post->ID, self::META_KEY, true );
     285
     286                $title = __( 'Removes this post from the homepage, but NOT from any other page', 'shy-posts' );
     287
     288                // Echo the meta box.
     289                echo '<div class="misc-pub-section misc-pub-section-last">';
     290                echo '<input type="checkbox" id="shyposts_hide_field" name="shyposts_hide_field" value="1" ' . checked( $value, '1', false ) . ' title="' . esc_attr( $title ) . '"> ';
     291                echo '<label for="shyposts_hide_field" title="' . esc_attr( $title ) . '">';
     292                echo esc_html__( 'Hide on the homepage?', 'shy-posts' );
     293                echo '</label> ';
     294                echo '</div>';
     295        }
     296
     297
     298        /**
     299         * Updates post meta and the stored list with the form data
     300         *
     301         * @access public
     302         * @param int $post_id Post being saved.
     303         * @return void
     304         */
     305        public function save( $post_id ) {
     306
     307                // Autosaves and revisions don't carry the checkbox's meaning.
     308                if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || wp_is_post_revision( $post_id ) ) {
     309                        return;
     310                }
     311
     312                // Check if the user intended to change this value.
     313                if ( ! isset( $_POST['shyposts_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['shyposts_nonce'] ) ), plugin_basename( __FILE__ ) ) ) {
     314                        return;
     315                }
     316
     317                // Check if the current user is authorised to do this action.
     318                $post = get_post( $post_id );
     319                if ( ! $post ) {
     320                        return;
     321                }
     322
     323                $post_type = get_post_type_object( $post->post_type );
     324                if ( ! $post_type || ! current_user_can( $post_type->cap->edit_post, $post_id ) ) {
     325                        return;
     326                }
     327
     328                // Unchecked checkboxes aren't submitted at all.
     329                $is_shy = isset( $_POST['shyposts_hide_field'] ) && '1' === $_POST['shyposts_hide_field'];
     330
     331                // The meta hooks update the stored list.
     332                if ( $is_shy ) {
     333                        update_post_meta( $post_id, self::META_KEY, '1' );
     334                } else {
     335                        delete_post_meta( $post_id, self::META_KEY );
     336                }
     337        }
     338
     339        /**
     340         * Register the meta so the block editor can read and write it over REST
     341         *
     342         * @access public
     343         * @since 2.0
     344         * @return void
     345         */
     346        public function register_meta() {
     347                register_post_meta(
     348                        '',
     349                        self::META_KEY,
     350                        array(
     351                                'type'              => 'boolean',
     352                                'single'            => true,
     353                                'default'           => false,
     354                                'show_in_rest'      => true,
     355                                'sanitize_callback' => array( $this, 'sanitize_meta' ),
     356                                // The key is protected, so editing over REST needs an explicit auth callback.
     357                                'auth_callback'     => array( $this, 'can_edit_meta' ),
    136358                        )
    137359                );
    138 
    139                 $shy_post_ids = get_posts($args);
    140 
    141                 set_transient( 'shy_posts_transient', $shy_post_ids, 60 * 60 * 24 * 365 );
    142 
    143          }
    144         /**
    145          * Load plugin textdomain.
    146          *
    147          * @access public
    148          * @return void
    149          */
    150         public function load_textdomain() {
    151                 load_plugin_textdomain( 'shy-posts', false, plugin_basename( dirname( __FILE__ ) ) );
    152         }
    153 
    154         /**
    155          * Filter the post meta field so it doesn't appear in the Custom Fields list
    156          *
    157          * @access private
     360        }
     361
     362        /**
     363         * Store the flag the same way the classic editor always has: '1' or ''
     364         *
     365         * @access public
     366         * @since 2.0
     367         * @param mixed $value Submitted meta value.
     368         * @return string
     369         */
     370        public function sanitize_meta( $value ) {
     371                return rest_sanitize_boolean( $value ) ? '1' : '';
     372        }
     373
     374        /**
     375         * Only users who can edit the post can change its shy flag
     376         *
     377         * @access public
     378         * @since 2.0
     379         * @param bool   $allowed  Whether the user can edit the meta, before this check.
     380         * @param string $meta_key Meta key being edited.
     381         * @param int    $post_id  Post the meta belongs to.
    158382         * @return bool
    159383         */
    160         public function hide_field( $protected, $meta_key ) {
    161                 if ( 'shy_post' == $meta_key ) {
    162                         $protected = true;
    163                 }
    164                 return $protected;
    165         }
    166 
    167         /**
    168          * Get shy post IDs
    169          *
    170          * @access public
    171          * @return array
    172          */
    173         public function get_shy_post_ids() {
    174 
    175                 $shy_post_ids = get_transient( $this->shy_post_transient_name );
    176 
    177                 if(!is_array($shy_post_ids)) {
    178                         $shy_post_ids = array();
    179                 }
    180 
    181                 $this->shy_post_ids = $shy_post_ids;
    182 
    183         }
    184 
    185         /**
    186          * Update the shy_posts_transient
    187          *
    188          * @access private
    189          * @return void
    190          */
    191         private function update_transient( $post_id, $shydata) {
    192 
    193 
    194                 if($shydata != 1) {
    195                         // invert the post ids array so we can delete by key
    196                         $shy_post_ids_flipped = array_flip($this->shy_post_ids);
    197 
    198                         // delete the one we don't want anymore
    199                         unset($shy_post_ids_flipped[$post_id]);
    200 
    201                         // flip it back
    202                         $this->shy_post_ids = array_flip($shy_post_ids_flipped);
    203                 } else {
    204                         // add this post_id to the array
    205                         $this->shy_post_ids[] = $post_id;
    206                 }
    207                
    208                 // save it back
    209                 set_transient( $this->shy_post_transient_name, $this->shy_post_ids, 60 * 60 * 24 * 365 );
    210 
    211         }
    212 
    213         /**
    214          * Places checkbox in the Publish meta box
    215          *
    216          * @access public
    217          * @return void
    218          */
    219         public function option_hide_in_publish() {
    220 
    221                 global $post;
    222 
    223                 // Use nonce for verification
    224                 wp_nonce_field( plugin_basename( __FILE__ ), 'shyposts_nonce' );
    225 
    226                 // The actual fields for data entry
    227                 // Get the data
    228                 $value = get_post_meta( $post->ID, 'shy_post', true );
    229 
    230                 // get the value of the option we want
    231                 $checked = $value;
    232 
    233                 // echo the meta box
    234                 echo '<div class="misc-pub-section misc-pub-section-last">';
    235                 echo '<input type="checkbox" id="shyposts_hide_field" name="shyposts_hide_field" value="1" ' . checked( $checked, true, false ) . '" title="' . esc_attr__('Removes this post from the homepage, but NOT from any other page', 'shy-posts') . '"> ';
    236                 echo '<label for="shyposts_hide_field" title="' . esc_attr__('Removes this post from the homepage, but NOT from any other page', 'shy-posts') . '">';
    237                 echo __( 'Hide on the homepage?', 'shy-posts');
    238                 echo '</label> ';
    239                 echo '</div>';
    240         }
    241 
    242 
    243         /**
    244          * Updates the options table with the form data
    245          *
    246          * @access public
    247          * @param int $post_id
    248          * @return void
    249          */
    250         public function save( $post_id ) {
    251 
    252                 // Check if the current user is authorised to do this action.
    253                 $post_type = get_post_type_object( get_post( $post_id )->post_type );
    254                 if ( ! current_user_can( $post_type->cap->edit_post, $post_id ) ) {
    255                         return;
    256                 }
    257 
    258                 // Check if the user intended to change this value.
    259                 if ( ! isset( $_POST['shyposts_nonce'] ) || ! wp_verify_nonce( $_POST['shyposts_nonce'], plugin_basename( __FILE__ ) ) )
    260                         return;
    261 
    262                 // Sanitize user input
    263                 $shydata = sanitize_text_field( $_POST['shyposts_hide_field'] );
    264 
    265                 // Update or create the key/value
    266                 update_post_meta( $post_id, 'shy_post', $shydata );
    267 
    268                 $this->update_transient( $post_id, $shydata );
    269 
     384        public function can_edit_meta( $allowed, $meta_key, $post_id ) {
     385                return current_user_can( 'edit_post', $post_id );
     386        }
     387
     388        /**
     389         * Load the block editor sidebar control
     390         *
     391         * @access public
     392         * @since 2.0
     393         * @return void
     394         */
     395        public function enqueue_block_editor_assets() {
     396                // This hook also fires in the site and widget editors, which have no post to flag.
     397                $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
     398                if ( ! $screen || 'post' !== $screen->base ) {
     399                        return;
     400                }
     401
     402                wp_enqueue_script(
     403                        'shy-posts-editor',
     404                        plugins_url( 'js/shy-posts-editor.js', __FILE__ ),
     405                        array( 'wp-plugins', 'wp-edit-post', 'wp-editor', 'wp-element', 'wp-components', 'wp-data', 'wp-i18n' ),
     406                        self::VERSION,
     407                        true
     408                );
     409
     410                wp_set_script_translations( 'shy-posts-editor', 'shy-posts' );
     411        }
     412
     413        /**
     414         * Update the stored list when the shy meta is added or changed
     415         *
     416         * @access public
     417         * @since 2.0
     418         * @param int    $meta_id    ID of the meta row.
     419         * @param int    $post_id    Post the meta belongs to.
     420         * @param string $meta_key   Meta key that changed.
     421         * @param mixed  $meta_value New meta value.
     422         * @return void
     423         */
     424        public function meta_changed( $meta_id, $post_id, $meta_key, $meta_value ) {
     425                if ( self::META_KEY !== $meta_key || wp_is_post_revision( $post_id ) ) {
     426                        return;
     427                }
     428
     429                $this->update_shy_post_ids( $post_id, '1' === (string) $meta_value );
     430        }
     431
     432        /**
     433         * Update the stored list when the shy meta is deleted, including when its post is deleted.
     434         * Trashed posts keep their meta, so they're still shy if restored.
     435         *
     436         * @access public
     437         * @since 2.0
     438         * @param array  $meta_ids IDs of the deleted meta rows.
     439         * @param int    $post_id  Post the meta belonged to.
     440         * @param string $meta_key Meta key that was deleted.
     441         * @return void
     442         */
     443        public function meta_deleted( $meta_ids, $post_id, $meta_key ) {
     444                if ( self::META_KEY !== $meta_key ) {
     445                        return;
     446                }
     447
     448                $this->update_shy_post_ids( $post_id, false );
    270449        }
    271450
    … …  
    274453         *
    275454         * @access public
    276          * @param mixed $query
     455         * @param WP_Query $query Query about to run.
    277456         * @return void
    278457         */
    279458        public function exclude_shy_posts_from_homepage( $query ) {
    280                 // make sure we're looking in the right place
    281                 if ( is_front_page() AND $query->is_main_query() ) {
    282 
    283                         // exclude all posts in the array of hidden posts
    284                         $query->set('post__not_in', $this->shy_post_ids);
    285 
    286                 }
    287         }
    288 
    289 // end class
     459                // Make sure we're looking in the right place.
     460                if ( ! $query->is_main_query() || ! $query->is_front_page() ) {
     461                        return;
     462                }
     463
     464                $shy_post_ids = $this->get_shy_post_ids();
     465                if ( empty( $shy_post_ids ) ) {
     466                        return;
     467                }
     468
     469                // Add to any exclusions something else already set, rather than replacing them.
     470                $not_in = array_merge( (array) $query->get( 'post__not_in' ), $shy_post_ids );
     471
     472                $query->set( 'post__not_in', array_values( array_unique( array_map( 'intval', $not_in ) ) ) );
     473        }
     474
    290475}
Note: See TracChangeset for help on using the changeset viewer.