├── .babelrc ├── .bowerrc ├── .editorconfig ├── .gitignore ├── .jshintrc ├── .wordpress-org ├── banner-1544x500.png ├── banner-772x250.png ├── icon-128x128.png ├── icon-256x256.png ├── icon.svg ├── screenshot-1.jpg ├── screenshot-2.jpg ├── screenshot-3.jpg └── screenshot-4.jpg ├── Gruntfile.js ├── assets ├── css │ ├── readme.md │ ├── sass │ │ └── wp-post-forking.scss │ ├── wp-post-forking.css │ ├── wp-post-forking.css.map │ └── wp-post-forking.min.css └── js │ ├── src │ └── wp-post-forking.js │ ├── wp-post-forking.js │ └── wp-post-forking.min.js ├── autoload.php ├── bin └── install-wp-tests.sh ├── bower.json ├── composer.json ├── dist └── main.js ├── includes ├── API.php ├── API │ ├── ForkPostController.php │ └── MergePostController.php ├── Forking │ ├── AbstractForker.php │ ├── AbstractMerger.php │ ├── PostForker.php │ └── PostMerger.php ├── Plugin.php ├── Posts.php ├── Posts │ ├── ArchivedForks.php │ ├── Notices.php │ ├── PostTypeSupport.php │ ├── PublishingButtons.php │ ├── Statuses.php │ ├── Statuses │ │ ├── AbstractStatus.php │ │ ├── ArchivedForkStatus.php │ │ ├── DraftForkStatus.php │ │ └── PendingForkStatus.php │ └── Trash.php ├── functions │ ├── db-helpers.php │ ├── helpers.php │ ├── log-helpers.php │ └── post-helpers.php └── readme.md ├── languages └── forkit.pot ├── package-lock.json ├── package.json ├── readme.md ├── src └── index.js ├── tasks ├── _template.js ├── build.js ├── css.js ├── default.js ├── js.js ├── options │ ├── _template.js │ ├── clean.js │ ├── compress.js │ ├── concat.js │ ├── copy.js │ ├── cssmin.js │ ├── jshint.js │ ├── mocha.js │ ├── phpunit.js │ ├── postcss.js │ ├── sass.js │ ├── uglify.js │ └── watch.js └── test.js ├── tests └── phpunit │ ├── PluginTests.php │ ├── bootstrap.php │ └── test-tools │ └── TestCase.php ├── webpack.config.js └── wp-safe-edit.php /.babelrc: -------------------------------------------------------------------------------- 1 | { "presets": ["react", "es2015"] } 2 | -------------------------------------------------------------------------------- /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "bower_components" 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | # WordPress Coding Standards 5 | # http://make.wordpress.org/core/handbook/coding-standards/ 6 | 7 | root = true 8 | 9 | [*] 10 | charset = utf-8 11 | end_of_line = lf 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | indent_style = tab 15 | 16 | [*.json] 17 | indent_style = space 18 | indent_size = 2 19 | 20 | [*.txt,wp-config-sample.php] 21 | end_of_line = crlf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | release 3 | vendor 4 | composer.lock 5 | phpunit.xml 6 | .idea -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "curly": true, 3 | "eqeqeq": true, 4 | "immed": true, 5 | "latedef": true, 6 | "newcap": true, 7 | "noarg": true, 8 | "sub": true, 9 | "undef": true, 10 | "boss": true, 11 | "eqnull": true, 12 | "validthis": true, 13 | "globals": { 14 | "exports": true, 15 | "module": false, 16 | "console": true, 17 | "document": true, 18 | "window": true, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.wordpress-org/banner-1544x500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/10up/wp-safe-edit/cd18ada86649e56b3e1a5bc3d97967530c0654f0/.wordpress-org/banner-1544x500.png -------------------------------------------------------------------------------- /.wordpress-org/banner-772x250.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/10up/wp-safe-edit/cd18ada86649e56b3e1a5bc3d97967530c0654f0/.wordpress-org/banner-772x250.png -------------------------------------------------------------------------------- /.wordpress-org/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/10up/wp-safe-edit/cd18ada86649e56b3e1a5bc3d97967530c0654f0/.wordpress-org/icon-128x128.png -------------------------------------------------------------------------------- /.wordpress-org/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/10up/wp-safe-edit/cd18ada86649e56b3e1a5bc3d97967530c0654f0/.wordpress-org/icon-256x256.png -------------------------------------------------------------------------------- /.wordpress-org/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /.wordpress-org/screenshot-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/10up/wp-safe-edit/cd18ada86649e56b3e1a5bc3d97967530c0654f0/.wordpress-org/screenshot-1.jpg -------------------------------------------------------------------------------- /.wordpress-org/screenshot-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/10up/wp-safe-edit/cd18ada86649e56b3e1a5bc3d97967530c0654f0/.wordpress-org/screenshot-2.jpg -------------------------------------------------------------------------------- /.wordpress-org/screenshot-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/10up/wp-safe-edit/cd18ada86649e56b3e1a5bc3d97967530c0654f0/.wordpress-org/screenshot-3.jpg -------------------------------------------------------------------------------- /.wordpress-org/screenshot-4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/10up/wp-safe-edit/cd18ada86649e56b3e1a5bc3d97967530c0654f0/.wordpress-org/screenshot-4.jpg -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | 3 | // require `load-grunt-tasks`, which loads all grunt tasks defined in package.json 4 | require('load-grunt-tasks')(grunt); 5 | // load tasks defined in the `/tasks` folder 6 | grunt.loadTasks('tasks'); 7 | 8 | // Function to load the options for each grunt module 9 | var loadConfig = function (path) { 10 | var glob = require('glob'); 11 | var object = {}; 12 | var key; 13 | 14 | glob.sync('*', {cwd: path}).forEach(function(option) { 15 | key = option.replace(/\.js$/,''); 16 | object[key] = require(path + option); 17 | }); 18 | 19 | return object; 20 | }; 21 | 22 | var config = { 23 | pkg: grunt.file.readJSON('package.json'), 24 | env: process.env 25 | }; 26 | 27 | grunt.util._.extend(config, loadConfig('./tasks/options/')); 28 | 29 | grunt.initConfig(config); 30 | 31 | }; 32 | -------------------------------------------------------------------------------- /assets/css/readme.md: -------------------------------------------------------------------------------- 1 | # Styles 2 | 3 | Only final CSS styles should exist in this folder. If you are using SASS, LESS, autoprefixer, or some other pre-processor, please place your raw source files in a subdirectory. 4 | -------------------------------------------------------------------------------- /assets/css/sass/wp-post-forking.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * WP Safe Edit 3 | * https://github.com/10up/WP-Safe-Edit 4 | * 5 | * Copyright (c) 2017 Michael Phillips 6 | * Licensed under the MIT license. 7 | */ 8 | 9 | .wpse-view-source-post-message, 10 | .wpse-fork-exists-message, 11 | .wpse-viewing-archived-fork-message { 12 | margin-bottom: 10px; 13 | } 14 | 15 | .wpse-fork-post-button-wrapper, 16 | .wpse-merge-post-button-wrapper { 17 | float: right; 18 | line-height: 23px; 19 | clear: both; 20 | margin-bottom: 10px; 21 | } 22 | 23 | #publishing-action { 24 | clear: both; 25 | } 26 | 27 | #wpse-lock-dialog .post-locked-message { 28 | margin: 25px; 29 | } 30 | 31 | .wpse-spinner { 32 | float: left; 33 | } 34 | -------------------------------------------------------------------------------- /assets/css/wp-post-forking.css: -------------------------------------------------------------------------------- 1 | /** 2 | * WP Safe Edit 3 | * https://github.com/10up/WP-Safe-Edit 4 | * 5 | * Copyright (c) 2017 Michael Phillips 6 | * Licensed under the MIT license. 7 | */ 8 | .wpse-view-source-post-message, 9 | .wpse-fork-exists-message, 10 | .wpse-viewing-archived-fork-message { 11 | margin-bottom: 10px; } 12 | 13 | .wpse-fork-post-button-wrapper, 14 | .wpse-merge-post-button-wrapper { 15 | float: right; 16 | line-height: 23px; 17 | clear: both; 18 | margin-bottom: 10px; } 19 | 20 | #publishing-action { 21 | clear: both; } 22 | 23 | #wpse-lock-dialog .post-locked-message { 24 | margin: 25px; } 25 | 26 | .wpse-spinner { 27 | float: left; } -------------------------------------------------------------------------------- /assets/css/wp-post-forking.css.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "file": "wp-post-forking.css", 4 | "sources": [ 5 | "sass/wp-post-forking.scss" 6 | ], 7 | "mappings": "AAAA;;;;;;GAMG;AAEH,AAAA,4BAA4B;AAC5B,AAAA,uBAAuB;AACvB,AAAA,iCAAiC,CAAC;EACjC,aAAa,EAAE,IAAK,GACpB;;AAED,AAAA,4BAA4B;AAC5B,AAAA,6BAA6B,CAAC;EAC7B,KAAK,EAAE,KAAM;EACb,WAAW,EAAE,IAAK;EAClB,KAAK,EAAE,IAAK;EACZ,aAAa,EAAE,IAAK,GACpB;;AAED,AAAA,kBAAkB,CAAC;EAClB,KAAK,EAAE,IAAK,GACZ;;AAED,AAAgB,eAAD,CAAC,oBAAoB,CAAC;EACpC,MAAM,EAAE,IAAK,GACb;;AAED,AAAA,WAAW,CAAC;EACX,KAAK,EAAE,IAAK,GACZ", 8 | "names": [] 9 | } -------------------------------------------------------------------------------- /assets/css/wp-post-forking.min.css: -------------------------------------------------------------------------------- 1 | .wpse-fork-exists-message,.wpse-view-source-post-message,.wpse-viewing-archived-fork-message{margin-bottom:10px}.wpse-fork-post-button-wrapper,.wpse-merge-post-button-wrapper{float:right;line-height:23px;clear:both;margin-bottom:10px}#publishing-action{clear:both}#wpse-lock-dialog .post-locked-message{margin:25px}.wpse-spinner{float:left} -------------------------------------------------------------------------------- /assets/js/src/wp-post-forking.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WP Safe Edit 3 | * https://github.com/10up/WP-Safe-Edit 4 | * 5 | * Copyright (c) 2017 Michael Phillips 6 | * Licensed under the MIT license. 7 | */ 8 | 9 | ( function( $, window, undefined ) { 10 | 'use strict'; 11 | var form = null, 12 | formActionField = null, 13 | formSpinner = null, 14 | postID = 0, 15 | blogID = 0; 16 | 17 | function getPostID() { 18 | if ( ! postID ) { 19 | postID = $( document.getElementById('post_ID') ).val() || 0; 20 | } 21 | 22 | return postID; 23 | } 24 | 25 | function getblogID() { 26 | if ( ! blogID ) { 27 | blogID = typeof window.autosaveL10n !== 'undefined' && window.autosaveL10n.blog_id; 28 | } 29 | 30 | return blogID; 31 | } 32 | 33 | function getPostForm() { 34 | if ( ! form ) { 35 | form = document.querySelector('form#post'); 36 | } 37 | 38 | return form; 39 | } 40 | 41 | function getPostFormActionField() { 42 | if ( ! formActionField ) { 43 | var form = getPostForm(); 44 | 45 | if ( ! form ) { 46 | return; 47 | } 48 | 49 | formActionField = form.querySelector('input[name=action]'); 50 | } 51 | 52 | return formActionField; 53 | } 54 | 55 | /** 56 | * Clear the stored session data in the browser for a post. 57 | */ 58 | function clearStoredPostData() { 59 | var postID = getPostID(), 60 | storedData = getStoredPostData(); 61 | 62 | if ( ! postID || ! storedData ) { 63 | return; 64 | } 65 | 66 | storedData = JSON.parse( storedData ); 67 | 68 | if ( ! storedData.hasOwnProperty( 'post_' + postID ) ) { 69 | return; 70 | } 71 | 72 | delete storedData[ 'post_' + postID ]; 73 | 74 | saveStoredPostData( storedData ); 75 | } 76 | 77 | function getStoredPostData() { 78 | var blogID = getblogID(); 79 | 80 | if ( 81 | ! window.sessionStorage || 82 | ! blogID 83 | ) { 84 | return; 85 | } 86 | 87 | return window.sessionStorage.getItem( 'wp-autosave-' + blogID ); 88 | } 89 | 90 | function saveStoredPostData( data ) { 91 | var blogID = getblogID(); 92 | 93 | if ( 94 | ! window.sessionStorage || 95 | ! blogID 96 | ) { 97 | return; 98 | } 99 | 100 | var key = 'wp-autosave-' + blogID; 101 | window.sessionStorage.setItem( key, JSON.stringify( data ) ); 102 | } 103 | 104 | function getPostFormSpinner() { 105 | if ( ! formSpinner ) { 106 | var form = getPostForm(); 107 | 108 | if ( ! form ) { 109 | return; 110 | } 111 | 112 | formSpinner = form.querySelector('.wpse-spinner'); 113 | } 114 | 115 | return formSpinner; 116 | } 117 | 118 | function showPostFormSpinner() { 119 | var spinner = getPostFormSpinner(); 120 | 121 | if ( ! spinner ) { 122 | return; 123 | } 124 | 125 | $( spinner ).addClass( 'is-active' ); 126 | } 127 | 128 | function hidePostFormSpinner() { 129 | var spinner = getPostFormSpinner(); 130 | 131 | if ( ! spinner ) { 132 | return; 133 | } 134 | 135 | $( spinner ).removeClass( 'is-active' ); 136 | } 137 | 138 | var ForkPostSupport = function () { 139 | this.forkButton = null; 140 | }; 141 | 142 | var MergePostSupport = function () { 143 | this.mergeButton = null; 144 | }; 145 | 146 | ForkPostSupport.prototype = { 147 | init: function () { 148 | var self = this; 149 | $(document).ready( function () { 150 | setTimeout( function() { 151 | self.setupEvents(); 152 | self.setupLockDialog(); 153 | }, 500 ); 154 | } ); 155 | }, 156 | 157 | setupEvents: function() { 158 | this.forkButton = this.getForkButton(); 159 | 160 | if ( this.forkButton ) { 161 | $(this.forkButton).on('click', $.proxy( 162 | this.didClickForkButton, this 163 | )); 164 | } 165 | }, 166 | 167 | setupLockDialog: function() { 168 | this.lockDialog = this.getLockDialog(); 169 | 170 | if ( this.lockDialog ) { 171 | var $notificationEl = $( this.lockDialog ).find('.notification-dialog'); 172 | 173 | if ( 0 === $notificationEl.length ) { 174 | return; 175 | } 176 | 177 | $notificationEl.find( '.wp-tab-first' ).focus(); 178 | 179 | $( '.notification-dialog-background' ).on( 'click', function(e) { 180 | $notificationEl.find( '.wp-tab-first' ).focus(); 181 | e.preventDefault(); 182 | }); 183 | 184 | // Contain focus inside the dialog. If the dialog is shown, focus the first item. This code borrowed from WordPress's post lock diagram. 185 | $notificationEl.on( 'keydown', function(e) { 186 | // Don't do anything unless [tab] is pressed. 187 | if ( e.which !== 9 ) { 188 | return; 189 | } 190 | 191 | var $target = $( e.target ); 192 | 193 | // [shift] + [tab] on first tab cycles back to last tab. 194 | if ( $target.hasClass( 'wp-tab-first' ) && e.shiftKey ) { 195 | $( this ).find( '.wp-tab-last' ).focus(); 196 | e.preventDefault(); 197 | 198 | // [tab] on last tab cycles back to first tab. 199 | } else if ( $target.hasClass( 'wp-tab-last' ) && ! e.shiftKey ) { 200 | $( this ).find( '.wp-tab-first' ).focus(); 201 | e.preventDefault(); 202 | } 203 | }).filter( ':visible' ).find( '.wp-tab-first' ).focus(); 204 | } 205 | }, 206 | 207 | getLockDialog: function() { 208 | if ( ! this.lockDialog ) { 209 | this.lockDialog = document.getElementById('wpse-lock-dialog'); 210 | } 211 | 212 | return this.lockDialog; 213 | }, 214 | 215 | getForkButton: function() { 216 | if ( ! this.forkButton ) { 217 | this.forkButton = document.getElementById('wpse-fork-post-button'); 218 | } 219 | 220 | return this.forkButton; 221 | }, 222 | 223 | didClickForkButton: function (event) { 224 | var form = getPostForm(), 225 | formActionField = getPostFormActionField(); 226 | 227 | if ( ! form || ! formActionField ) { 228 | event.preventDefault(); 229 | return false; 230 | } 231 | 232 | // Change the action sent to post.php 233 | formActionField.setAttribute('value', 'fork_post'); 234 | 235 | // Clear the stored session data for this post to prevent the "The backup of this post in your browser is different from the version below" notice from showing after you fork a post. 236 | clearStoredPostData(); 237 | 238 | showPostFormSpinner(); 239 | }, 240 | }; 241 | 242 | MergePostSupport.prototype = { 243 | init: function () { 244 | var self = this; 245 | 246 | $(document).ready( function () { 247 | setTimeout( function() { 248 | self.setupEvents(); 249 | }, 500 ); 250 | }); 251 | }, 252 | 253 | setupEvents: function() { 254 | this.mergeButton = this.getMergeButton(); 255 | 256 | if ( this.mergeButton ) { 257 | $(this.mergeButton).on('click', $.proxy( 258 | this.didClickMergeButton, this 259 | )); 260 | } 261 | }, 262 | 263 | getMergeButton: function() { 264 | if ( ! this.mergeButton ) { 265 | this.mergeButton = document.getElementById('wpse-merge-post-button'); 266 | } 267 | 268 | return this.mergeButton; 269 | }, 270 | 271 | didClickMergeButton: function (event) { 272 | var form = getPostForm(), 273 | formActionField = getPostFormActionField(); 274 | 275 | if ( ! form || ! formActionField ) { 276 | event.preventDefault(); 277 | return false; 278 | } 279 | 280 | // Change the action sent to post.php 281 | formActionField.setAttribute('value', 'merge_post'); 282 | 283 | // Clear the stored session data for this post to prevent the "The backup of this post in your browser is different from the version below" notice from showing after you merge a post. 284 | clearStoredPostData(); 285 | 286 | showPostFormSpinner(); 287 | }, 288 | }; 289 | 290 | var forkPostSupport = new ForkPostSupport(); 291 | forkPostSupport.init(); 292 | 293 | var mergePostSupport = new MergePostSupport(); 294 | mergePostSupport.init(); 295 | 296 | } )( jQuery, this ); 297 | -------------------------------------------------------------------------------- /assets/js/wp-post-forking.js: -------------------------------------------------------------------------------- 1 | /*! WP Safe Edit - v0.1.0 2 | * https://github.com/10up/WP-Safe-Edit 3 | * Copyright (c) 2018; * Licensed MIT */ 4 | ( function( $, window, undefined ) { 5 | 'use strict'; 6 | var form = null, 7 | formActionField = null, 8 | formSpinner = null, 9 | postID = 0, 10 | blogID = 0; 11 | 12 | function getPostID() { 13 | if ( ! postID ) { 14 | postID = $( document.getElementById('post_ID') ).val() || 0; 15 | } 16 | 17 | return postID; 18 | } 19 | 20 | function getblogID() { 21 | if ( ! blogID ) { 22 | blogID = typeof window.autosaveL10n !== 'undefined' && window.autosaveL10n.blog_id; 23 | } 24 | 25 | return blogID; 26 | } 27 | 28 | function getPostForm() { 29 | if ( ! form ) { 30 | form = document.querySelector('form#post'); 31 | } 32 | 33 | return form; 34 | } 35 | 36 | function getPostFormActionField() { 37 | if ( ! formActionField ) { 38 | var form = getPostForm(); 39 | 40 | if ( ! form ) { 41 | return; 42 | } 43 | 44 | formActionField = form.querySelector('input[name=action]'); 45 | } 46 | 47 | return formActionField; 48 | } 49 | 50 | /** 51 | * Clear the stored session data in the browser for a post. 52 | */ 53 | function clearStoredPostData() { 54 | var postID = getPostID(), 55 | storedData = getStoredPostData(); 56 | 57 | if ( ! postID || ! storedData ) { 58 | return; 59 | } 60 | 61 | storedData = JSON.parse( storedData ); 62 | 63 | if ( ! storedData.hasOwnProperty( 'post_' + postID ) ) { 64 | return; 65 | } 66 | 67 | delete storedData[ 'post_' + postID ]; 68 | 69 | saveStoredPostData( storedData ); 70 | } 71 | 72 | function getStoredPostData() { 73 | var blogID = getblogID(); 74 | 75 | if ( 76 | ! window.sessionStorage || 77 | ! blogID 78 | ) { 79 | return; 80 | } 81 | 82 | return window.sessionStorage.getItem( 'wp-autosave-' + blogID ); 83 | } 84 | 85 | function saveStoredPostData( data ) { 86 | var blogID = getblogID(); 87 | 88 | if ( 89 | ! window.sessionStorage || 90 | ! blogID 91 | ) { 92 | return; 93 | } 94 | 95 | var key = 'wp-autosave-' + blogID; 96 | window.sessionStorage.setItem( key, JSON.stringify( data ) ); 97 | } 98 | 99 | function getPostFormSpinner() { 100 | if ( ! formSpinner ) { 101 | var form = getPostForm(); 102 | 103 | if ( ! form ) { 104 | return; 105 | } 106 | 107 | formSpinner = form.querySelector('.wpse-spinner'); 108 | } 109 | 110 | return formSpinner; 111 | } 112 | 113 | function showPostFormSpinner() { 114 | var spinner = getPostFormSpinner(); 115 | 116 | if ( ! spinner ) { 117 | return; 118 | } 119 | 120 | $( spinner ).addClass( 'is-active' ); 121 | } 122 | 123 | function hidePostFormSpinner() { 124 | var spinner = getPostFormSpinner(); 125 | 126 | if ( ! spinner ) { 127 | return; 128 | } 129 | 130 | $( spinner ).removeClass( 'is-active' ); 131 | } 132 | 133 | var ForkPostSupport = function () { 134 | this.forkButton = null; 135 | }; 136 | 137 | var MergePostSupport = function () { 138 | this.mergeButton = null; 139 | }; 140 | 141 | ForkPostSupport.prototype = { 142 | init: function () { 143 | var self = this; 144 | $(document).ready( function () { 145 | setTimeout( function() { 146 | self.setupEvents(); 147 | self.setupLockDialog(); 148 | }, 500 ); 149 | } ); 150 | }, 151 | 152 | setupEvents: function() { 153 | this.forkButton = this.getForkButton(); 154 | 155 | if ( this.forkButton ) { 156 | $(this.forkButton).on('click', $.proxy( 157 | this.didClickForkButton, this 158 | )); 159 | } 160 | }, 161 | 162 | setupLockDialog: function() { 163 | this.lockDialog = this.getLockDialog(); 164 | 165 | if ( this.lockDialog ) { 166 | var $notificationEl = $( this.lockDialog ).find('.notification-dialog'); 167 | 168 | if ( 0 === $notificationEl.length ) { 169 | return; 170 | } 171 | 172 | $notificationEl.find( '.wp-tab-first' ).focus(); 173 | 174 | $( '.notification-dialog-background' ).on( 'click', function(e) { 175 | $notificationEl.find( '.wp-tab-first' ).focus(); 176 | e.preventDefault(); 177 | }); 178 | 179 | // Contain focus inside the dialog. If the dialog is shown, focus the first item. This code borrowed from WordPress's post lock diagram. 180 | $notificationEl.on( 'keydown', function(e) { 181 | // Don't do anything unless [tab] is pressed. 182 | if ( e.which !== 9 ) { 183 | return; 184 | } 185 | 186 | var $target = $( e.target ); 187 | 188 | // [shift] + [tab] on first tab cycles back to last tab. 189 | if ( $target.hasClass( 'wp-tab-first' ) && e.shiftKey ) { 190 | $( this ).find( '.wp-tab-last' ).focus(); 191 | e.preventDefault(); 192 | 193 | // [tab] on last tab cycles back to first tab. 194 | } else if ( $target.hasClass( 'wp-tab-last' ) && ! e.shiftKey ) { 195 | $( this ).find( '.wp-tab-first' ).focus(); 196 | e.preventDefault(); 197 | } 198 | }).filter( ':visible' ).find( '.wp-tab-first' ).focus(); 199 | } 200 | }, 201 | 202 | getLockDialog: function() { 203 | if ( ! this.lockDialog ) { 204 | this.lockDialog = document.getElementById('wpse-lock-dialog'); 205 | } 206 | 207 | return this.lockDialog; 208 | }, 209 | 210 | getForkButton: function() { 211 | if ( ! this.forkButton ) { 212 | this.forkButton = document.getElementById('wpse-fork-post-button'); 213 | } 214 | 215 | return this.forkButton; 216 | }, 217 | 218 | didClickForkButton: function (event) { 219 | var form = getPostForm(), 220 | formActionField = getPostFormActionField(); 221 | 222 | if ( ! form || ! formActionField ) { 223 | event.preventDefault(); 224 | return false; 225 | } 226 | 227 | // Change the action sent to post.php 228 | formActionField.setAttribute('value', 'fork_post'); 229 | 230 | // Clear the stored session data for this post to prevent the "The backup of this post in your browser is different from the version below" notice from showing after you fork a post. 231 | clearStoredPostData(); 232 | 233 | showPostFormSpinner(); 234 | }, 235 | }; 236 | 237 | MergePostSupport.prototype = { 238 | init: function () { 239 | var self = this; 240 | 241 | $(document).ready( function () { 242 | setTimeout( function() { 243 | self.setupEvents(); 244 | }, 500 ); 245 | }); 246 | }, 247 | 248 | setupEvents: function() { 249 | this.mergeButton = this.getMergeButton(); 250 | 251 | if ( this.mergeButton ) { 252 | $(this.mergeButton).on('click', $.proxy( 253 | this.didClickMergeButton, this 254 | )); 255 | } 256 | }, 257 | 258 | getMergeButton: function() { 259 | if ( ! this.mergeButton ) { 260 | this.mergeButton = document.getElementById('wpse-merge-post-button'); 261 | } 262 | 263 | return this.mergeButton; 264 | }, 265 | 266 | didClickMergeButton: function (event) { 267 | var form = getPostForm(), 268 | formActionField = getPostFormActionField(); 269 | 270 | if ( ! form || ! formActionField ) { 271 | event.preventDefault(); 272 | return false; 273 | } 274 | 275 | // Change the action sent to post.php 276 | formActionField.setAttribute('value', 'merge_post'); 277 | 278 | // Clear the stored session data for this post to prevent the "The backup of this post in your browser is different from the version below" notice from showing after you merge a post. 279 | clearStoredPostData(); 280 | 281 | showPostFormSpinner(); 282 | }, 283 | }; 284 | 285 | var forkPostSupport = new ForkPostSupport(); 286 | forkPostSupport.init(); 287 | 288 | var mergePostSupport = new MergePostSupport(); 289 | mergePostSupport.init(); 290 | 291 | } )( jQuery, this ); 292 | -------------------------------------------------------------------------------- /assets/js/wp-post-forking.min.js: -------------------------------------------------------------------------------- 1 | /*! WP Safe Edit - v0.1.0 2 | * https://github.com/10up/WP-Safe-Edit 3 | * Copyright (c) 2018; * Licensed MIT */ 4 | !function(a,b,c){"use strict";function d(){return p||(p=a(document.getElementById("post_ID")).val()||0),p}function e(){return q||(q=void 0!==b.autosaveL10n&&b.autosaveL10n.blog_id),q}function f(){return m||(m=document.querySelector("form#post")),m}function g(){if(!n){var a=f();if(!a)return;n=a.querySelector("input[name=action]")}return n}function h(){var a=d(),b=i();a&&b&&(b=JSON.parse(b),b.hasOwnProperty("post_"+a)&&(delete b["post_"+a],j(b)))}function i(){var a=e();if(b.sessionStorage&&a)return b.sessionStorage.getItem("wp-autosave-"+a)}function j(a){var c=e();if(b.sessionStorage&&c){var d="wp-autosave-"+c;b.sessionStorage.setItem(d,JSON.stringify(a))}}function k(){if(!o){var a=f();if(!a)return;o=a.querySelector(".wpse-spinner")}return o}function l(){var b=k();b&&a(b).addClass("is-active")}var m=null,n=null,o=null,p=0,q=0,r=function(){this.forkButton=null},s=function(){this.mergeButton=null};r.prototype={init:function(){var b=this;a(document).ready(function(){setTimeout(function(){b.setupEvents(),b.setupLockDialog()},500)})},setupEvents:function(){this.forkButton=this.getForkButton(),this.forkButton&&a(this.forkButton).on("click",a.proxy(this.didClickForkButton,this))},setupLockDialog:function(){if(this.lockDialog=this.getLockDialog(),this.lockDialog){var b=a(this.lockDialog).find(".notification-dialog");if(0===b.length)return;b.find(".wp-tab-first").focus(),a(".notification-dialog-background").on("click",function(a){b.find(".wp-tab-first").focus(),a.preventDefault()}),b.on("keydown",function(b){if(9===b.which){var c=a(b.target);c.hasClass("wp-tab-first")&&b.shiftKey?(a(this).find(".wp-tab-last").focus(),b.preventDefault()):c.hasClass("wp-tab-last")&&!b.shiftKey&&(a(this).find(".wp-tab-first").focus(),b.preventDefault())}}).filter(":visible").find(".wp-tab-first").focus()}},getLockDialog:function(){return this.lockDialog||(this.lockDialog=document.getElementById("wpse-lock-dialog")),this.lockDialog},getForkButton:function(){return this.forkButton||(this.forkButton=document.getElementById("wpse-fork-post-button")),this.forkButton},didClickForkButton:function(a){var b=f(),c=g();if(!b||!c)return a.preventDefault(),!1;c.setAttribute("value","fork_post"),h(),l()}},s.prototype={init:function(){var b=this;a(document).ready(function(){setTimeout(function(){b.setupEvents()},500)})},setupEvents:function(){this.mergeButton=this.getMergeButton(),this.mergeButton&&a(this.mergeButton).on("click",a.proxy(this.didClickMergeButton,this))},getMergeButton:function(){return this.mergeButton||(this.mergeButton=document.getElementById("wpse-merge-post-button")),this.mergeButton},didClickMergeButton:function(a){var b=f(),c=g();if(!b||!c)return a.preventDefault(),!1;c.setAttribute("value","merge_post"),h(),l()}},(new r).init(),(new s).init()}(jQuery,this); -------------------------------------------------------------------------------- /autoload.php: -------------------------------------------------------------------------------- 1 | [db-host] [wp-version]" 5 | exit 1 6 | fi 7 | 8 | DB_NAME=$1 9 | DB_USER=$2 10 | DB_PASS=$3 11 | DB_HOST=${4-localhost} 12 | WP_VERSION=${5-latest} 13 | 14 | WP_TESTS_DIR=${WP_TESTS_DIR-/tmp/wordpress-tests-lib} 15 | WP_CORE_DIR=${WP_CORE_DIR-/tmp/wordpress/} 16 | 17 | download() { 18 | if [ `which curl` ]; then 19 | curl -s "$1" > "$2"; 20 | elif [ `which wget` ]; then 21 | wget -nv -O "$2" "$1" 22 | fi 23 | } 24 | 25 | if [[ $WP_VERSION =~ [0-9]+\.[0-9]+(\.[0-9]+)? ]]; then 26 | WP_TESTS_TAG="tags/$WP_VERSION" 27 | elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then 28 | WP_TESTS_TAG="trunk" 29 | else 30 | # http serves a single offer, whereas https serves multiple. we only want one 31 | download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json 32 | grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json 33 | LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//') 34 | if [[ -z "$LATEST_VERSION" ]]; then 35 | echo "Latest WordPress version could not be found" 36 | exit 1 37 | fi 38 | WP_TESTS_TAG="tags/$LATEST_VERSION" 39 | fi 40 | 41 | set -ex 42 | 43 | install_wp() { 44 | 45 | if [ -d $WP_CORE_DIR ]; then 46 | return; 47 | fi 48 | 49 | mkdir -p $WP_CORE_DIR 50 | 51 | if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then 52 | mkdir -p /tmp/wordpress-nightly 53 | download https://wordpress.org/nightly-builds/wordpress-latest.zip /tmp/wordpress-nightly/wordpress-nightly.zip 54 | unzip -q /tmp/wordpress-nightly/wordpress-nightly.zip -d /tmp/wordpress-nightly/ 55 | mv /tmp/wordpress-nightly/wordpress/* $WP_CORE_DIR 56 | else 57 | if [ $WP_VERSION == 'latest' ]; then 58 | local ARCHIVE_NAME='latest' 59 | else 60 | local ARCHIVE_NAME="wordpress-$WP_VERSION" 61 | fi 62 | download https://wordpress.org/${ARCHIVE_NAME}.tar.gz /tmp/wordpress.tar.gz 63 | tar --strip-components=1 -zxmf /tmp/wordpress.tar.gz -C $WP_CORE_DIR 64 | fi 65 | 66 | download https://raw.github.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php 67 | } 68 | 69 | install_test_suite() { 70 | # portable in-place argument for both GNU sed and Mac OSX sed 71 | if [[ $(uname -s) == 'Darwin' ]]; then 72 | local ioption='-i .bak' 73 | else 74 | local ioption='-i' 75 | fi 76 | 77 | # set up testing suite if it doesn't yet exist 78 | if [ ! -d $WP_TESTS_DIR ]; then 79 | # set up testing suite 80 | mkdir -p $WP_TESTS_DIR 81 | svn co --quiet https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes 82 | fi 83 | 84 | if [ ! -f wp-tests-config.php ]; then 85 | download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php 86 | sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR':" "$WP_TESTS_DIR"/wp-tests-config.php 87 | sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php 88 | sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php 89 | sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php 90 | sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php 91 | fi 92 | 93 | } 94 | 95 | install_db() { 96 | # parse DB_HOST for port or socket references 97 | local PARTS=(${DB_HOST//\:/ }) 98 | local DB_HOSTNAME=${PARTS[0]}; 99 | local DB_SOCK_OR_PORT=${PARTS[1]}; 100 | local EXTRA="" 101 | 102 | if ! [ -z $DB_HOSTNAME ] ; then 103 | if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then 104 | EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" 105 | elif ! [ -z $DB_SOCK_OR_PORT ] ; then 106 | EXTRA=" --socket=$DB_SOCK_OR_PORT" 107 | elif ! [ -z $DB_HOSTNAME ] ; then 108 | EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" 109 | fi 110 | fi 111 | 112 | # create database 113 | mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA 114 | } 115 | 116 | install_wp 117 | install_test_suite 118 | install_db 119 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wp-post-forking", 3 | "version": "0.1.0", 4 | "main": "/", 5 | "license": "MIT", 6 | "ignore": [ 7 | "**/.*", 8 | "*.md", 9 | "assets/css/sass/*", 10 | "assets/css/src/*", 11 | "assets/js/src/*", 12 | "assets/js/vendor/*", 13 | "images/src/*", 14 | "tests", 15 | "bootstrap.php", 16 | "Gruntfile.js", 17 | "package.json", 18 | "composer.json", 19 | "phpunit.xml.dist" 20 | ], 21 | "devDependencies": {}, 22 | "keywords": [] 23 | } 24 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "10up/wp-safe-edit", 3 | "description": "Edit published posts safely behind the scenes and publish the changes when ready.", 4 | "version": "0.1.0", 5 | "type": "wordpress-plugin", 6 | "keywords": [], 7 | "homepage": "https://github.com/10up/WP-Safe-Edit", 8 | "license": "MIT", 9 | "authors": [ 10 | { 11 | "name": "Michael Phillips", 12 | "email": "michael.phillips@10up.com", 13 | "role": "Developer" 14 | } 15 | ], 16 | "minimum-stability": "dev", 17 | "require": { 18 | "php": ">=5.4" 19 | }, 20 | "require-dev": { 21 | "phpunit/phpunit": "^5.7", 22 | "10up/wp_mock": "0.2.0" 23 | }, 24 | "autoload": { 25 | "psr-4": { 26 | "TenUp\\WPSafeEdit\\": "includes" 27 | } 28 | }, 29 | "abandoned": true 30 | } 31 | -------------------------------------------------------------------------------- /dist/main.js: -------------------------------------------------------------------------------- 1 | !function(e){var t={};function r(n){if(t[n])return t[n].exports;var o=t[n]={i:n,l:!1,exports:{}};return e[n].call(o.exports,o,o.exports,r),o.l=!0,o.exports}r.m=e,r.c=t,r.d=function(e,t,n){r.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)r.d(n,o,function(t){return e[t]}.bind(null,o));return n},r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,"a",t),t},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.p="",r(r.s=0)}([function(e,t,r){"use strict";var n=function(){function e(e,t){for(var r=0;rx.length&&x.push(e)}function $(e,t,r){return null==e?0:function e(t,r,n,o){var a=typeof t;"undefined"!==a&&"boolean"!==a||(t=null);var c=!1;if(null===t)c=!0;else switch(a){case"string":case"number":c=!0;break;case"object":switch(t.$$typeof){case i:case u:c=!0}}if(c)return n(o,t,""===r?"."+R(t,0):r),1;if(c=0,r=""===r?".":r+":",Array.isArray(t))for(var l=0;lfork_post_controller = new ForkPostController(); 17 | $this->merge_post_controller = new MergePostController(); 18 | } 19 | 20 | /** 21 | * Register hooks and actions. 22 | */ 23 | public function register() { 24 | $this->fork_post_controller->register(); 25 | $this->merge_post_controller->register(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /includes/API/ForkPostController.php: -------------------------------------------------------------------------------- 1 | \d+)', array( 25 | 'methods' => 'GET', 26 | 'callback' => 'TenUp\WPSafeEdit\API\ForkPostController::handle_fork_post_api_request', 27 | 'permission_callback' => '__return_true', 28 | 'args' => array( 29 | 'id' => array( 30 | 'required' => true, 31 | 'description' => esc_html__( 'Id of post that is being forked.', 'wp-safe-edit' ), 32 | 'type' => 'integer', 33 | ), 34 | 'nonce' => array( 35 | 'required' => true, 36 | 'description' => esc_html__( 'Action nonce.', 'wp-safe-edit' ), 37 | 'type' => 'string', 38 | ), 39 | ), 40 | 41 | ) ); 42 | } ); 43 | } 44 | 45 | // Handle REST API based forking requests. 46 | public static function handle_fork_post_api_request( $request ) { 47 | if ( ! wp_verify_nonce( sanitize_key( $_REQUEST['nonce'] ), 'post-fork' ) ) { 48 | return new \WP_Error( 49 | 'rest_cannot_create', 50 | esc_html__( 'Sorry, you are not allowed to fork posts.', 'wp-safe-edit' ), 51 | array( 'status' => rest_authorization_required_code() ) 52 | ); 53 | } 54 | 55 | $post_id = absint( $request['id'] ); 56 | 57 | if ( true !== Helpers\is_valid_post_id( $post_id ) ) { 58 | wp_send_json_error( 59 | esc_html__( 'Post could not be forked because the request did not provide a valid post ID.', 'wp-safe-edit' ) 60 | ); 61 | } 62 | 63 | // Adds slashes as data passed by API strips slashes. 64 | add_filter( 'safe_edit_prepared_post_data_for_fork', 'wp_slash' ); 65 | 66 | $forker = new PostForker(); 67 | $fork_post_id = $forker->fork( $post_id ); 68 | 69 | if ( true === Helpers\is_valid_post_id( $fork_post_id ) ) { 70 | do_action( 'safe_edit_post_fork_success', $fork_post_id, $post_id ); 71 | 72 | $message = self::get_post_forking_success_message( $fork_post_id, $post_id ); 73 | 74 | $url = get_edit_post_link( $fork_post_id, 'nodisplay' ); 75 | $url = add_query_arg( array( 76 | 'pf_success_message' => rawurlencode( $message ), 77 | ), $url ); 78 | $url = apply_filters( 'safe_edit_post_fork_success_redirect_url', $url, $fork_post_id, $post_id ); 79 | 80 | $data = array( 81 | 'shouldRedirect' => self::should_redirect(), 82 | 'redirectUrl' => $url, 83 | ); 84 | wp_send_json_success( $data ); 85 | 86 | } else { 87 | do_action( 'safe_edit_post_fork_failure', $post_id, $fork_post_id ); 88 | wp_send_json_error( self::get_post_forking_failure_message_from_result( $fork_post_id ) ); 89 | } 90 | } 91 | 92 | /** 93 | * Handle request to fork a post. 94 | */ 95 | public function handle_fork_post_request() { 96 | try { 97 | $post_id = $this->get_post_id_from_request(); 98 | 99 | if ( true !== Helpers\is_valid_post_id( $post_id ) ) { 100 | throw new Exception( 101 | esc_html__( 'Post could not be forked because the request did not provide a valid post ID.', 'wp-safe-edit' ) 102 | ); 103 | } 104 | 105 | if ( true !== $this->is_request_valid() ) { 106 | throw new Exception( 107 | esc_html__( 'Post could not be forked because the request was invalid.', 'wp-safe-edit' ) 108 | ); 109 | } 110 | 111 | $forker = new PostForker(); 112 | $result = $forker->fork( $post_id ); 113 | 114 | if ( true === Helpers\is_valid_post_id( $result ) ) { 115 | self::handle_fork_success( $result, $post_id ); 116 | } else { 117 | self::handle_fork_failure( $post_id, $result ); 118 | } 119 | 120 | } catch ( Exception $e ) { 121 | \TenUp\WPSafeEdit\Logging\log_exception( $e ); 122 | 123 | $result = new WP_Error( 124 | 'post_forker', 125 | $e->getMessage() 126 | ); 127 | 128 | self::handle_fork_failure( $post_id, $result ); 129 | } 130 | } 131 | 132 | /** 133 | * Handle a successful fork post request. 134 | * 135 | * @param int $fork_post_id The post ID of the post fork. 136 | * @param int $source_post_id The post ID of the post that was forked. 137 | */ 138 | public static function handle_fork_success( $fork_post_id, $source_post_id ) { 139 | do_action( 'safe_edit_post_fork_success', $fork_post_id, $source_post_id ); 140 | 141 | if ( true !== self::should_redirect() ) { 142 | return; 143 | } 144 | 145 | $message = self::get_post_forking_success_message( $fork_post_id, $source_post_id ); 146 | 147 | $url = get_edit_post_link( $fork_post_id, 'nodisplay' ); 148 | $url = add_query_arg( array( 149 | 'pf_success_message' => rawurlencode( $message ), 150 | ), $url ); 151 | 152 | $url = apply_filters( 'safe_edit_post_fork_success_redirect_url', $url, $fork_post_id, $source_post_id ); 153 | 154 | // Stay in the classic editor when forking from the classic editor. 155 | if ( isset( $_REQUEST[ 'classic-editor' ] ) ) { 156 | $url = add_query_arg( array( 157 | 'classic-editor' => true, 158 | ), $url ); 159 | } 160 | 161 | wp_safe_redirect( esc_url_raw( $url ) ); 162 | exit; 163 | } 164 | 165 | /** 166 | * Handle an unsuccessful fork post request. 167 | * 168 | * @param int $source_post_id The post ID of the post we attempted to fork. 169 | * @param \WP_Error|mixed $result The result from the fork request, usually a WP_Error. 170 | */ 171 | public static function handle_fork_failure( $source_post_id, $result ) { 172 | do_action( 'safe_edit_post_fork_failure', $source_post_id, $result ); 173 | 174 | if ( true !== self::should_redirect() ) { 175 | return; 176 | } 177 | 178 | $message = self::get_post_forking_failure_message_from_result( $result ); 179 | 180 | $url = get_edit_post_link( $source_post_id, 'nodisplay' ); 181 | $url = add_query_arg( array( 182 | 'pf_error_message' => rawurlencode( $message ), 183 | ), $url ); 184 | 185 | $url = apply_filters( 'safe_edit_post_fork_failure_redirect_url', $url, $source_post_id, $result ); 186 | 187 | wp_safe_redirect( esc_url_raw( $url ) ); 188 | exit; 189 | } 190 | 191 | /** 192 | * Get the feedback message for a user when a post could not be forked. 193 | * 194 | * @param \WP_Error|mixed $result The result from the fork request, usually a WP_Error. 195 | * @return string 196 | */ 197 | public static function get_post_forking_failure_message_from_result( $result ) { 198 | $message = __( 'Post could not be saved as a draft.', 'wp-safe-edit' ); 199 | 200 | if ( is_wp_error( $result ) ) { 201 | $message = $result->get_error_message(); 202 | } 203 | 204 | return apply_filters( 'safe_edit_fork_failure_message', $message, $result ); 205 | } 206 | 207 | /** 208 | * Get the feedback message for a user when a post was forked. 209 | * 210 | * @param int|\WP_Post $fork The fork created 211 | * @param int|\WP_Post $source_post The post the fork was created from 212 | * @return string 213 | */ 214 | public static function get_post_forking_success_message( $fork, $source_post ) { 215 | $message = __( 'A draft has been created and you can edit it below. Publish your changes to make them live.', 'wp-safe-edit' ); 216 | 217 | return apply_filters( 'safe_edit_fork_success_message', $message, $fork, $source_post ); 218 | } 219 | 220 | /** 221 | * Determine if the current request should be redirected after success or failure. 222 | * 223 | * @return boolean 224 | */ 225 | public static function should_redirect() { 226 | if ( defined( 'PHPUNIT_RUNNER' ) || defined( 'WP_CLI' ) ) { 227 | return false; 228 | } 229 | 230 | return true; 231 | } 232 | 233 | /** 234 | * Get the post ID from a request. 235 | * 236 | * @return int 237 | */ 238 | public function get_post_id_from_request() { 239 | return absint( filter_input( INPUT_POST, 'post_ID' ) ); 240 | } 241 | 242 | /** 243 | * Get the nonce a request. 244 | * 245 | * @return int 246 | */ 247 | public function get_nonce_from_request() { 248 | return sanitize_text_field( filter_input( INPUT_POST, static::NONCE_NAME ) ); 249 | } 250 | 251 | /** 252 | * Determine if the request to fork a post is valid. 253 | * 254 | * @return boolean 255 | */ 256 | public function is_request_valid() { 257 | try { 258 | $post_id = $this->get_post_id_from_request(); 259 | $nonce = $this->get_nonce_from_request(); 260 | 261 | if ( false === wp_verify_nonce( $nonce, static::NONCE_ACTION ) ) { 262 | throw new Exception( 263 | esc_html__( 'Post could not be forked because the request nonce was invalid.', 'wp-safe-edit' ) 264 | ); 265 | } 266 | 267 | if ( true !== \TenUp\WPSafeEdit\Posts\post_can_be_forked( $post_id ) ) { 268 | throw new Exception( 269 | esc_html__( 'Post could not be forked because the post specified in the request was not forkable.', 'wp-safe-edit' ) 270 | ); 271 | } 272 | 273 | return true; 274 | 275 | } catch ( Exception $e ) { 276 | \TenUp\WPSafeEdit\Logging\log_exception( $e ); 277 | 278 | return false; 279 | } 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /includes/API/MergePostController.php: -------------------------------------------------------------------------------- 1 | \d+)', array( 25 | 'methods' => 'GET', 26 | 'callback' => 'TenUp\WPSafeEdit\API\MergePostController::handle_merge_post_api_request', 27 | 'permission_callback' => '__return_true', 28 | 'args' => array( 29 | 'id' => array( 30 | 'required' => true, 31 | 'description' => esc_html__( 'Id of post that is being forked.', 'wp-safe-edit' ), 32 | 'type' => 'integer', 33 | ), 34 | 'nonce' => array( 35 | 'required' => true, 36 | 'description' => esc_html__( 'Action nonce.', 'wp-safe-edit' ), 37 | 'type' => 'string', 38 | ), 39 | ), 40 | 41 | ) ); 42 | } ); 43 | } 44 | 45 | // Handle REST API based forking requests. 46 | public static function handle_merge_post_api_request( $request ) { 47 | 48 | $post_id = absint( $request['id'] ); 49 | 50 | if ( true !== Helpers\is_valid_post_id( $post_id ) ) { 51 | wp_send_json_error( 52 | esc_html__( 'Post could not be merged because the request did not provide a valid post ID.', 'wp-safe-edit' ) 53 | ); 54 | } 55 | 56 | // Adds slashes as data passed by API strips slashes. 57 | add_filter( 'safe_edit_prepared_post_data_for_merge', 'wp_slash' ); 58 | 59 | try { 60 | $_POST = (array) get_post( $post_id ); 61 | $_POST['post_ID'] = $post_id; 62 | $merger = new PostMerger(); 63 | $result = $merger->merge( $post_id ); 64 | 65 | if ( true === Helpers\is_valid_post_id( $result ) ) { 66 | $message = self::get_post_merge_success_message( $result, $post_id ); 67 | $url = get_edit_post_link( $result, 'nodisplay' ); 68 | $url = add_query_arg( array( 69 | 'pf_success_message' => rawurlencode( $message ), 70 | ), $url ); 71 | 72 | $url = apply_filters( 'safe_edit_post_merge_success_redirect_url', $url, $result, $post_id ); 73 | 74 | // Stay in the classic editor when forking from the classic editor. 75 | if ( isset( $_REQUEST[ 'classic-editor' ] ) ) { 76 | $url = add_query_arg( array( 77 | 'classic-editor' => true, 78 | ), $url ); 79 | } 80 | $data = array( 81 | 'shouldRedirect' => self::should_redirect(), 82 | 'redirectUrl' => $url, 83 | 'message' => $message, 84 | ); 85 | wp_send_json_success( $data ); 86 | } else { 87 | $message = self::get_post_merge_failure_message_from_result( $result ); 88 | wp_send_json_error( 89 | $message 90 | ); 91 | } 92 | } catch ( Exception $e ) { 93 | \TenUp\WPSafeEdit\Logging\log_exception( $e ); 94 | 95 | $result = new WP_Error( 96 | 'post_merger', 97 | $e->getMessage() 98 | ); 99 | 100 | $message = self::get_post_merge_failure_message_from_result( $result ); 101 | wp_send_json_error( 102 | $message 103 | ); 104 | } 105 | } 106 | 107 | /** 108 | * Handle request to merge a post. 109 | */ 110 | public function handle_merge_post_request() { 111 | try { 112 | $post_id = $this->get_post_id_from_request(); 113 | 114 | if ( true !== Helpers\is_valid_post_id( $post_id ) ) { 115 | throw new Exception( 116 | esc_html__( 'Post could not be merged because the request did not provide a valid post ID.', 'wp-safe-edit' ) 117 | ); 118 | } 119 | 120 | if ( true !== $this->is_request_valid() ) { 121 | throw new Exception( 122 | esc_html__( 'Post could not be merged because the request was invalid.', 'wp-safe-edit' ) 123 | ); 124 | } 125 | 126 | $merger = new PostMerger(); 127 | $result = $merger->merge( $post_id ); 128 | 129 | if ( true === Helpers\is_valid_post_id( $result ) ) { 130 | $this->handle_merge_success( $result, $post_id ); 131 | } else { 132 | $this->handle_merge_failure( $post_id, $result ); 133 | } 134 | 135 | } catch ( Exception $e ) { 136 | \TenUp\WPSafeEdit\Logging\log_exception( $e ); 137 | 138 | $result = new WP_Error( 139 | 'post_merger', 140 | $e->getMessage() 141 | ); 142 | 143 | $this->handle_merge_failure( $post_id, $result ); 144 | } 145 | } 146 | 147 | /** 148 | * Handle a successful merge post request. 149 | * 150 | * @param int $source_post_id The post ID of the post that the fork was merged into. 151 | * @param int $fork_post_id The post ID of the fork that was merged into the source post. 152 | */ 153 | public function handle_merge_success( $source_post_id, $fork_post_id ) { 154 | do_action( 'safe_edit_post_merge_success', $fork_post_id, $source_post_id ); 155 | 156 | if ( true !== self::should_redirect() ) { 157 | return; 158 | } 159 | 160 | $message = self::get_post_merge_success_message( $source_post_id, $fork_post_id ); 161 | 162 | $url = get_edit_post_link( $source_post_id, 'nodisplay' ); 163 | $url = add_query_arg( array( 164 | 'pf_success_message' => rawurlencode( $message ), 165 | ), $url ); 166 | 167 | $url = apply_filters( 'safe_edit_post_merge_success_redirect_url', $url, $fork_post_id, $source_post_id ); 168 | 169 | // Stay in the classic editor when forking from the classic editor. 170 | if ( isset( $_REQUEST[ 'classic-editor' ] ) ) { 171 | $url = add_query_arg( array( 172 | 'classic-editor' => true, 173 | ), $url ); 174 | } 175 | 176 | wp_safe_redirect( esc_url_raw( $url ) ); 177 | exit; 178 | } 179 | 180 | /** 181 | * Handle an unsuccessful merge post request. 182 | * 183 | * @param int $fork_post_id The post ID of the post we attempted to merge into its source post. 184 | * @param \WP_Error|mixed $result The result from the merge request, usually a WP_Error. 185 | */ 186 | public function handle_merge_failure( $fork_post_id, $result ) { 187 | do_action( 'safe_edit_post_fork_failure', $fork_post_id, $result ); 188 | 189 | if ( true !== self::should_redirect() ) { 190 | return; 191 | } 192 | 193 | $message = self::get_post_merge_failure_message_from_result( $result ); 194 | 195 | $url = get_edit_post_link( $fork_post_id, 'nodisplay' ); 196 | $url = add_query_arg( array( 197 | 'pf_error_message' => rawurlencode( $message ), 198 | ), $url ); 199 | 200 | $url = apply_filters( 'safe_edit_post_merge_failure_redirect_url', $url, $fork_post_id, $result ); 201 | 202 | wp_safe_redirect( esc_url_raw( $url ) ); 203 | exit; 204 | } 205 | 206 | /** 207 | * Get the feedback message for a user when a post could not be merged. 208 | * 209 | * @param \WP_Error|mixed $result The result from the merge request, usually a WP_Error. 210 | * @return string 211 | */ 212 | public static function get_post_merge_failure_message_from_result( $result ) { 213 | $message = __( 'The draft changes could not be published.', 'wp-safe-edit' ); 214 | 215 | if ( is_wp_error( $result ) ) { 216 | $message = $result->get_error_message(); 217 | } 218 | 219 | return apply_filters( 'safe_edit_merge_failure_message', $message, $result ); 220 | } 221 | 222 | /** 223 | * Get the feedback message for a user when a fork was merged into its source post. 224 | * 225 | * @param int|\WP_Post $source_post The post the fork was merged into 226 | * @param int|\WP_Post $fork The fork that was merged into its source post 227 | * @return string 228 | */ 229 | public static function get_post_merge_success_message( $source_post, $fork ) { 230 | $message = __( 'The draft changes have been published.', 'wp-safe-edit' ); 231 | 232 | return apply_filters( 'safe_edit_merge_success_message', $message, $source_post, $fork ); 233 | } 234 | 235 | /** 236 | * Determine if the current request should be redirected after success or failure. 237 | * 238 | * @return boolean 239 | */ 240 | public static function should_redirect() { 241 | if ( defined( 'PHPUNIT_RUNNER' ) || defined( 'WP_CLI' ) ) { 242 | return false; 243 | } 244 | 245 | return true; 246 | } 247 | 248 | /** 249 | * Get the post ID from a request. 250 | * 251 | * @return int 252 | */ 253 | public function get_post_id_from_request() { 254 | return absint( filter_input( INPUT_POST, 'post_ID' ) ); 255 | } 256 | 257 | /** 258 | * Get the nonce a request. 259 | * 260 | * @return int 261 | */ 262 | public function get_nonce_from_request() { 263 | return sanitize_text_field( filter_input( INPUT_POST, static::NONCE_NAME ) ); 264 | } 265 | 266 | /** 267 | * Determine if the request to merge a post is valid. 268 | * 269 | * @return boolean 270 | */ 271 | public function is_request_valid() { 272 | try { 273 | $post_id = $this->get_post_id_from_request(); 274 | $nonce = $this->get_nonce_from_request(); 275 | 276 | if ( false === wp_verify_nonce( $nonce, static::NONCE_ACTION ) ) { 277 | throw new Exception( 278 | esc_html__( 'Post could not be merged because the request nonce was invalid.', 'wp-safe-edit' ) 279 | ); 280 | } 281 | 282 | if ( true !== \TenUp\WPSafeEdit\Posts\post_can_be_merged( $post_id ) ) { 283 | throw new Exception( 284 | esc_html__( 'Post could not be merged because the post specified in the request was not mergable.', 'wp-safe-edit' ) 285 | ); 286 | } 287 | 288 | return true; 289 | 290 | } catch ( Exception $e ) { 291 | \TenUp\WPSafeEdit\Logging\log_exception( $e ); 292 | 293 | return false; 294 | } 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /includes/Forking/AbstractForker.php: -------------------------------------------------------------------------------- 1 | can_fork( $post ) ) { 36 | throw new Exception( 37 | esc_html__( 'Post could not be forked.', 'wp-safe-edit' ) 38 | ); 39 | } 40 | 41 | // If a post doesn't have any archived forks, back up the original post data as the first archived fork. 42 | if ( false === Posts\post_has_archived_forks( $post ) ) { 43 | $archived_fork_post_id = $this->archive_post( $post ); 44 | } 45 | 46 | $forked_post_id = $this->fork_post( $post ); 47 | 48 | if ( true !== Helpers\is_valid_post_id( $forked_post_id ) ) { 49 | throw new Exception( 50 | esc_html__( 'Post could not be forked.', 'wp-safe-edit' ) 51 | ); 52 | } 53 | 54 | return $forked_post_id; 55 | 56 | } catch ( Exception $e ) { 57 | \TenUp\WPSafeEdit\Logging\log_exception( $e ); 58 | 59 | return new WP_Error( 60 | 'post_forker', 61 | $e->getMessage() 62 | ); 63 | } 64 | } 65 | 66 | /** 67 | * Fork post data. 68 | * 69 | * @param int|\WP_Post $post 70 | * @param array $post_data Array of post data to use when forking a post. 71 | * @return int|\WP_Error The forked post ID, if successful. 72 | */ 73 | public function fork_post( $post, $post_data = array() ) { 74 | try { 75 | $post = Helpers\get_post( $post ); 76 | 77 | if ( true !== Helpers\is_post_or_post_id( $post ) ) { 78 | throw new InvalidArgumentException( 79 | esc_html__( 'Post could not be forked because it is not a valid post object or post ID.', 'wp-safe-edit' ) 80 | ); 81 | } 82 | 83 | do_action( 'safe_edit_before_fork_post', $post ); 84 | 85 | if ( empty( $post_data ) || ! is_array( $post_data ) ) { 86 | $post_data = $post->to_array(); 87 | } 88 | // First, create a copy of the post using the source post. 89 | $post_data = $this->prepare_post_data_for_fork( $post, $post_data ); 90 | 91 | if ( ! is_array( $post_data ) || empty( $post_data ) ) { 92 | throw new Exception( 93 | esc_html__( 'Post could not be forked because the post data was invalid.', 'wp-safe-edit' ) 94 | ); 95 | } 96 | 97 | $forked_post_id = wp_insert_post( $post_data, true ); 98 | 99 | if ( is_wp_error( $forked_post_id ) ) { 100 | throw new Exception( 101 | esc_html__( 'Post could not be forked: ', 'wp-safe-edit' ) . $forked_post_id->get_error_message() 102 | ); 103 | } 104 | 105 | if ( true !== Helpers\is_valid_post_id( $forked_post_id ) ) { 106 | throw new Exception( 107 | esc_html__( 'Post could not be forked.', 'wp-safe-edit' ) 108 | ); 109 | } 110 | 111 | // Second, copy post meta and terms from the source post. 112 | $this->copy_post_meta( $post, $forked_post_id ); 113 | $this->copy_post_terms( $post, $forked_post_id ); 114 | 115 | $updated_forked_post_id = null; 116 | $post_data = array(); 117 | 118 | // Third, if $_POST is not empty, use that as the post data. This is needed to capture changes made to the post edit fields before the fork button was pressed. 119 | if ( ! empty( $_POST ) ) { 120 | $post_data = $_POST; 121 | } 122 | 123 | $updated_forked_post_id = $this->update_forked_post( $forked_post_id, $post_data ); 124 | 125 | if ( is_wp_error( $updated_forked_post_id ) || ! Helpers\is_valid_post_id( $updated_forked_post_id ) ) { 126 | throw new Exception( 127 | esc_html__( 'The fork could not be updated: ', 'wp-safe-edit' ) . $updated_forked_post_id->get_error_message() 128 | ); 129 | } 130 | 131 | \TenUp\WPSafeEdit\Posts\set_original_post_id_for_fork( $forked_post_id, $post->ID ); 132 | 133 | do_action( 'safe_edit_after_fork_post', $forked_post_id, $post, $post_data ); 134 | 135 | return $forked_post_id; 136 | 137 | } catch ( Exception $e ) { 138 | \TenUp\WPSafeEdit\Logging\log_exception( $e ); 139 | 140 | return new WP_Error( 141 | 'post_forker', 142 | $e->getMessage() 143 | ); 144 | } 145 | } 146 | 147 | /** 148 | * Update a fork using an array of post data. 149 | * 150 | * @param int|\WP_Post $fork The fork post ID or object. 151 | * @param array $post_data The post data to use when updating the fork. 152 | * @return int|\WP_Error The value 0 or WP_Error on failure. The fork ID on success. 153 | */ 154 | function update_forked_post( $fork, $post_data ) { 155 | $fork = Helpers\get_post( $fork ); 156 | if ( true !== Helpers\is_post( $fork ) ) { 157 | return; 158 | } 159 | 160 | $post_data = $this->prepare_post_data_for_fork_update( $fork, $post_data ); 161 | 162 | if ( ! is_array( $post_data ) || empty( $post_data ) ) { 163 | return; 164 | } 165 | 166 | // Make sure the post ID is set to the fork's ID since the post data passed in could be from the source post. 167 | $post_data['ID'] = $fork->ID; 168 | 169 | $fork_id = wp_update_post( $post_data ); 170 | 171 | return $fork_id; 172 | } 173 | 174 | /** 175 | * Archive a post as a fork. 176 | * 177 | * @param int|\WP_Post $post 178 | * @return int|\WP_Error The archived post ID, if successful. 179 | */ 180 | public function archive_post( $post ) { 181 | try { 182 | $post = Helpers\get_post( $post ); 183 | if ( true !== Helpers\is_post( $post ) ) { 184 | throw new InvalidArgumentException( 185 | esc_html__( 'Could not create an archived fork of a post because it\'s not valid or could not be found.', 'wp-safe-edit' ) 186 | ); 187 | } 188 | 189 | $post_data = $post->to_array(); 190 | $post_data['pf_post_status'] = ArchivedForkStatus::get_name(); // Set the post status that should override the default fork post status. 191 | 192 | $post_id = $this->fork_post( $post, $post_data ); 193 | 194 | if ( true !== Helpers\is_valid_post_id( $post_id ) ) { 195 | throw new Exception( 196 | esc_html__( 'Could not back up the original post data as an archived fork.', 'wp-safe-edit' ) 197 | ); 198 | } 199 | 200 | return $post_id; 201 | 202 | } catch ( Exception $e ) { 203 | \TenUp\WPSafeEdit\Logging\log_exception( $e ); 204 | 205 | return new WP_Error( 206 | 'post_forker', 207 | $e->getMessage() 208 | ); 209 | } 210 | } 211 | 212 | /** 213 | * Copy the post meta from the original post to the forked post. 214 | * 215 | * @param int|\WP_Post $post The original post ID or object 216 | * @param int|\WP_Post $forked_post The forked post ID or object 217 | * @return int|\WP_Error The number of post meta rows copied if successful. 218 | */ 219 | public function copy_post_meta( $post, $forked_post ) { 220 | try { 221 | $post = Helpers\get_post( $post ); 222 | $forked_post = Helpers\get_post( $forked_post ); 223 | 224 | if ( 225 | true !== Helpers\is_post( $post ) || 226 | true !== Helpers\is_post( $forked_post ) 227 | ) { 228 | throw new InvalidArgumentException( 229 | esc_html__( 'Could not fork post meta because the posts given were not valid.', 'wp-safe-edit' ) 230 | ); 231 | } 232 | 233 | $result = Helpers\clear_post_meta( $forked_post ); // Clear any existing meta data first to prevent duplicate rows for the same meta keys. 234 | 235 | do_action( 'safe_edit_before_fork_post_meta', $forked_post, $post ); 236 | 237 | $result = Helpers\copy_post_meta( $post, $forked_post ); 238 | 239 | do_action( 'safe_edit_after_fork_post_meta', $forked_post, $post, $result ); 240 | 241 | return $result; 242 | 243 | } catch ( Exception $e ) { 244 | \TenUp\WPSafeEdit\Logging\log_exception( $e ); 245 | 246 | return new WP_Error( 247 | 'post_forker', 248 | $e->getMessage() 249 | ); 250 | } 251 | } 252 | 253 | /** 254 | * Copy the taxonomy terms from the original post to the forked post. 255 | * 256 | * @param int|\WP_Post $post The original post ID or object 257 | * @param int|\WP_Post $forked_post The forked post ID or object 258 | * @return int|\WP_Error The number of taxonomy terms copied to the destination post if successful. 259 | */ 260 | public function copy_post_terms( $post, $forked_post ) { 261 | try { 262 | $post = Helpers\get_post( $post ); 263 | $forked_post = Helpers\get_post( $forked_post ); 264 | 265 | if ( 266 | true !== Helpers\is_post( $post ) || 267 | true !== Helpers\is_post( $forked_post ) 268 | ) { 269 | throw new InvalidArgumentException( 270 | esc_html__( 'Could not fork post terms because the posts given were not valid.', 'wp-safe-edit' ) 271 | ); 272 | } 273 | 274 | do_action( 'safe_edit_before_fork_post_terms', $forked_post, $post ); 275 | 276 | $result = Helpers\copy_post_terms( $post, $forked_post ); 277 | 278 | do_action( 'safe_edit_after_fork_post_terms', $forked_post, $post ); 279 | 280 | return $result; 281 | 282 | } catch ( Exception $e ) { 283 | \TenUp\WPSafeEdit\Logging\log_exception( $e ); 284 | 285 | return new WP_Error( 286 | 'post_forker', 287 | $e->getMessage() 288 | ); 289 | } 290 | } 291 | 292 | /** 293 | * Prepare the post data to be forked. 294 | * 295 | * @param int|\WP_Post $post The post ID or object we're forking. 296 | * @param array $post_data Array of post data to use for the fork. 297 | * @return array The post data for the forked post. 298 | */ 299 | public function prepare_post_data_for_fork( $post, $post_data ) { 300 | try { 301 | $post = Helpers\get_post( $post ); 302 | 303 | if ( true !== Helpers\is_post( $post ) ) { 304 | throw new InvalidArgumentException( 305 | esc_html__( 'Could not prepare the forked post data because the original post is not a valid post object or post ID.', 'wp-safe-edit' ) 306 | ); 307 | } 308 | 309 | if ( ! empty( $post_data['pf_post_status'] ) ) { 310 | $post_status = $post_data['pf_post_status']; 311 | } else { 312 | $post_status = $this->get_draft_fork_post_status(); 313 | } 314 | 315 | if ( empty( $post_status ) ) { 316 | throw new Exception( 317 | esc_html__( 'Could not prepare the forked post data because the correct post status could not be determined.', 'wp-safe-edit' ) 318 | ); 319 | } 320 | 321 | // Make sure the post data contains the correct keys for the DB post columns. This is needed in case $_POST data is used where the form fields don't all match the DB columns. 322 | $post_data = \TenUp\WPSafeEdit\Helpers\_wp_translate_postdata( false, $post_data ); 323 | 324 | $excluded_columns = $this->get_columns_to_exclude(); 325 | foreach ( (array) $excluded_columns as $column ) { 326 | if ( array_key_exists( $column, $post_data ) ) { 327 | unset( $post_data[ $column ] ); 328 | } 329 | } 330 | 331 | // Double check to make sure we don't include a post ID 332 | unset( $post_data['post_ID'] ); 333 | unset( $post_data['ID'] ); 334 | 335 | $post_data['post_status'] = $post_status; 336 | 337 | return apply_filters( 'safe_edit_prepared_post_data_for_fork', $post_data ); 338 | 339 | } catch ( Exception $e ) { 340 | \TenUp\WPSafeEdit\Logging\log_exception( $e ); 341 | 342 | return array(); 343 | } 344 | } 345 | 346 | /** 347 | * Prepare the post data to be updated for a fork. 348 | * 349 | * @param int|\WP_Post $fork The post ID or object for the fork to be updated. 350 | * @param array $post_data Array of post data to use for the fork. 351 | * @return array|boolean The post data for the forked post if successful. 352 | */ 353 | public function prepare_post_data_for_fork_update( $fork, $post_data ) { 354 | $fork = Helpers\get_post( $fork ); 355 | 356 | if ( true !== Helpers\is_post( $fork ) ) { 357 | return false; 358 | } 359 | 360 | // Make sure the post data contains the correct keys for the DB post columns. This is needed in case $_POST data is used where the form fields don't all match the DB columns. 361 | $post_data = \TenUp\WPSafeEdit\Helpers\_wp_translate_postdata( true, $post_data ); 362 | 363 | $excluded_columns = $this->get_columns_to_exclude(); 364 | foreach ( (array) $excluded_columns as $column ) { 365 | if ( array_key_exists( $column, $post_data ) ) { 366 | unset( $post_data[ $column ] ); 367 | } 368 | } 369 | 370 | // Make sure the post ID is correct. 371 | $post_data['ID'] = $fork->ID; 372 | 373 | $post_data['post_parent'] = $fork->post_parent; 374 | $post_data['post_status'] = $fork->post_status; 375 | 376 | return $post_data; 377 | } 378 | 379 | public function get_draft_fork_post_status() { 380 | return DraftForkStatus::get_name(); 381 | } 382 | 383 | /** 384 | * Get the columns that should be ignored when forking a post. 385 | * 386 | * @return array 387 | */ 388 | public function get_columns_to_exclude() { 389 | return array( 390 | 'ID', 391 | 'post_ID', // ID may be specified with this field alternatively. 392 | 'post_status', 393 | 'post_name', 394 | 'guid', 395 | ); 396 | } 397 | 398 | /** 399 | * Determine if a post can be forked. 400 | * 401 | * @param int|\WP_Post $post 402 | * @return boolean 403 | */ 404 | public function can_fork( $post ) { 405 | return true === \TenUp\WPSafeEdit\Posts\post_can_be_forked( $post ); 406 | } 407 | 408 | /** 409 | * Determine if a post has an open fork. 410 | * 411 | * @param int|\WP_Post $post 412 | * @return boolean 413 | */ 414 | public function has_fork( $post ) { 415 | return true === \TenUp\WPSafeEdit\Posts\post_has_open_fork( $post ); 416 | } 417 | } 418 | -------------------------------------------------------------------------------- /includes/Forking/PostMerger.php: -------------------------------------------------------------------------------- 1 | can_merge( $fork ) ) { 27 | throw new Exception( 28 | esc_html__( 'Post could not be merged.', 'wp-safe-edit' ) 29 | ); 30 | } 31 | 32 | $result = $this->merge_post( $fork ); 33 | 34 | if ( true !== Helpers\is_valid_post_id( $result ) ) { 35 | throw new Exception( 36 | esc_html__( 'Post could not be merged.', 'wp-safe-edit' ) 37 | ); 38 | } 39 | 40 | return $result; 41 | 42 | } catch ( Exception $e ) { 43 | \TenUp\WPSafeEdit\Logging\log_exception( $e ); 44 | 45 | return new WP_Error( 46 | 'post_merger', 47 | $e->getMessage() 48 | ); 49 | } 50 | } 51 | 52 | /** 53 | * Merge post data. 54 | * 55 | * @param int|\WP_Post $fork 56 | * @return int|\WP_Error The forked post ID, if successful. 57 | */ 58 | public function merge_post( $fork ) { 59 | try { 60 | $fork = Helpers\get_post( $fork ); 61 | 62 | if ( true !== Helpers\is_post( $fork ) ) { 63 | throw new InvalidArgumentException( 64 | esc_html__( 'Post could not be merged because it is not a valid post object or post ID.', 'wp-safe-edit' ) 65 | ); 66 | } 67 | 68 | // First, save the fork in case changes were made to the fields but not saved. 69 | if ( isset( $_POST['ID'] ) ) { 70 | $fork_post_data = $this->prepare_post_data( $_POST, true ); 71 | } else { 72 | $fork_post_data = $fork; 73 | } 74 | $updated_fork_post_id = wp_update_post( $fork_post_data, true ); 75 | 76 | if ( is_wp_error( $updated_fork_post_id ) ) { 77 | throw new Exception( 78 | esc_html__( 'Fork could not be updated with $_POST data during merge: ', 'wp-safe-edit' ) . $updated_fork_post_id->get_error_message() 79 | ); 80 | } 81 | 82 | // Get a fresh copy of the fork since it may have been updated. 83 | $fork = Helpers\get_post( $fork->ID ); 84 | 85 | $source_post = Posts\get_source_post_for_fork( $fork ); 86 | 87 | if ( true !== Helpers\is_post( $source_post ) ) { 88 | throw new Exception( 89 | esc_html__( 'Post could not be merged because the source post could not be found.', 'wp-safe-edit' ) 90 | ); 91 | } 92 | 93 | do_action( 'safe_edit_before_merge_post', $fork, $source_post ); 94 | 95 | // Second, update the source post 96 | $post_data = $this->prepare_post_data_for_merge( $fork, $source_post, $_POST ); 97 | 98 | if ( ! is_array( $post_data ) || empty( $post_data ) ) { 99 | throw new Exception( 100 | esc_html__( 'Fork could not be merged because the post data was invalid.', 'wp-safe-edit' ) 101 | ); 102 | } 103 | 104 | $merge_post_id = wp_update_post( $post_data, true ); 105 | 106 | if ( is_wp_error( $merge_post_id ) ) { 107 | throw new Exception( 108 | esc_html__( 'Fork could not be merged: ', 'wp-safe-edit' ) . $merge_post_id->get_error_message() 109 | ); 110 | } 111 | 112 | if ( true !== Helpers\is_valid_post_id( $merge_post_id ) ) { 113 | throw new Exception( 114 | esc_html__( 'Fork could not be merged.', 'wp-safe-edit' ) 115 | ); 116 | } 117 | 118 | // Third, copy post meta and terms from the source post. 119 | $this->copy_post_meta( $fork, $merge_post_id ); 120 | $this->copy_post_terms( $fork, $merge_post_id ); 121 | 122 | $this->archive_forked_post( $fork->ID ); 123 | 124 | clean_post_cache( $source_post->ID ); 125 | 126 | do_action( 'safe_edit_after_merge_post', $fork, $source_post ); 127 | 128 | return $merge_post_id; 129 | 130 | } catch ( Exception $e ) { 131 | \TenUp\WPSafeEdit\Logging\log_exception( $e ); 132 | 133 | return new WP_Error( 134 | 'post_merger', 135 | $e->getMessage() 136 | ); 137 | } 138 | } 139 | 140 | /** 141 | * Prepare an array of post data so it can be saved to the database. 142 | * 143 | * @param array $post_data Array of post data to prepare. 144 | * @param bool $update Are we updating a pre-existing post. 145 | * @return array The prepared post data. 146 | */ 147 | public function prepare_post_data( $post_data, $update ) { 148 | try { 149 | // Make sure the post data contains the correct keys for the DB post columns. This is needed in case $_POST data is used where the form fields don't all match the DB columns. 150 | $post_data = \TenUp\WPSafeEdit\Helpers\_wp_translate_postdata( $update, $post_data ); 151 | 152 | if ( empty( $post_data ) || ! is_array( $post_data ) ) { 153 | throw new InvalidArgumentException( 154 | esc_html__( 'Could not prepare the post data to merging because it was invalid.', 'wp-safe-edit' ) 155 | ); 156 | } 157 | 158 | // Converts to an object for escaping. 159 | return apply_filters( 'safe_edit_prepared_post_data_for_merge', $post_data ); 160 | 161 | } catch ( Exception $e ) { 162 | \TenUp\WPSafeEdit\Logging\log_exception( $e ); 163 | 164 | return array(); 165 | } 166 | } 167 | 168 | /** 169 | * Prepare the fork's post data to be merged into its source post. 170 | * 171 | * @param int|\WP_Post $fork The post ID or object we're merging. 172 | * @param int|\WP_Post $source_post The post ID or object of the fork's source post. 173 | * @param array $post_data Array of post data to use for the merge. 174 | * @return array The post data for the merged post. 175 | */ 176 | public function prepare_post_data_for_merge( $fork, $source_post, $post_data ) { 177 | try { 178 | $fork = Helpers\get_post( $fork ); 179 | 180 | if ( true !== Helpers\is_post( $fork ) ) { 181 | throw new InvalidArgumentException( 182 | esc_html__( 'Could not prepare the forked post data to merge because the fork is not a valid post object or post ID.', 'wp-safe-edit' ) 183 | ); 184 | } 185 | 186 | $source_post = Helpers\get_post( $source_post ); 187 | 188 | if ( true !== Helpers\is_post( $source_post ) ) { 189 | throw new InvalidArgumentException( 190 | esc_html__( 'Could not prepare the forked post data to merge because the source post is not a valid post object or post ID.', 'wp-safe-edit' ) 191 | ); 192 | } 193 | 194 | $post_data = $this->prepare_post_data( $post_data, true ); 195 | 196 | $excluded_columns = $this->get_columns_to_exclude(); 197 | foreach ( (array) $excluded_columns as $column ) { 198 | if ( array_key_exists( $column, $post_data ) ) { 199 | unset( $post_data[ $column ] ); 200 | } 201 | } 202 | 203 | $post_data['ID'] = $source_post->ID; 204 | $post_data['post_status'] = Helpers\get_property( 'post_status', $source_post ); 205 | 206 | return $post_data; 207 | 208 | } catch ( Exception $e ) { 209 | \TenUp\WPSafeEdit\Logging\log_exception( $e ); 210 | 211 | return array(); 212 | } 213 | } 214 | 215 | /** 216 | * Copy the post meta from the forked post to the source post. 217 | * 218 | * @param int|\WP_Post $forked_post The forked post ID or object 219 | * @param int|\WP_Post $source_post The original post ID or object 220 | * @return int|\WP_Error The number of post meta rows copied if successful. 221 | */ 222 | public function copy_post_meta( $forked_post, $source_post ) { 223 | try { 224 | $forked_post = Helpers\get_post( $forked_post ); 225 | $source_post = Helpers\get_post( $source_post ); 226 | 227 | if ( 228 | true !== Helpers\is_post( $source_post ) || 229 | true !== Helpers\is_post( $forked_post ) 230 | ) { 231 | throw new InvalidArgumentException( 232 | esc_html__( 'Could not merge post meta because the posts given were not valid.', 'wp-safe-edit' ) 233 | ); 234 | } 235 | 236 | $result = Helpers\clear_post_meta( $source_post ); // Clear any existing meta data first to prevent duplicate rows for the same meta keys. 237 | 238 | do_action( 'safe_edit_before_merge_post_meta', $source_post, $forked_post ); 239 | 240 | $excluded_keys = $this->get_meta_keys_to_exclude(); 241 | $result = Helpers\copy_post_meta( $forked_post, $source_post, $excluded_keys ); 242 | 243 | do_action( 'safe_edit_after_merge_post_meta', $source_post, $forked_post, $result ); 244 | 245 | return $result; 246 | } catch ( Exception $e ) { 247 | \TenUp\WPSafeEdit\Logging\log_exception( $e ); 248 | 249 | return new WP_Error( 250 | 'post_merger', 251 | $e->getMessage() 252 | ); 253 | } 254 | } 255 | 256 | /** 257 | * Copy the taxonomy terms from the forked post to the source post. 258 | * 259 | * @param int|\WP_Post $forked_post The forked post ID or object 260 | * @param int|\WP_Post $source_post The original post ID or object 261 | * 262 | * @return int|\WP_Error The number of taxonomy terms copied to the destination post if successful. 263 | */ 264 | public function copy_post_terms( $forked_post, $source_post ) { 265 | try { 266 | $source_post = Helpers\get_post( $source_post ); 267 | $forked_post = Helpers\get_post( $forked_post ); 268 | 269 | if ( 270 | true !== Helpers\is_post( $source_post ) || 271 | true !== Helpers\is_post( $forked_post ) 272 | ) { 273 | throw new InvalidArgumentException( 274 | esc_html__( 'Could not merge post terms because the posts given were not valid.', 'wp-safe-edit' ) 275 | ); 276 | } 277 | 278 | do_action( 'safe_edit_before_merge_post_terms', $source_post, $forked_post ); 279 | 280 | $result = Helpers\copy_post_terms( $forked_post, $source_post ); 281 | 282 | do_action( 'safe_edit_after_merge_post_terms', $source_post, $forked_post ); 283 | 284 | return $result; 285 | } catch ( Exception $e ) { 286 | \TenUp\WPSafeEdit\Logging\log_exception( $e ); 287 | 288 | return new WP_Error( 289 | 'post_merger', 290 | $e->getMessage() 291 | ); 292 | } 293 | } 294 | 295 | /** 296 | * Archive a forked post after it's been merged. 297 | * 298 | * @param int $post_id The post ID for the fork to archive. 299 | * @return boolean|\WP_Error 300 | */ 301 | public function archive_forked_post( $post_id ) { 302 | try { 303 | if ( true !== Helpers\is_valid_post_id( $post_id ) ) { 304 | throw new Exception( 305 | esc_html__( 'Forked post could not be archived because the supplied post ID was not valid.', 'wp-safe-edit' ) 306 | ); 307 | } 308 | 309 | $post_data = array(); 310 | $post_data['ID'] = absint( $post_id ); 311 | $post_data['post_status'] = $this->get_archived_fork_post_status(); 312 | 313 | $result = wp_update_post( $post_data, true ); 314 | 315 | if ( true !== Helpers\is_valid_post_id( $result ) ) { 316 | throw new Exception( 317 | esc_html__( 'Forked post could not be archived.', 'wp-safe-edit' ) 318 | ); 319 | } 320 | 321 | return true; 322 | 323 | } catch ( Exception $e ) { 324 | \TenUp\WPSafeEdit\Logging\log_exception( $e ); 325 | 326 | return new WP_Error( 327 | 'post_merger', 328 | $e->getMessage() 329 | ); 330 | } 331 | } 332 | 333 | public function get_archived_fork_post_status() { 334 | return ArchivedForkStatus::get_name(); 335 | } 336 | 337 | /** 338 | * Get the columns that should be ignored when merging a post. 339 | * 340 | * @return array 341 | */ 342 | public function get_columns_to_exclude() { 343 | return array( 344 | 'ID', 345 | 'post_ID', // ID may be specified with this field alternatively. 346 | 'post_status', 347 | 'post_name', 348 | 'guid', 349 | ); 350 | } 351 | 352 | /** 353 | * Get the meta keys to exclude when copying meta data from the fork to the source post. 354 | * 355 | * @return array 356 | */ 357 | public function get_meta_keys_to_exclude() { 358 | return array( 359 | Posts::ORIGINAL_POST_ID_META_KEY 360 | ); 361 | } 362 | 363 | /** 364 | * Determine if a fork can be merged back into it's source post. 365 | * 366 | * @param int|\WP_Post $fork 367 | * @return boolean 368 | */ 369 | public function can_merge( $fork ) { 370 | return true === \TenUp\WPSafeEdit\Posts\post_can_be_merged( $fork ); 371 | } 372 | } 373 | -------------------------------------------------------------------------------- /includes/Plugin.php: -------------------------------------------------------------------------------- 1 | posts = new Posts(); 40 | $this->api = new API(); 41 | } 42 | 43 | /** 44 | * Register hooks and actions. 45 | */ 46 | public function register() { 47 | $this->posts->register(); 48 | $this->api->register(); 49 | 50 | add_action( 51 | 'init', 52 | array( $this, 'i18n' ) 53 | ); 54 | 55 | add_action( 56 | 'init', 57 | array( $this, 'init' ) 58 | ); 59 | 60 | add_action( 61 | 'admin_enqueue_scripts', 62 | array( $this, 'enqueue_admin_scripts' ) 63 | ); 64 | 65 | add_action( 66 | 'enqueue_block_editor_assets', 67 | array( $this, 'enqueue_gutenberg_edit_scripts' ) 68 | ); 69 | 70 | add_action( 71 | 'admin_enqueue_scripts', 72 | array( $this, 'enqueue_admin_styles' ) 73 | ); 74 | 75 | add_filter( 76 | 'admin_body_class', 77 | array( $this, 'admin_body_class' ) 78 | ); 79 | 80 | do_action( 'safe_edit_loaded' ); 81 | } 82 | 83 | /** 84 | * Get the current instance of the plugin, or instantiate it if needed. 85 | * 86 | * @return \TenUp\WPSafeEdit\Plugin 87 | */ 88 | public static function get_instance() { 89 | if ( true !== self::$instance instanceof TenUp\WPSafeEdit\Plugin ) { 90 | self::$instance = new self(); 91 | self::$instance->register(); 92 | } 93 | 94 | return self::$instance; 95 | } 96 | 97 | /** 98 | * Perform plugin activation tasks. 99 | */ 100 | public static function activate() { 101 | flush_rewrite_rules(); 102 | } 103 | 104 | /** 105 | * Perform plugin deactivation tasks. 106 | */ 107 | public static function deactivate() { 108 | 109 | } 110 | 111 | /** 112 | * Registers the default textdomain. 113 | * 114 | * @uses apply_filters() 115 | * @uses get_locale() 116 | * @uses load_textdomain() 117 | * @uses load_plugin_textdomain() 118 | * @uses plugin_basename() 119 | * 120 | * @return void 121 | */ 122 | function i18n() { 123 | $locale = apply_filters( 'plugin_locale', get_locale(), 'wp-safe-edit' ); 124 | load_textdomain( 'wp-safe-edit', WP_LANG_DIR . '/wp-safe-edit/wp-safe-edit-' . $locale . '.mo' ); 125 | load_plugin_textdomain( 'wp-safe-edit', false, plugin_basename( WP_SAFE_EDIT_PATH ) . '/languages/' ); 126 | } 127 | 128 | /** 129 | * Initializes the plugin and fires an action other plugins can hook into. 130 | * 131 | * @uses do_action() 132 | * 133 | * @return void 134 | */ 135 | function init() { 136 | do_action( 'safe_edit_init' ); 137 | } 138 | 139 | /** 140 | * Enqueue any needed admin scripts. 141 | * 142 | * @return void 143 | */ 144 | function enqueue_admin_scripts() { 145 | $min = '.min'; 146 | $version = WP_SAFE_EDIT_VERSION; 147 | 148 | if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) { 149 | $min = ''; 150 | $version = time(); 151 | } 152 | 153 | wp_enqueue_script( 154 | 'wp_safe_edit_admin', 155 | trailingslashit( WP_SAFE_EDIT_URL ) . "assets/js/wp-post-forking{$min}.js", 156 | array( 'jquery' ), 157 | $version, 158 | true 159 | ); 160 | } 161 | 162 | /** 163 | * Enable Gutenberg support. 164 | * 165 | * @return void 166 | */ 167 | function enqueue_gutenberg_edit_scripts() { 168 | wp_enqueue_script( 169 | 'wp_safe_edit_gutenberg_admin', 170 | trailingslashit( WP_SAFE_EDIT_URL ) . "dist/main.js", 171 | array( 'wp-blocks' ), 172 | WP_SAFE_EDIT_VERSION, 173 | true 174 | ); 175 | wp_localize_script( 176 | 'wp_safe_edit_gutenberg_admin', 177 | 'wpSafeEditGutenbergData', 178 | array( 179 | 'id' => get_the_ID(), 180 | 'forknonce' => wp_create_nonce( 'post-fork' ), 181 | 'message' => isset( $_GET['pf_success_message'] ) ? 182 | sanitize_text_field( $_GET['pf_success_message'] ) : 183 | false, 184 | 'locale' => self::get_jed_locale_data( 'wp-safe-edit' ), 185 | ) 186 | ); 187 | } 188 | 189 | /** 190 | * Returns Jed-formatted localization data. From Gutenberg. 191 | * 192 | * @param string $domain Translation domain. 193 | * 194 | * @return array 195 | */ 196 | public static function get_jed_locale_data( $domain ) { 197 | $translations = get_translations_for_domain( $domain ); 198 | 199 | $locale = array( 200 | '' => array( 201 | 'domain' => $domain, 202 | 'lang' => is_admin() ? get_user_locale() : get_locale(), 203 | ), 204 | ); 205 | 206 | if ( ! empty( $translations->headers['Plural-Forms'] ) ) { 207 | $locale['']['plural_forms'] = $translations->headers['Plural-Forms']; 208 | } 209 | 210 | foreach ( $translations->entries as $msgid => $entry ) { 211 | $locale[ $msgid ] = $entry->translations; 212 | } 213 | 214 | return $locale; 215 | } 216 | 217 | /** 218 | * Enqueue any needed admin styles. 219 | * 220 | * @return void 221 | */ 222 | function enqueue_admin_styles() { 223 | $min = '.min'; 224 | $version = WP_SAFE_EDIT_VERSION; 225 | 226 | if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) { 227 | $min = ''; 228 | $version = time(); 229 | } 230 | 231 | wp_enqueue_style( 232 | 'wp_safe_edit_admin', 233 | trailingslashit( WP_SAFE_EDIT_URL ) . "assets/css/wp-post-forking{$min}.css", 234 | array(), 235 | $version 236 | ); 237 | } 238 | 239 | /** 240 | * Add custom body class to drafts. 241 | * 242 | * @param string $classes Current classes. 243 | * @return string 244 | */ 245 | function admin_body_class( $classes ) { 246 | global $post; 247 | 248 | if ( ! $post ) { 249 | return $classes; 250 | } 251 | 252 | if ( 'wpse-draft' === $post->post_status ) { 253 | $classes .= ' wpse-draft '; 254 | } 255 | 256 | return $classes; 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /includes/Posts.php: -------------------------------------------------------------------------------- 1 | publishing_buttons = new PublishingButtons(); 60 | $this->statuses = new Statuses(); 61 | $this->notices = new Notices(); 62 | $this->archived_forks = new ArchivedForks(); 63 | $this->trash = new Trash(); 64 | } 65 | 66 | /** 67 | * Register hooks and actions. 68 | */ 69 | public function register() { 70 | $this->publishing_buttons->register(); 71 | $this->statuses->register(); 72 | $this->notices->register(); 73 | $this->archived_forks->register(); 74 | $this->trash->register(); 75 | 76 | add_filter( 77 | 'wp_insert_post_data', 78 | [ $this, 'filter_insert_post_data' ], 79 | 999, 2 80 | ); 81 | 82 | add_action( 83 | 'init', 84 | [ $this, 'add_post_type_support' ] 85 | ); 86 | 87 | add_action( 88 | 'safe_edit_add_post_type_support', 89 | [ $this, 'add_custom_post_type_support' ] 90 | ); 91 | 92 | add_filter( 93 | 'post_row_actions', 94 | [ $this, 'modify_list_row_actions' ], 95 | 10, 2 96 | ); 97 | 98 | add_filter( 99 | 'page_row_actions', 100 | [ $this, 'modify_list_row_actions' ], 101 | 10, 2 102 | ); 103 | } 104 | 105 | /** 106 | * Filter post data before it is saved to the database. 107 | * 108 | * @param array $data An array of slashed post data. 109 | * @param array $postarr An array of sanitized, but otherwise unmodified post data. 110 | * @return array 111 | */ 112 | public function filter_insert_post_data( $data, $postarr ) { 113 | $post = null; 114 | 115 | if ( ! empty( $postarr['ID'] ) ) { 116 | $post = Helpers\get_post( $postarr['ID'] ); 117 | } 118 | 119 | if ( true !== Helpers\is_post( $post ) ) { 120 | return $data; 121 | } 122 | 123 | $valid_statuses = (array) Statuses::get_valid_fork_post_statuses(); 124 | 125 | // Bail out if this post isn't a fork. 126 | if ( empty( $valid_statuses ) || ! in_array( $post->post_status, $valid_statuses ) ) { 127 | return $data; 128 | } 129 | 130 | $data = apply_filters( 'safe_edit_filter_insert_post_data', $data, $postarr ); 131 | 132 | return $data; 133 | } 134 | 135 | /** 136 | * Add forking support for one or more post types. 137 | * 138 | * @param string|array $post_types The post types to add support to. 139 | * @return void 140 | */ 141 | public function add_post_type_support() { 142 | /** 143 | * Filter: WP Safe Edit Supported Post Types. 144 | * 145 | * Use this filter to add/remove post types from an array of supported post types. 146 | * 147 | * @param array $post_types An array of post type names that support safe editing. 148 | */ 149 | $post_types = apply_filters( 'safe_edit_supported_post_types', [ 'post', 'page' ] ); 150 | 151 | /** 152 | * Action: WP Safe Edit Add Post Type Support. 153 | * 154 | * Fires when support for WP Safe Edit is added to the supported post types. 155 | * 156 | * @param array $post_types An array of post type names that support safe editing. 157 | */ 158 | do_action( 'safe_edit_add_post_type_support', $post_types ); 159 | } 160 | 161 | /** 162 | * Add forking support for one or more custom post types. 163 | * 164 | * @param string|array $post_types The post types to add support to. 165 | * @return void 166 | */ 167 | public function add_custom_post_type_support( $post_types ) { 168 | if ( is_array( $post_types ) ) { 169 | foreach ( $post_types as $post_type ) { 170 | add_post_type_support( $post_type, PostTypeSupport::FORKING_FEATURE_NAME ); 171 | } 172 | } elseif( is_string( $post_types ) ) { 173 | add_post_type_support( $post_types, PostTypeSupport::FORKING_FEATURE_NAME ); 174 | } 175 | } 176 | 177 | /** 178 | * Modify the action links for post lists. 179 | * 180 | * @param array $actions The current action links. 181 | * @param \WP_Post $post The post the links are for. 182 | * @return array The modified action links. 183 | */ 184 | public function modify_list_row_actions( $actions, $post ) { 185 | if ( 186 | true !== Posts\post_type_supports_forking( $post ) || 187 | true !== Posts\current_user_can_edit_fork( $post ) || 188 | true !== Posts\post_has_open_fork( $post ) 189 | ) { 190 | return $actions; 191 | } 192 | 193 | $fork = Posts\get_open_fork_for_post( $post ); 194 | 195 | if ( true !== Helpers\is_post( $fork ) ) { 196 | return $actions; 197 | } 198 | 199 | $edit_draft_revision_action = array( 'draft_revision' => sprintf( 200 | '%2$s', 201 | get_edit_post_link( $fork->ID ), 202 | esc_html__( 'Edit Draft Revision', 'wp-safe-edit' ) 203 | ) ); 204 | 205 | // Insert the Edit Draft Revision link after the Edit link. 206 | $pos = array_search( 'edit', array_keys( $actions ), true ) + 1; 207 | $actions = array_merge( 208 | array_slice( $actions, 0, $pos ), 209 | $edit_draft_revision_action, 210 | array_slice( $actions, $pos ) 211 | ); 212 | 213 | // Remove the edit link since further edits need to be done on the open draft revision. 214 | unset( $actions['edit'] ); 215 | 216 | // Remove the quick edit link since further edits need to be done on the open draft revision. 217 | unset( $actions['inline hide-if-no-js'] ); 218 | 219 | return $actions; 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /includes/Posts/ArchivedForks.php: -------------------------------------------------------------------------------- 1 | should_show_archived_forks_meta_box() ) { 34 | $this->register_archived_forks_meta_box(); 35 | } 36 | } 37 | 38 | /** 39 | * Determine if the archived forks meta box should be shown. 40 | * 41 | * @return bool 42 | */ 43 | function should_show_archived_forks_meta_box() { 44 | global $post; 45 | 46 | $value = false; 47 | 48 | if ( 49 | true === Helpers\is_post( $post ) && 50 | post_type_supports( $post->post_type, PostTypeSupport::FORKING_FEATURE_NAME ) && 51 | false === Posts\is_fork( $post ) 52 | ) { 53 | $value = true; 54 | } 55 | 56 | return apply_filters( 'safe_edit_should_show_archived_forks_meta_box', $value, $post ); 57 | } 58 | 59 | /** 60 | * Add the archived draft meta box. 61 | * 62 | * @return void 63 | */ 64 | public function register_archived_forks_meta_box() { 65 | add_meta_box( 66 | 'post-forking-archived-forks', 67 | esc_html__( 'Archived Draft Revisions', 'wp-safe-edit' ), 68 | [ $this, 'render_archived_forks_meta_box' ], 69 | (array) Posts\get_forkable_post_types() 70 | ); 71 | } 72 | 73 | /** 74 | * Render the archived draft meta box. 75 | * 76 | * @param \WP_Post $post Post object. 77 | * @return void 78 | */ 79 | public function render_archived_forks_meta_box( $post ) { 80 | if ( true !== Helpers\is_post( $post ) ) { 81 | return; 82 | } 83 | 84 | $query = Posts\get_archived_forks_query( $post ); 85 | 86 | if ( $query->have_posts() ) { 87 | while ( $query->have_posts() ) { 88 | $query->the_post(); ?> 89 |

