fork_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 |
51 |
52 |
70 |
71 |
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 |
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 |
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://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 | 
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 | 
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 | 
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 | 
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 |
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 |