ID ); return array( 'id' => (string) $post->ID, 'type' => $post->post_type, 'slug' => suiko_public_slug( $post ), 'title' => $post->post_title, // brut : get_the_title() ajoute des entités HTML (apostrophes), que Suiko afficherait telles quelles 'url' => get_permalink( $post ), 'date' => suiko_gmt_iso( $post->post_date_gmt ), 'modified_gmt' => suiko_gmt_iso( $post->post_modified_gmt ), 'keyword' => $seo['keyword'], 'categories' => wp_get_post_terms( $post->ID, 'category', array( 'fields' => 'names' ) ), 'format' => has_blocks( $post->post_content ) ? 'blocks' : 'classic', 'builder' => suiko_builder_of( $post ), ); } /** A MySQL GMT datetime as ISO 8601 in UTC, the only form Suiko compares. */ function suiko_gmt_iso( $mysql_gmt ) { $ts = strtotime( $mysql_gmt . ' UTC' ); return $ts ? gmdate( 'Y-m-d\TH:i:s\Z', $ts ) : null; } /** The path of the public address, without the domain: what Suiko shows as the page slug. */ function suiko_public_slug( $post ) { if ( 'publish' !== $post->post_status ) { return $post->post_name; } $path = wp_parse_url( get_permalink( $post ), PHP_URL_PATH ); return trim( is_string( $path ) ? $path : $post->post_name, '/' ); } /** The full article: summary, rendered HTML, SEO fields, authors. */ function suiko_article_full( $post ) { $author = get_userdata( $post->post_author ); return array_merge( suiko_article_summary( $post ), array( 'content_html' => suiko_render_content( $post ), 'excerpt' => $post->post_excerpt, 'seo' => suiko_seo_read( $post->ID ), 'authors' => $author ? array( array( 'name' => $author->display_name ) ) : array(), 'status' => $post->post_status, ) ); } /** * Applies an update. $input: title?, content_html, seo?, mode? ('draft'|'direct', site default otherwise), * expected_modified_gmt? (refused with a conflict when the article changed since Suiko read it). * Returns an array for the REST response, or a WP_Error. */ function suiko_apply_update( $post, $input ) { if ( ! empty( $input['expected_modified_gmt'] ) ) { $current = suiko_gmt_iso( $post->post_modified_gmt ); if ( strtotime( $current ) !== strtotime( $input['expected_modified_gmt'] ) ) { return new WP_Error( 'suiko_conflict', 'The article changed since Suiko read it.', array( 'status' => 409, 'modified_gmt' => $current ) ); } } $builder = suiko_builder_of( $post ); if ( $builder ) { return new WP_Error( 'suiko_builder', sprintf( 'This article is built with %s: Suiko cannot write it back without breaking the layout. Apply the update by hand in %s.', ucfirst( $builder ), ucfirst( $builder ) ), array( 'status' => 422, 'builder' => $builder ) ); } $html = isset( $input['content_html'] ) && is_string( $input['content_html'] ) ? $input['content_html'] : null; if ( null === $html ) { return new WP_Error( 'suiko_bad_request', 'content_html is required.', array( 'status' => 400 ) ); } $content = has_blocks( $post->post_content ) ? suiko_html_to_blocks( $html ) : $html; $lost = suiko_lost_tags( $content ); if ( $lost ) { return suiko_lost_tags_error( $lost ); } $title = isset( $input['title'] ) && is_string( $input['title'] ) && '' !== trim( $input['title'] ) ? $input['title'] : null; $mode = isset( $input['mode'] ) && 'direct' === $input['mode'] ? 'direct' : ( isset( $input['mode'] ) && 'draft' === $input['mode'] ? 'draft' : suiko_mode() ); $seo = isset( $input['seo'] ) && is_array( $input['seo'] ) ? $input['seo'] : null; if ( 'direct' === $mode ) { $data = array( 'ID' => $post->ID, 'post_content' => $content ); if ( $title ) { $data['post_title'] = $title; } $id = wp_update_post( wp_slash( $data ), true ); if ( is_wp_error( $id ) ) { return $id; } suiko_seo_write( $post->ID, $seo ); update_post_meta( $post->ID, '_suiko_updated_at', gmdate( 'c' ) ); $fresh = get_post( $post->ID ); return array( 'status' => 'published', 'id' => (string) $post->ID, 'url' => get_permalink( $fresh ), 'modified_gmt' => suiko_gmt_iso( $fresh->post_modified_gmt ) ); } // Draft mode: one pending draft per article. A new update replaces the previous pending draft. // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- one small lookup per update, among drafts only. $existing = get_posts( array( 'post_type' => $post->post_type, 'post_status' => 'draft', 'meta_key' => '_suiko_update_of', 'meta_value' => $post->ID, 'numberposts' => 1, 'fields' => 'ids' ) ); $data = array( 'post_type' => $post->post_type, 'post_status' => 'draft', 'post_title' => $title ? $title : $post->post_title, 'post_content' => $content, 'post_author' => $post->post_author, 'post_excerpt' => $post->post_excerpt, ); if ( $existing ) { $data['ID'] = $existing[0]; $id = wp_update_post( wp_slash( $data ), true ); } else { $id = wp_insert_post( wp_slash( $data ), true ); } if ( is_wp_error( $id ) ) { return $id; } update_post_meta( $id, '_suiko_update_of', $post->ID ); update_post_meta( $id, '_suiko_received_at', gmdate( 'c' ) ); if ( $seo ) { update_post_meta( $id, '_suiko_seo', wp_json_encode( $seo ) ); suiko_seo_write( $id, $seo ); } // admin_url() rather than get_edit_post_link(): the latter needs a logged-in user and returns null from the API. return array( 'status' => 'draft', 'id' => (string) $post->ID, 'draft_id' => (string) $id, 'edit_url' => admin_url( 'post.php?post=' . (int) $id . '&action=edit' ), 'apply_url' => suiko_apply_url( $id ) ); } /** * Requests from Suiko run with no logged-in user, so WordPress filters their HTML (wp_kses_post): an iframe or a script is * removed. Rather than let an embed vanish from a live article, the write is refused. Returns the tags that would be lost. */ function suiko_lost_tags( $content ) { $count = function ( $html ) { preg_match_all( '/<([a-z][a-z0-9-]*)\b/i', $html, $m ); return array_count_values( array_map( 'strtolower', $m[1] ) ); }; $kept = $count( wp_kses_post( $content ) ); $lost = array(); foreach ( $count( $content ) as $tag => $n ) { if ( ( isset( $kept[ $tag ] ) ? $kept[ $tag ] : 0 ) < $n ) { $lost[] = $tag; } } return $lost; } function suiko_lost_tags_error( $lost ) { return new WP_Error( 'suiko_html', sprintf( 'This content contains elements WordPress does not accept from an outside service (%s). Apply the update by hand in WordPress.', implode( ', ', $lost ) ), array( 'status' => 422, 'tags' => $lost ) ); } /** Address of the one-click "Apply this update" action for a Suiko draft (admin, capability-checked). */ function suiko_apply_url( $draft_id ) { return add_query_arg( array( 'action' => 'suiko_apply', 'draft' => (int) $draft_id, '_wpnonce' => wp_create_nonce( 'suiko_apply_' . (int) $draft_id ) ), admin_url( 'admin-post.php' ) ); } /** Copies a Suiko draft onto its live article (title, content, SEO fields), then deletes the draft. */ function suiko_apply_draft( $draft_id ) { $draft = get_post( $draft_id ); $of = $draft ? (int) get_post_meta( $draft_id, '_suiko_update_of', true ) : 0; $live = $of ? get_post( $of ) : null; if ( ! $draft || 'draft' !== $draft->post_status || ! $live ) { return new WP_Error( 'suiko_not_found', 'This draft is not a Suiko update, or its article no longer exists.' ); } $id = wp_update_post( wp_slash( array( 'ID' => $live->ID, 'post_title' => $draft->post_title, 'post_content' => $draft->post_content ) ), true ); if ( is_wp_error( $id ) ) { return $id; } $seo = get_post_meta( $draft_id, '_suiko_seo', true ); if ( $seo ) { suiko_seo_write( $live->ID, json_decode( $seo, true ) ); } update_post_meta( $live->ID, '_suiko_updated_at', gmdate( 'c' ) ); wp_delete_post( $draft_id, true ); return $live->ID; }