90 | %s', 93 | esc_url( get_edit_post_link( absint( get_the_ID() ) ) ), 94 | get_the_title() 95 | ); ?> 96 |
97 | 98 |

99 | 105 |

106 | render_success_notices(); 29 | $this->render_error_notices(); 30 | } 31 | 32 | /** 33 | * Render the success notices. 34 | * 35 | * @return void 36 | */ 37 | public function render_success_notices() { 38 | $notice = sanitize_text_field( 39 | rawurldecode( 40 | filter_input( INPUT_GET, 'pf_success_message' ) 41 | ) 42 | ); 43 | 44 | if ( empty( $notice ) ) { 45 | return; 46 | } ?> 47 | 48 |
49 |

50 |
51 | 52 | 70 | 71 |
72 |

73 |
74 | 75 | render_open_fork_message(); 39 | $this->render_archived_fork_message(); 40 | $this->render_view_source_post_message(); 41 | 42 | $this->render_fork_post_button(); 43 | $this->render_merge_post_button(); 44 | 45 | $this->alter_publishing_buttons(); 46 | $this->alter_publishing_fields(); 47 | } 48 | 49 | /** 50 | * Render a message letting the user know the post has an open fork pending. 51 | * 52 | * @return void 53 | */ 54 | function render_open_fork_message() { 55 | global $post; 56 | 57 | if ( true !== Posts\post_type_supports_forking( $post ) ) { 58 | return; 59 | } 60 | 61 | $fork = Posts\get_open_fork_for_post( $post ); 62 | 63 | if ( true !== Helpers\is_post( $fork ) ) { 64 | return; 65 | } 66 | 67 | $message = $this->get_fork_exists_message(); 68 | $link_label = $this->get_edit_fork_label(); ?> 69 | 70 |
71 | 72 | 73 | 76 | 77 | 78 |
79 | get_editing_fork_message(); 101 | 102 | $link = sprintf( 103 | '%s', 108 | esc_url( get_permalink( $source_post->ID ) ), 109 | esc_html( get_the_title( $source_post ) ) 110 | ); 111 | 112 | $message = sprintf( $message, $link ); ?> 113 | 114 |
115 | 116 |
117 | get_viewing_archived_fork_message(); 139 | $link_label = $this->get_edit_source_post_label(); ?> 140 | 141 |
142 | 143 | 144 | 147 | 148 | 149 |
150 | get_fork_post_button_label(); ?> 166 | 167 |
168 | 169 | 175 | 180 | 182 |
183 | get_merge_post_button_label(); ?> 199 | 200 |
201 | 202 | 203 | 209 | 210 | 212 |
213 | should_hide_wp_publish_buttons() ) { 223 | return; 224 | } ?> 225 | 226 | 231 | 232 | alter_status_field(); 244 | 245 | if ( Posts\is_archived_fork( $post ) ) { ?> 246 | 253 | should_hide_wp_status_field() ) { 264 | return; 265 | } ?> 266 | 267 | 272 | 273 | get_fork_exists_message(); 360 | $link_label = $this->get_edit_fork_label(); ?> 361 | 362 |
363 |
364 |
365 |
366 |

367 | 368 |

369 | 370 |

371 | 372 | 373 | 374 |

375 |
376 |
377 |
378 | draft_status = new DraftForkStatus(); 39 | $this->pending_status = new PendingForkStatus(); 40 | $this->archived_status = new ArchivedForkStatus(); 41 | } 42 | 43 | /** 44 | * Register needed hooks. 45 | * 46 | * @return void 47 | */ 48 | public function register() { 49 | $this->draft_status->register(); 50 | $this->pending_status->register(); 51 | $this->archived_status->register(); 52 | 53 | add_filter( 54 | 'safe_edit_filter_insert_post_data', 55 | [ $this, 'filter_draft_fork_post_data' ], 56 | 10, 2 57 | ); 58 | 59 | add_filter( 60 | 'the_title', 61 | array( $this, 'filter_admin_post_list_title' ), 62 | 10, 2 63 | ); 64 | } 65 | 66 | /** 67 | * Get our valid statuses. 68 | * 69 | * @return array 70 | */ 71 | public static function get_valid_fork_post_statuses() { 72 | return array( 73 | DraftForkStatus::get_name(), 74 | PendingForkStatus::get_name(), 75 | ArchivedForkStatus::get_name(), 76 | ); 77 | } 78 | 79 | /** 80 | * Filter post data when saving a draft of a fork. This keeps the post status the same instead of applying the default "pending" status for drafts. 81 | * 82 | * @param array $data An array of slashed post data. 83 | * @param array $postarr An array of sanitized, but otherwise unmodified post data. 84 | * @return array 85 | */ 86 | public function filter_draft_fork_post_data( $data, $postarr ) { 87 | $post = null; 88 | 89 | if ( ! empty( $postarr['ID'] ) ) { 90 | $post = Helpers\get_post( $postarr['ID'] ); 91 | } 92 | 93 | if ( true !== Helpers\is_post( $post ) ) { 94 | return $data; 95 | } 96 | 97 | if ( empty( $postarr['post_status'] ) ) { 98 | return $data; 99 | } 100 | 101 | // If the new post status is pending, keep the original post status. 102 | if ( 'pending' === $postarr['post_status'] ) { 103 | $data['post_status'] = PendingForkStatus::get_name(); 104 | } 105 | 106 | return $data; 107 | } 108 | 109 | /** 110 | * Alter the post title for forks shown in the dashboard post lists. 111 | * 112 | * @param string $title The post title 113 | * @param int $id The post ID 114 | * @return string The post title 115 | */ 116 | public function filter_admin_post_list_title( $title, $id ) { 117 | global $pagenow; 118 | 119 | if ( 120 | ! is_admin() || 121 | 'edit.php' !== $pagenow || 122 | true !== Posts\post_type_supports_forking( $id ) 123 | ) { 124 | return $title; 125 | } 126 | 127 | $suffix = ''; 128 | $status = ''; 129 | 130 | if ( 'trash' === sanitize_text_field( filter_input( INPUT_GET, 'post_status' ) ) ) { 131 | $status = get_post_meta( $id, '_wp_trash_meta_status', true ); 132 | } else { 133 | $status = get_post_status( $id ); 134 | } 135 | 136 | switch ( $status ) { 137 | case DraftForkStatus::get_name(): 138 | $suffix = esc_html__( '— Draft Revision', 'wp-safe-edit' ); 139 | break; 140 | 141 | case PendingForkStatus::get_name(): 142 | $suffix = esc_html__( '— Pending Draft Revision', 'wp-safe-edit' ); 143 | break; 144 | 145 | case ArchivedForkStatus::get_name(): 146 | $suffix = esc_html__( '— Archived Draft Revision', 'wp-safe-edit' ); 147 | break; 148 | 149 | case 'publish': 150 | if ( true === Posts\post_has_open_fork( $id ) ) { 151 | $suffix = esc_html__( '— Draft Revision Pending', 'wp-safe-edit' ); 152 | } 153 | 154 | break; 155 | 156 | default: 157 | $suffix = ''; 158 | break; 159 | } 160 | 161 | $suffix = apply_filters( 'safe_edit_admin_post_title_suffix', $suffix, $title, $id ); 162 | 163 | if ( empty( $suffix ) ) { 164 | return $title; 165 | } 166 | 167 | $title = sprintf( 168 | '%s %s', 169 | $title, 170 | $suffix 171 | ); 172 | 173 | return $title; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /includes/Posts/Statuses/AbstractStatus.php: -------------------------------------------------------------------------------- 1 | register_post_status(); 40 | } 41 | 42 | /** 43 | * Register post status. 44 | * 45 | * @return void 46 | */ 47 | function register_post_status() { 48 | register_post_status( $this->get_name(), $this->get_options() ); 49 | } 50 | 51 | /** 52 | * Get the options to use when registering the post status. 53 | * 54 | * @return array 55 | */ 56 | function get_options() { 57 | return array( 58 | 'label' => $this->get_label(), 59 | 'internal' => true, 60 | 'exclude_from_search' => true, 61 | 'show_in_admin_all_list' => false, 62 | 'protected' => true, 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /includes/Posts/Statuses/ArchivedForkStatus.php: -------------------------------------------------------------------------------- 1 | (%s)', 'wp-safe-edit' ); 24 | $options['label_count'] = _n_noop( $label_value, $label_value ); 25 | 26 | return $options; 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /includes/Posts/Statuses/PendingForkStatus.php: -------------------------------------------------------------------------------- 1 | (%s)', 'wp-safe-edit' ); 24 | $options['label_count'] = _n_noop( $label_value, $label_value ); 25 | 26 | return $options; 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /includes/Posts/Trash.php: -------------------------------------------------------------------------------- 1 | trash_forks( $post_id ); 41 | } 42 | 43 | /** 44 | * Handle cleanup when a post is untrashed. 45 | * 46 | * @param int $post_id The ID of the post untrashed 47 | * @return void 48 | */ 49 | public function handle_untrashed_post( $post_id ) { 50 | $this->untrash_forks( $post_id ); 51 | } 52 | 53 | /** 54 | * Trash all forks for a post. 55 | * 56 | * @param int $post_id The ID of the post to trash the forks for. 57 | * @return void 58 | */ 59 | public function trash_forks( $post_id ) { 60 | if ( true !== Helpers\is_valid_post_id( $post_id ) ) { 61 | return; 62 | } 63 | 64 | $forks_query = Posts\get_all_forks_for_post( 65 | $post_id, 66 | array( 67 | 'posts_per_page' => 500 // A safe, but hopefully adequate max. 68 | ) 69 | ); 70 | 71 | if ( true !== $forks_query->have_posts() ) { 72 | return; 73 | } 74 | 75 | foreach ( $forks_query->posts as $fork ) { 76 | wp_trash_post( $fork->ID ); 77 | } 78 | } 79 | 80 | /** 81 | * Untrash all forks for a post. 82 | * 83 | * @param int $post_id The ID of the post to untrash the forks for. 84 | * @return void 85 | */ 86 | public function untrash_forks( $post_id ) { 87 | if ( true !== Helpers\is_valid_post_id( $post_id ) ) { 88 | return; 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /includes/functions/db-helpers.php: -------------------------------------------------------------------------------- 1 | prepare( $query, absint( $post->ID ) ); 33 | $results = $wpdb->query( $query ); 34 | 35 | return $results; 36 | } 37 | 38 | /** 39 | * Copy meta data from one post to another. 40 | * 41 | * @param int|\WP_Post $source_post The post to copy the meta data from 42 | * @param int|\WP_Post $destination_post The post to copy the meta data to 43 | * @param array $excluded_keys Array of meta keys to ignore 44 | * @return int|boolean The number of rows inserted if successful; false if not. 45 | */ 46 | function copy_post_meta( $source_post, $destination_post, $excluded_keys = array() ) { 47 | global $wpdb; 48 | 49 | $source_post = Helpers\get_post( $source_post ); 50 | $destination_post = Helpers\get_post( $destination_post ); 51 | 52 | if ( 53 | true !== Helpers\is_post( $source_post ) || 54 | true !== Helpers\is_post( $destination_post ) 55 | ) { 56 | return false; 57 | } 58 | 59 | $query = get_copy_meta_data_insert_sql( $source_post->ID, $destination_post->ID, $excluded_keys ); 60 | 61 | if ( empty( $query ) ) { 62 | return false; 63 | } 64 | 65 | $result = $wpdb->query( $query ); 66 | return $result; 67 | } 68 | 69 | /** 70 | * Copy the taxonomy terms from the original post to the forked post. 71 | * 72 | * @param int|\WP_Post $source_post The post ID or object to copy the terms from 73 | * @param int|\WP_Post $destination_post The post ID or object to copy the terms to 74 | * @return int|boolean The number of taxonomy terms copied to the destination post if successful; false if not. 75 | */ 76 | function copy_post_terms( $source_post, $destination_post ) { 77 | $source_post = Helpers\get_post( $source_post ); 78 | $destination_post = Helpers\get_post( $destination_post ); 79 | 80 | if ( 81 | true !== Helpers\is_post( $source_post ) || 82 | true !== Helpers\is_post( $destination_post ) 83 | ) { 84 | return false; 85 | } 86 | 87 | $post_type = get_post_type( $source_post ); 88 | $taxonomies = get_object_taxonomies( $post_type, 'names' ); 89 | $count = 0; 90 | 91 | if ( empty( $taxonomies ) || ! is_array( $taxonomies ) ) { 92 | return false; 93 | } 94 | 95 | foreach ( $taxonomies as $taxonomy ) { 96 | $terms = wp_get_object_terms( 97 | $source_post->ID, 98 | $taxonomy, 99 | array( 'fields' => 'ids' ) 100 | ); 101 | 102 | if ( empty( $terms ) ) { 103 | continue; 104 | } 105 | 106 | wp_set_object_terms( $destination_post->ID, $terms, $taxonomy, false ); 107 | 108 | $count += count( $terms ); 109 | } 110 | 111 | return $count; 112 | } 113 | 114 | /** 115 | * Get the SQL statement to insert post meta fields copied from another post. 116 | * 117 | * @param int $source_post_id The post id to copy the meta data from 118 | * @param int $destination_post_id The post id to copy the meta data to 119 | * @param array $excluded_keys Array of meta keys to ignore 120 | * @return string|boolean The SQL statement if successful; false if not. 121 | */ 122 | function get_copy_meta_data_insert_sql( $source_post_id, $destination_post_id, $excluded_keys = array() ) { 123 | global $wpdb; 124 | 125 | if ( 126 | true !== Helpers\is_valid_post_id( $source_post_id ) || 127 | true !== Helpers\is_valid_post_id( $destination_post_id ) 128 | ) { 129 | return false; 130 | } 131 | 132 | $table = get_postmeta_table_name(); 133 | if ( empty( $table ) ) { 134 | return false; 135 | } 136 | 137 | $values = ''; 138 | $meta_data = get_all_post_meta_data( $source_post_id ); 139 | 140 | if ( empty( $meta_data ) || ! is_array( $meta_data ) ) { 141 | return false; 142 | } 143 | 144 | foreach ( $meta_data as $field ) { 145 | $meta_key = Helpers\get_property( 'meta_key', $field ); 146 | 147 | if ( empty( $meta_key ) ) { 148 | continue; 149 | } 150 | 151 | if ( in_array( $meta_key, (array) $excluded_keys ) ) { 152 | continue; 153 | } 154 | 155 | $meta_value = Helpers\get_property( 'meta_value', $field ); 156 | 157 | $fragment = '(%d, %s, %s),'; 158 | $fragment = $wpdb->prepare( 159 | $fragment, 160 | absint( $destination_post_id ), 161 | $meta_key, 162 | $meta_value 163 | ); 164 | 165 | $values .= $fragment; 166 | } 167 | 168 | if ( empty( $values ) ) { 169 | return ''; 170 | } 171 | 172 | $values = rtrim( $values, ',' ); 173 | $query = <<prepare( $query, absint( $post_id ) ); 207 | $results = $wpdb->get_results( $query, ARRAY_A ); 208 | 209 | return $results; 210 | } 211 | 212 | /** 213 | * Get the postmeta table name. 214 | * 215 | * @return string 216 | */ 217 | function get_postmeta_table_name() { 218 | global $wpdb; 219 | return $wpdb->postmeta; 220 | } 221 | -------------------------------------------------------------------------------- /includes/functions/helpers.php: -------------------------------------------------------------------------------- 1 | $key ) ) { 127 | return $default; 128 | } 129 | 130 | return $data->$key; 131 | } 132 | -------------------------------------------------------------------------------- /includes/functions/log-helpers.php: -------------------------------------------------------------------------------- 1 | getMessage(), 18 | $message 19 | ); 20 | } else { 21 | $message = $e->getMessage(); 22 | } 23 | 24 | error_log( $message ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /includes/functions/post-helpers.php: -------------------------------------------------------------------------------- 1 | post_status, array( 'publish', 'private' ) ) ) { 38 | throw new Exception( 39 | esc_html__( 'Post cannot be forked because the post status is not supported.', 'wp-safe-edit' ) 40 | ); 41 | } 42 | 43 | if ( true === is_open_fork( $post ) ) { 44 | throw new Exception( 45 | esc_html__( 'Post cannot be forked because it is already a fork.', 'wp-safe-edit' ) 46 | ); 47 | } 48 | 49 | if ( true === post_has_open_fork( $post ) ) { 50 | throw new Exception( 51 | esc_html__( 'Post cannot be forked because a previous fork that is still open.', 'wp-safe-edit' ) 52 | ); 53 | } 54 | 55 | if ( true !== current_user_can_fork_post( $post ) ) { 56 | throw new Exception( 57 | esc_html__( 'Post cannot be forked because the current user does not have permission.', 'wp-safe-edit' ) 58 | ); 59 | } 60 | 61 | return apply_filters( 'safe_edit_post_can_be_forked', true, $post ); 62 | 63 | } catch ( Exception $e ) { 64 | return false; 65 | } 66 | } 67 | 68 | /** 69 | * Determine if a post can be merged. 70 | * 71 | * @param int|\WP_Post $post 72 | * @return boolean 73 | */ 74 | function post_can_be_merged( $post ) { 75 | $post = Helpers\get_post( $post ); 76 | 77 | try { 78 | if ( true !== Helpers\is_post( $post ) ) { 79 | throw new InvalidArgumentException( 80 | esc_html__( 'Post cannot be merged because it is not a valid post object or post ID.', 'wp-safe-edit' ) 81 | ); 82 | } 83 | 84 | if ( true !== post_type_supports_forking( $post ) ) { 85 | throw new Exception( 86 | esc_html__( 'Post cannot be merged because the post type does not support forking.', 'wp-safe-edit' ) 87 | ); 88 | } 89 | 90 | if ( true !== is_open_fork( $post ) && 'publish' !== $post->post_status ) { 91 | throw new Exception( 92 | esc_html__( 'Post cannot be merged because it is not an open fork.', 'wp-safe-edit' ) 93 | ); 94 | } 95 | 96 | if ( true !== fork_has_source_post( $post ) ) { 97 | throw new Exception( 98 | esc_html__( 'Post cannot be merged because the source post cannot be found.', 'wp-safe-edit' ) 99 | ); 100 | } 101 | 102 | if ( true !== current_user_can_merge_post( $post ) ) { 103 | throw new Exception( 104 | esc_html__( 'Post cannot be merged because the current user does not have permission.', 'wp-safe-edit' ) 105 | ); 106 | } 107 | 108 | return apply_filters( 'safe_edit_post_can_be_merged', true, $post ); 109 | 110 | } catch ( Exception $e ) { 111 | return false; 112 | } 113 | } 114 | 115 | /** 116 | * Determine if a post has a currently open fork. 117 | * 118 | * @param int|\WP_Post $post 119 | * @return boolean 120 | */ 121 | function post_has_open_fork( $post ) { 122 | $fork = get_open_fork_for_post( $post ); 123 | 124 | if ( true === Helpers\is_post( $fork ) ) { 125 | return true; 126 | } 127 | 128 | return false; 129 | } 130 | 131 | /** 132 | * Determine if a fork has a source post. 133 | * 134 | * @param int|\WP_Post $post 135 | * @return boolean 136 | */ 137 | function fork_has_source_post( $post ) { 138 | $source = get_source_post_for_fork( $post ); 139 | 140 | if ( true === Helpers\is_post( $source ) ) { 141 | return true; 142 | } 143 | 144 | return false; 145 | } 146 | 147 | /** 148 | * Get the current forked version of a post. 149 | * 150 | * @param int|\WP_Post $post 151 | * @return \WP_Post|null 152 | */ 153 | function get_open_fork_for_post( $post ) { 154 | $post_id = 0; 155 | 156 | if ( Helpers\is_post( $post ) ) { 157 | $post_id = $post->ID; 158 | } elseif ( Helpers\is_valid_post_id( $post ) ) { 159 | $post_id = absint( $post ); 160 | } 161 | 162 | if ( true !== Helpers\is_valid_post_id( $post_id ) ) { 163 | return null; 164 | } 165 | 166 | $args = array( 167 | 'post_type' => 'any', 168 | 'posts_per_page' => 1, 169 | 'order' => 'DESC', 170 | 'orderby' => 'modified', // Important to order by the modified date because the published date won't change when a post is updated. 171 | 'post_status' => (array) get_open_fork_post_statuses(), 172 | 'no_found_rows' => true, 173 | 'ignore_sticky_posts' => true, 174 | 'meta_query' => array( 175 | array( 176 | 'key' => Posts::ORIGINAL_POST_ID_META_KEY, 177 | 'value' => $post_id, 178 | ), 179 | ), 180 | ); 181 | 182 | $fork_query = new \WP_Query( $args ); 183 | 184 | if ( $fork_query->have_posts() ) { 185 | return $fork_query->posts[0]; 186 | } 187 | 188 | return null; 189 | } 190 | 191 | /** 192 | * Get the WP_Query object for all forks (open and archived) for a post. 193 | * 194 | * @param int|\WP_Post $post 195 | * @param array $query_args Args to pass to WP_Query 196 | * @return \WP_Query|null 197 | */ 198 | function get_all_forks_for_post( $post, $query_args = array() ) { 199 | $post_id = 0; 200 | 201 | if ( Helpers\is_post( $post ) ) { 202 | $post_id = $post->ID; 203 | } elseif ( Helpers\is_valid_post_id( $post ) ) { 204 | $post_id = absint( $post ); 205 | } 206 | 207 | if ( true !== Helpers\is_valid_post_id( $post_id ) ) { 208 | return null; 209 | } 210 | 211 | $args = array( 212 | 'post_type' => 'any', 213 | 'post_status' => (array) Statuses::get_valid_fork_post_statuses(), 214 | 'no_found_rows' => true, 215 | 'ignore_sticky_posts' => true, 216 | 'meta_query' => array( 217 | array( 218 | 'key' => Posts::ORIGINAL_POST_ID_META_KEY, 219 | 'value' => $post_id, 220 | ), 221 | ), 222 | ); 223 | 224 | if ( ! empty( $query_args ) && is_array( $query_args ) ) { 225 | $args = array_merge( $args, $query_args ); 226 | } 227 | 228 | $fork_query = new \WP_Query( $args ); 229 | 230 | return $fork_query; 231 | } 232 | 233 | /** 234 | * Get the source post for a fork. 235 | * 236 | * @param int|\WP_Post $post 237 | * @return \WP_Post|null 238 | */ 239 | function get_source_post_for_fork( $post ) { 240 | $post = Helpers\get_post( $post ); 241 | 242 | if ( true !== Helpers\is_post( $post ) ) { 243 | return null; 244 | } 245 | 246 | $original_post_id = get_original_post_id_for_fork( $post ); 247 | 248 | if ( true !== Helpers\is_valid_post_id( $original_post_id ) ) { 249 | return null; 250 | } 251 | 252 | $args = array( 253 | 'p' => absint( $original_post_id ), 254 | 'post_type' => 'any', 255 | 'posts_per_page' => 1, 256 | 'no_found_rows' => true, 257 | 'ignore_sticky_posts' => true, 258 | ); 259 | 260 | $source_query = new \WP_Query( $args ); 261 | 262 | if ( $source_query->have_posts() ) { 263 | return $source_query->posts[0]; 264 | } 265 | 266 | return null; 267 | } 268 | 269 | /** 270 | * Determine if a post supports forking. 271 | * 272 | * @param int|\WP_Post $post 273 | * @return boolean 274 | */ 275 | function post_type_supports_forking( $post ) { 276 | $post_type = get_post_type( $post ); 277 | 278 | return true === post_type_supports( $post_type, \TenUp\WPSafeEdit\Posts\PostTypeSupport::FORKING_FEATURE_NAME ); 279 | } 280 | 281 | /** 282 | * Determine if the current user can fork a post. 283 | * 284 | * @param int|\WP_Post $post 285 | * @return boolean 286 | */ 287 | function current_user_can_fork_post( $post ) { 288 | $post = Helpers\get_post( $post ); 289 | 290 | if ( true !== Helpers\is_post( $post ) ) { 291 | return false; 292 | } 293 | 294 | $post_type = get_post_type_object( $post->post_type ); 295 | 296 | // First determine if the user can edit published posts. 297 | $edit_published_privilege = $post_type->cap->edit_published_posts; 298 | $value = current_user_can( $edit_published_privilege ); 299 | 300 | // If the user can edit published posts, also determine if the user can edit the fork post by ID. 301 | if ( true === $value ) { 302 | $edit_post_privilege = $post_type->cap->edit_post; 303 | $value = current_user_can( $edit_post_privilege, $post->ID ); 304 | } 305 | 306 | return true === apply_filters( 'safe_edit_current_user_can_fork_post', $value, $post ); 307 | } 308 | 309 | /** 310 | * Determine if the current user can edit a fork. 311 | * 312 | * @param int|\WP_Post $post 313 | * @return boolean 314 | */ 315 | function current_user_can_edit_fork( $post ) { 316 | $value = current_user_can_fork_post( $post ); 317 | return true === apply_filters( 'safe_edit_current_user_can_edit_fork', $value, $post ); 318 | } 319 | 320 | /** 321 | * Determine if the current user can merge a post. 322 | * 323 | * @param int|\WP_Post $post 324 | * @return boolean 325 | */ 326 | function current_user_can_merge_post( $post ) { 327 | $post = Helpers\get_post( $post ); 328 | 329 | if ( true !== Helpers\is_post( $post ) ) { 330 | return false; 331 | } 332 | 333 | $post_type = get_post_type_object( $post->post_type ); 334 | 335 | // First determine if the user can publish posts. 336 | $published_privilege = $post_type->cap->publish_posts; 337 | $value = current_user_can( $published_privilege ); 338 | 339 | // As an extra level of security, also determine if the user can edit the post by ID. 340 | if ( true === $value ) { 341 | $edit_post_privilege = $post_type->cap->edit_post; 342 | $value = current_user_can( $edit_post_privilege, $post->ID ); 343 | } 344 | 345 | return true === apply_filters( 'safe_edit_current_user_can_merge_post', $value, $post ); 346 | } 347 | 348 | /** 349 | * Get an array of post statuses for forks that have not yet been published or archived. 350 | * 351 | * @return array 352 | */ 353 | function get_open_fork_post_statuses() { 354 | return array( 355 | DraftForkStatus::NAME, 356 | PendingForkStatus::NAME, 357 | ); 358 | } 359 | 360 | /** 361 | * Determine if a post is an open fork. 362 | * 363 | * @param int|\WP_Post $post 364 | * @return boolean 365 | */ 366 | function is_open_fork( $post ) { 367 | $status = get_post_status( $post ); 368 | $open_statuses = get_open_fork_post_statuses(); 369 | 370 | return in_array( $status, $open_statuses ); 371 | } 372 | 373 | /** 374 | * Determine if a post is an archived fork. 375 | * 376 | * @param int|\WP_Post $post 377 | * @return boolean 378 | */ 379 | function is_archived_fork( $post ) { 380 | $status= get_post_status( $post ); 381 | 382 | return $status === ArchivedForkStatus::get_name(); 383 | } 384 | 385 | /** 386 | * Determine if a post is a fork (any valid fork status). 387 | * 388 | * @param int|\WP_Post $post 389 | * @return boolean 390 | */ 391 | function is_fork( $post ) { 392 | $status = get_post_status( $post ); 393 | $valid_statuses = (array) Statuses::get_valid_fork_post_statuses(); 394 | 395 | return in_array( $post->post_status, $valid_statuses ); 396 | } 397 | 398 | /** 399 | * Save the original post ID for a fork. 400 | * 401 | * @param int|\WP_Post $forked_post The fork 402 | * @param int|\WP_Post $original_post The original post 403 | */ 404 | function set_original_post_id_for_fork( $forked_post, $original_post ) { 405 | try { 406 | $forked_post_id = $forked_post; 407 | $original_post_id = $original_post; 408 | 409 | if ( true === Helpers\is_post( $forked_post ) ) { 410 | $forked_post_id = $forked_post->ID; 411 | } 412 | 413 | if ( true === Helpers\is_post( $original_post ) ) { 414 | $original_post_id = $original_post->ID; 415 | } 416 | 417 | if ( 418 | true !== Helpers\is_valid_post_id( $forked_post_id ) || 419 | true !== Helpers\is_valid_post_id( $original_post_id ) 420 | ) { 421 | throw new Exception( 422 | esc_html__( 'Could not set the original post ID for a fork because the fork or original post were invalid.', 'wp-safe-edit' ) 423 | ); 424 | } 425 | 426 | add_post_meta( 427 | absint( $forked_post_id ), 428 | Posts::ORIGINAL_POST_ID_META_KEY, 429 | absint( $original_post_id ), 430 | true 431 | ); 432 | 433 | } catch ( \Exception $e ) { 434 | return false; 435 | } 436 | } 437 | 438 | /** 439 | * Get the original post ID for a fork. 440 | * 441 | * @param int|\WP_Post $forked_post The fork 442 | * 443 | * @return int 444 | */ 445 | function get_original_post_id_for_fork( $forked_post ) { 446 | try { 447 | if ( true === Helpers\is_post( $forked_post ) ) { 448 | $forked_post = $forked_post->ID; 449 | } 450 | 451 | if ( true !== Helpers\is_valid_post_id( $forked_post ) ) { 452 | throw new Exception( 453 | esc_html__( 'Could not get the original post ID for a fork because the fork was invalid.', 'wp-safe-edit' ) 454 | ); 455 | } 456 | 457 | return get_post_meta( 458 | absint( $forked_post ), 459 | Posts::ORIGINAL_POST_ID_META_KEY, 460 | true 461 | ); 462 | 463 | } catch ( \Exception $e ) { 464 | return false; 465 | } 466 | } 467 | 468 | /** 469 | * Get an array of post types that support forking. 470 | * 471 | * @return array 472 | */ 473 | function get_forkable_post_types() { 474 | return get_post_types_by_support( PostTypeSupport::FORKING_FEATURE_NAME ); 475 | } 476 | 477 | /** 478 | * Get the archived forks query for a post. 479 | * 480 | * @param int|\WP_Post $post The post to get the archived forks for 481 | * @param array $query_args Array of query args 482 | * 483 | * @return \WP_Query|null 484 | */ 485 | function get_archived_forks_query( $post, $query_args = array() ) { 486 | $post = Helpers\get_post( $post ); 487 | 488 | if ( true !== Helpers\is_post( $post ) ) { 489 | return null; 490 | } 491 | 492 | $args = array( 493 | 'post_type' => $post->post_type, 494 | 'posts_per_page' => 10, 495 | 'order' => 'DESC', 496 | 'orderby' => 'modified', // Important to order by the modified date because the published date won't change when a post is updated. 497 | 'post_status' => ArchivedForkStatus::NAME, 498 | 'no_found_rows' => true, 499 | 'ignore_sticky_posts' => true, 500 | 'meta_query' => array( 501 | array( 502 | 'key' => Posts::ORIGINAL_POST_ID_META_KEY, 503 | 'value' => $post->ID, 504 | ), 505 | ), 506 | ); 507 | 508 | if ( is_array( $query_args ) || ! empty( $query_args ) ) { 509 | $args = array_merge( $args, $query_args ); 510 | } 511 | 512 | return new \WP_Query( $args ); 513 | } 514 | 515 | /** 516 | * Determine if a post has at least one archived fork. 517 | * 518 | * @param int|\WP_Post $post The post to get the archived forks for 519 | * @return boolean 520 | */ 521 | function post_has_archived_forks( $post ) { 522 | $archived_forks_query = get_archived_forks_query( $post, array( 'posts_per_page' => 1 ) ); 523 | 524 | if ( true === $archived_forks_query->have_posts() ) { 525 | return true; 526 | } 527 | 528 | return false; 529 | } 530 | -------------------------------------------------------------------------------- /includes/readme.md: -------------------------------------------------------------------------------- 1 | # Includes 2 | 3 | All plugin classes, objects, and libraries should be hidden away in this `/includes` directory. -------------------------------------------------------------------------------- /languages/forkit.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: WP Safe Edit\n" 4 | "POT-Creation-Date: 2017-10-16T22:04:53.645Z\n" 5 | "PO-Revision-Date: 2017-10-16T22:04:53.645Z\n" 6 | "Last-Translator: Michael Phillips <>\n" 7 | "Language-Team: \n" 8 | "MIME-Version: 1.0\n" 9 | "Content-Type: text/plain; charset=UTF-8\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "X-Poedit-KeywordsList: __;_e;__ngettext:1,2;_n:1,2;__ngettext_noop:1,2;" 12 | "_n_noop:1,2;_x:1,2c;_nx:4c,1,2;_nx_noop:4c,1,2;_ex:1,2c;" 13 | "esc_attr__;esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c\n" 14 | "X-Poedit-Basepath: .\n" 15 | "X-Poedit-SearchPath-0: ..\n" 16 | 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "10up-wp-safe-edit", 3 | "title": "WP Safe Edit", 4 | "description": "Edit published posts safely behind the scenes and publish the changes when ready.", 5 | "version": "0.1.0", 6 | "homepage": "https://github.com/10up/WP-Safe-Edit", 7 | "repository": { 8 | "type": "git", 9 | "url": "" 10 | }, 11 | "author": { 12 | "name": "Michael Phillips", 13 | "email": "", 14 | "url": "" 15 | }, 16 | "devDependencies": { 17 | "@babel/core": "^7.0.0-beta.51", 18 | "@babel/preset-env": "^7.0.0-beta.51", 19 | "@wordpress/data": "^1.0.0-alpha.1", 20 | "autoprefixer": "^6.0.0", 21 | "babel-cli": "^6.26.0", 22 | "babel-core": "^6.26.3", 23 | "babel-loader": "^7.1.4", 24 | "babel-preset-env": "^1.6.1", 25 | "babel-preset-react": "^6.24.1", 26 | "chai": "^3.5.0", 27 | "glob": "~5.0.15", 28 | "grunt": "^1.5.3", 29 | "grunt-babel": "^7.0.0", 30 | "grunt-contrib-clean": "^0.6.0", 31 | "grunt-contrib-compress": "^2.0.0", 32 | "grunt-contrib-concat": "^0.5.1", 33 | "grunt-contrib-copy": "^0.8.0", 34 | "grunt-contrib-cssmin": "^0.12.3", 35 | "grunt-contrib-jshint": "^3.2.0", 36 | "grunt-contrib-uglify": "^0.9.1", 37 | "grunt-contrib-watch": "^1.1.0", 38 | "grunt-mocha": "^1.1.0", 39 | "grunt-phpunit": "^0.3.6", 40 | "grunt-postcss": "^0.6.0", 41 | "grunt-sass": "^1.0.0", 42 | "grunt-wp-readme-to-markdown": "^0.9.0", 43 | "load-grunt-config": "~4.0.1", 44 | "load-grunt-tasks": "^3.3.0", 45 | "react": "^16.4.1", 46 | "webpack": "^4.12.1", 47 | "webpack-cli": "^2.1.5", 48 | "window": "^4.2.5" 49 | }, 50 | "keywords": [], 51 | "dependencies": {}, 52 | "scripts": { 53 | "watch": "webpack -w --mode development", 54 | "dev": "webpack --mode development", 55 | "build": "webpack --mode production" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # WP Safe Edit 2 | 3 | > Safely edit published posts behind the scenes without affecting the live site. You can save your changes as a draft and publish them when ready, so you don't have to finish your updates in one sitting. This gives editors the opportunity to collaborate on changes or get approval before publishing. 4 | 5 | [![Support Level](https://img.shields.io/badge/support-archived-red.svg)](#support-level) [![MIT License](https://img.shields.io/github/license/10up/wp-safe-edit.svg)](https://github.com/10up/wp-safe-edit/blob/5d7cf0c421d6fdbeb98e3dd54ccb3d41e6d3d4d2/composer.json#L8) 6 | 7 | > [!CAUTION] 8 | > As of 12 April 2024, this project is archived and no longer being actively maintained. 9 | 10 | ## Requirements 11 | 12 | * **WordPress >= 4.5** due to the use of `get_post_types_by_support()` 13 | * **PHP >=5.4** 14 | 15 | ## Installation 16 | 17 | 1. Download and activate the plugin in WordPress. 18 | 19 | 2. Draft functionality is available for posts and pages by default. You can register support for custom post types using a filter: 20 | 21 | ```php 22 | add_filter( 'safe_edit_supported_post_types', function( $post_types ) { 23 | // Add 'book' post type to array of supported post types. 24 | $post_types[] = 'book'; 25 | 26 | return $post_types; 27 | } ); 28 | ``` 29 | 30 | ## Usage 31 | 32 | 1. When this plugin is installed, a **"Save as Draft"** button [Fig. 1] will be available for posts and pages as well as the post types you registered support for. Pressing this button will create a draft copy of the post where you can stage your changes. All post meta and taxonomy terms associated with the post will be included.

33 | 34 | ![Save Draft button](.wordpress-org/screenshot-1.jpg "Image of the “Save as Draft” button.") 35 | 36 | 2. When editing a draft, it functions like any other post so you can do the following: 37 | * **Save Changes as a Draft:** Changes saved as a draft will not be reflected on the live site until you publish them. 38 | 39 | * **Preview Changes:** Preview your changes at any time by pressing the **"Preview"** button. 40 | 41 | * **Trash Changes:** If you change your mind, you can trash your updates by pressing the **"Move to Trash"** link. 42 | 43 | 3. Once you're happy with your changes, publish them by pressing the **"Publish Changes"** button [Fig. 2]. The post you created the draft from will be updated with your changes and reflected on the live site. 44 | 45 | ![Publish Changes button](.wordpress-org/screenshot-2.jpg "Image of the “Publish Changes” button.") 46 | 47 | ## Viewing Previous Drafts 48 | 49 | You can view the most recent drafts created for a post using the **"Archived Draft Revisions"** meta box [Fig. 3]. Unlike WordPress revisions, all content, terms, and meta data are retained so you can see what the draft looked like when it was published. 50 | 51 | ![Archived Draft Revisions meta box](.wordpress-org/screenshot-3.jpg "Image of the “Archived Draft Revisions” meta box.") 52 | 53 | ## Caveats & Limitations 54 | 55 | 1. This plugin isn't compatible with post types using Gutenberg yet. 56 | 57 | 2. You cannot edit a post in the dashboard if an open draft exists for it because the changes would be overwritten when the draft is published; a lockout message is shown if you try [Fig. 4]. **Note:** It's still possible to edit the post through an API or code, so consider that before enabling support. A planned improvement is to interrupt the publish draft process when the source post has been modified since the draft was created. 58 | 59 | ![Source Post Lockout](.wordpress-org/screenshot-4.jpg "Image of the “open draft exist” lockout message.") 60 | 61 | 3. If a post type contains meta boxes that save data behind the scenes using AJAX, you may need to hook into the publish draft process to make adjustments. Consider the following scenario: 62 | 63 | 1. You create a draft of a post. 64 | 2. On the draft, you use a meta box that creates an associated post in the background using AJAX. The associated post references the draft's post ID. 65 | 3. You publish the draft. 66 | 4. The source post has been updated with the changes from the draft, but the associated post you created still references the draft's post ID. To resolve this, adjustments to the associated post needs to be made during the draft publishing process using either the `safe_edit_before_merge_post` or `safe_edit_after_merge_post` action. 67 | 68 | 4. You cannot change a post's URL slug using a draft because drafts are always published back to the source post retaining the original URL. 69 | 70 | ## Roadmap 71 | 72 | Some of the planned improvements are listed below: 73 | 74 | - Compatibility with Gutenberg. 75 | 76 | - Interrupt the publish draft process when the source post has been modified since the draft was created. 77 | 78 | - Break up some of the more complex draft/merge functions. 79 | 80 | - Complete unit tests. 81 | 82 | - Show more than the last 10 archived drafts. 83 | 84 | ## Support Level 85 | 86 | **Archived:** This project is no longer maintained by 10up. We are no longer responding to Issues or Pull Requests unless they relate to security concerns. We encourage interested developers to fork this project and make it their own! 87 | 88 | ## Like what you see? 89 | 90 | Work with us at 10up 91 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | const { data, apiRequest, element } = wp; 3 | 4 | 5 | if ( wp.editPost && 'undefined' !== typeof wp.editPost.PluginSidebarMoreMenuItem ) { 6 | const { __, setLocaleData } = wp.i18n; 7 | const { PluginSidebarMoreMenuItem } = wp.editPost; 8 | const { registerPlugin } = wp.plugins; 9 | const WP_SAFE_EDIT_NOTICE_ID = 'wp-safe-edit-notice'; 10 | const WP_SAFE_EDIT_STATUS_ID = 'wp-safe-edit-status'; 11 | 12 | class WPSafeEditSidebar extends Component { 13 | 14 | constructor( props ) { 15 | super( props ); 16 | 17 | // Set up translations. 18 | setLocaleData( wpSafeEditGutenbergData.locale, 'wp-safe-edit' ); 19 | } 20 | async forkPost( e ) { 21 | e.preventDefault(); 22 | const id = document.getElementById( 'post_ID' ).value; 23 | const request = { 24 | path: 'wp-safe-edit/v1/fork/' + id, 25 | data: { 26 | nonce: wpSafeEditGutenbergData.forknonce, 27 | }, 28 | nonce: wpSafeEditGutenbergData.forknonce, 29 | type: 'GET', 30 | dataType: 'json', 31 | } 32 | const result = await apiRequest( request ); 33 | if ( result.data && result.data.shouldRedirect ) { 34 | document.location = result.data.redirectUrl; 35 | } 36 | } 37 | 38 | async mergeFork( e ) { 39 | const id = document.getElementById( 'post_ID' ).value; 40 | const request = { 41 | path: 'wp-safe-edit/v1/merge/' + id, 42 | data: { 43 | nonce: wpSafeEditGutenbergData.forknonce, 44 | }, 45 | nonce: wpSafeEditGutenbergData.forknonce, 46 | type: 'GET', 47 | dataType: 'json', 48 | } 49 | 50 | const result = await apiRequest( request ); 51 | if ( result.data && result.data.shouldRedirect ) { 52 | document.location = result.data.redirectUrl; 53 | } 54 | } 55 | 56 | componentDidMount() { 57 | const { subscribe } = data; 58 | 59 | const initialPostStatus = data.select( 'core/editor' ).getEditedPostAttribute( 'status' ); 60 | 61 | if ( 'wpse-draft' === initialPostStatus || 'wpse-pending' === initialPostStatus ) { 62 | // Watch for the publish event. 63 | const unssubscribe = subscribe( ( e ) => { 64 | const currentPostStatus = data.select( 'core/editor' ).getEditedPostAttribute( 'status' ); 65 | if ( 'publish' === currentPostStatus ) { 66 | unssubscribe(); 67 | setTimeout( () => { 68 | // Merge the fork. 69 | this.mergeFork(); 70 | }, 300 ); 71 | } 72 | } ); 73 | } else { 74 | 75 | // Display any message except on for editing page 76 | if ( wpSafeEditGutenbergData.message ) { 77 | data.dispatch( 'core/notices' ).createSuccessNotice( 78 | wpSafeEditGutenbergData.message, 79 | { 80 | id: WP_SAFE_EDIT_NOTICE_ID, 81 | } 82 | ); 83 | } else { 84 | // Remove any previous notice. 85 | data.dispatch( 'core/notices' ).removeNotice( WP_SAFE_EDIT_NOTICE_ID ); 86 | } 87 | } 88 | 89 | // Remove any previous notice. 90 | data.dispatch( 'core/notices' ).removeNotice( WP_SAFE_EDIT_STATUS_ID ); 91 | 92 | // Display a notice to inform the user if this is a safe draft. 93 | var postStatus = data.select( 'core/editor' ).getEditedPostAttribute( 'status' ); 94 | if ( 'wpse-draft' === postStatus ) { 95 | const message = __( 'A draft has been created and you can edit it below. Publish your changes to make them live.', 'wp-safe-edit' ); 96 | data.dispatch( 'core/notices' ).createSuccessNotice( 97 | message, 98 | { 99 | id: WP_SAFE_EDIT_STATUS_ID, 100 | isDismissible: false, 101 | } 102 | ); 103 | } 104 | } 105 | 106 | render() { 107 | // Only show the button if the post is published and its not a safe edit draft already. 108 | var postStatus = data.select( 'core/editor' ).getEditedPostAttribute( 'status' ); 109 | var isPublished = data.select( 'core/editor' ).isCurrentPostPublished(); 110 | if ( ! isPublished || 'wpse-draft' === postStatus ) { 111 | return null; 112 | } 113 | return ( 114 | 115 | { __( 'Save as Draft', 'wp-safe-edit' ) } 122 | 123 | ); 124 | } 125 | }; 126 | 127 | // Set up the plugin fills. 128 | registerPlugin( 'wp-safe-edit', { 129 | render: WPSafeEditSidebar, 130 | icon: null, 131 | } ); 132 | } 133 | -------------------------------------------------------------------------------- /tasks/_template.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | //grunt tasks here 3 | }; -------------------------------------------------------------------------------- /tasks/build.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | grunt.registerTask( 'build', ['default', 'clean', 'copy', 'compress'] ); 3 | }; -------------------------------------------------------------------------------- /tasks/css.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | grunt.registerTask( 'css', ['sass', 'postcss', 'cssmin'] ); 3 | }; -------------------------------------------------------------------------------- /tasks/default.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | grunt.registerTask( 'default', ['css', 'js'] ); 3 | }; -------------------------------------------------------------------------------- /tasks/js.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | grunt.registerTask( 'js', ['jshint', 'concat', 'uglify'] ); 3 | }; -------------------------------------------------------------------------------- /tasks/options/_template.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // options go here 3 | }; -------------------------------------------------------------------------------- /tasks/options/clean.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | main: ['release/<%= pkg.version %>'] 3 | }; -------------------------------------------------------------------------------- /tasks/options/compress.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | main: { 3 | options: { 4 | mode: 'zip', 5 | archive: './release/wp-safe-edit.<%= pkg.version %>.zip' 6 | }, 7 | expand: true, 8 | cwd: 'release/<%= pkg.version %>/', 9 | src: ['**/*'], 10 | dest: 'wp-safe-edit/' 11 | } 12 | }; -------------------------------------------------------------------------------- /tasks/options/concat.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | options: { 3 | stripBanners: true, 4 | banner: '/*! <%= pkg.title %> - v<%= pkg.version %>\n' + 5 | ' * <%= pkg.homepage %>\n' + 6 | ' * Copyright (c) <%= grunt.template.today("yyyy") %>;' + 7 | ' * Licensed MIT' + 8 | ' */\n' 9 | }, 10 | main: { 11 | src: [ 12 | 'assets/js/src/wp-post-forking.js' 13 | ], 14 | dest: 'assets/js/wp-post-forking.js' 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /tasks/options/copy.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Copy the theme to a versioned release directory 3 | main: { 4 | expand: true, 5 | src: [ 6 | '**', 7 | '!**/.*', 8 | '!**/readme.md', 9 | '!node_modules/**', 10 | '!vendor/**', 11 | '!tests/**', 12 | '!release/**', 13 | '!assets/css/sass/**', 14 | '!assets/css/src/**', 15 | '!assets/js/src/**', 16 | '!images/src/**', 17 | '!bootstrap.php', 18 | '!bower.json', 19 | '!composer.json', 20 | '!composer.lock', 21 | '!Gruntfile.js', 22 | '!package.json', 23 | '!phpunit.xml', 24 | '!phpunit.xml.dist' 25 | ], 26 | dest: 'release/<%= pkg.version %>/' 27 | } 28 | }; -------------------------------------------------------------------------------- /tasks/options/cssmin.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | options: { 3 | banner: '/*! <%= pkg.title %> - v<%= pkg.version %>\n' + 4 | ' * <%=pkg.homepage %>\n' + 5 | ' * Copyright (c) <%= grunt.template.today("yyyy") %>;' + 6 | ' * Licensed MIT' + 7 | ' */\n' 8 | }, 9 | minify: { 10 | expand: true, 11 | 12 | cwd: 'assets/css/', 13 | src: ['wp-post-forking.css'], 14 | 15 | dest: 'assets/css/', 16 | ext: '.min.css' 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /tasks/options/jshint.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | all: [ 3 | 'Gruntfile.js', 4 | 'assets/js/src/**/*.js', 5 | 'assets/js/test/**/*.js' 6 | ] 7 | }; -------------------------------------------------------------------------------- /tasks/options/mocha.js: -------------------------------------------------------------------------------- 1 | var mochaPath = 'tests/mocha/'; 2 | 3 | module.exports = { 4 | test: { 5 | src: [ mochaPath + '**/*.html' ], 6 | options: { 7 | run: true, 8 | timeout: 10000 9 | } 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /tasks/options/phpunit.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | classes: { 3 | dir: 'tests/phpunit/' 4 | }, 5 | options: { 6 | bin: 'vendor/bin/phpunit', 7 | bootstrap: 'bootstrap.php.dist', 8 | colors: true, 9 | testSuffix: 'Tests.php' 10 | } 11 | }; -------------------------------------------------------------------------------- /tasks/options/postcss.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | dist: { 3 | options: { 4 | processors: [ 5 | require('autoprefixer')({browsers: 'last 2 versions'}) 6 | ] 7 | }, 8 | files: { 9 | 'assets/css/wp-post-forking.css': [ 'assets/css/wp-post-forking.css' ] 10 | } 11 | } 12 | }; -------------------------------------------------------------------------------- /tasks/options/sass.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | all: { 3 | options: { 4 | precision: 2, 5 | sourceMap: true 6 | }, 7 | files: { 8 | 'assets/css/wp-post-forking.css': 'assets/css/sass/wp-post-forking.scss' 9 | } 10 | } 11 | }; -------------------------------------------------------------------------------- /tasks/options/uglify.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | all: { 3 | files: { 4 | 'assets/js/wp-post-forking.min.js': ['assets/js/wp-post-forking.js'] 5 | }, 6 | options: { 7 | banner: '/*! <%= pkg.title %> - v<%= pkg.version %>\n' + 8 | ' * <%= pkg.homepage %>\n' + 9 | ' * Copyright (c) <%= grunt.template.today("yyyy") %>;' + 10 | ' * Licensed MIT' + 11 | ' */\n', 12 | mangle: { 13 | except: ['jQuery'] 14 | } 15 | } 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /tasks/options/watch.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | livereload: { 3 | files: ['assets/css/*.css'], 4 | options: { 5 | livereload: true 6 | } 7 | }, 8 | css: { 9 | files: ['assets/css/sass/**/*.scss'], 10 | tasks: ['css'], 11 | options: { 12 | debounceDelay: 500 13 | } 14 | }, 15 | js: { 16 | files: ['assets/js/src/**/*.js', 'assets/js/vendor/**/*.js'], 17 | tasks: ['js'], 18 | options: { 19 | debounceDelay: 500 20 | } 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /tasks/test.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | grunt.registerTask( 'test', ['phpunit', 'mocha'] ); 3 | }; 4 | -------------------------------------------------------------------------------- /tests/phpunit/PluginTests.php: -------------------------------------------------------------------------------- 1 | assertTrue( $instance instanceof Plugin ); 12 | } 13 | 14 | public function test_register() { 15 | \WP_Mock::expectAction( 'safe_edit_loaded' ); 16 | 17 | $instance = Plugin::get_instance(); 18 | 19 | \WP_Mock::expectActionAdded( 'init', array( $instance, 'i18n' ) ); 20 | \WP_Mock::expectActionAdded( 'init', array( $instance, 'init' ) ); 21 | 22 | $instance->register(); 23 | 24 | $this->assertConditionsMet(); 25 | } 26 | 27 | public function test_init() { 28 | \WP_Mock::expectAction( 'safe_edit_init' ); 29 | 30 | $instance = Plugin::get_instance(); 31 | $instance->init(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/phpunit/bootstrap.php: -------------------------------------------------------------------------------- 1 | setPreserveGlobalState( false ); 13 | return parent::run( $result ); 14 | } 15 | 16 | protected $testFiles = array(); 17 | 18 | public function setUp() { 19 | if ( ! empty( $this->testFiles ) ) { 20 | foreach ( $this->testFiles as $file ) { 21 | if ( file_exists( PROJECT . $file ) ) { 22 | require_once( PROJECT . $file ); 23 | } 24 | } 25 | } 26 | 27 | parent::setUp(); 28 | } 29 | 30 | public function assertActionsCalled() { 31 | $actions_not_added = $expected_actions = 0; 32 | try { 33 | WP_Mock::assertActionsCalled(); 34 | } catch ( \Exception $e ) { 35 | $actions_not_added = 1; 36 | $expected_actions = $e->getMessage(); 37 | } 38 | $this->assertEmpty( $actions_not_added, $expected_actions ); 39 | } 40 | 41 | public function ns( $function ) { 42 | if ( ! is_string( $function ) || false !== strpos( $function, '\\' ) ) { 43 | return $function; 44 | } 45 | 46 | $thisClassName = trim( get_class( $this ), '\\' ); 47 | 48 | if ( ! strpos( $thisClassName, '\\' ) ) { 49 | return $function; 50 | } 51 | 52 | // $thisNamespace is constructed by exploding the current class name on 53 | // namespace separators, running array_slice on that array starting at 0 54 | // and ending one element from the end (chops the class name off) and 55 | // imploding that using namespace separators as the glue. 56 | $thisNamespace = implode( '\\', array_slice( explode( '\\', $thisClassName ), 0, - 1 ) ); 57 | 58 | return "$thisNamespace\\$function"; 59 | } 60 | 61 | /** 62 | * Define constants after requires/includes 63 | * 64 | * See http://kpayne.me/2012/07/02/phpunit-process-isolation-and-constant-already-defined/ 65 | * for more details 66 | * 67 | * @param \Text_Template $template 68 | */ 69 | public function prepareTemplate( \Text_Template $template ) { 70 | $template->setVar( [ 71 | 'globals' => '$GLOBALS[\'__PHPUNIT_BOOTSTRAP\'] = \'' . $GLOBALS['__PHPUNIT_BOOTSTRAP'] . '\';', 72 | ] ); 73 | parent::prepareTemplate( $template ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | module: { 3 | rules: [ 4 | { 5 | test: /\.js$/, 6 | exclude: /node_modules/, 7 | use: { 8 | loader: "babel-loader" 9 | } 10 | } 11 | ] 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /wp-safe-edit.php: -------------------------------------------------------------------------------- 1 |