(or ?id=; &include_raw=1 for the full source) * POST /wp-json/agentsia/v1/content/ * { "base_hash": "...", "operations": [ ... ], "return_raw": true } * { "base_hash": "...", "restore_raw": "..." } (undo: puts a previous full source back) * * A page is cut into UNITS, each with a stable id for the current version of the page: * - block editor: one unit per block, nested blocks included (`b0`, `b1`, then `b1.0`, `b1.0.2`… inside * a group, columns, cover…). Text blocks (paragraph, heading, list, quote, table, separator, HTML, * classic) are editable; layout blocks (group, columns, cover, buttons…) are listed (`container` * when they hold other blocks) but never replaced or removed — their attributes (colours, spacing, * classes) are never touched. Operations: `replace` / `delete` a run of sibling text blocks, * `insert_after` a block (at its level), `insert_into` a container (start or end), `set_text` (new * text of a paragraph or heading, keeping its tag, classes and attributes), `duplicate` a container * with new texts for every paragraph / heading it holds (an FAQ question styled like its neighbours); * - classic editor: the content split at its H2–H4 headings (`c0`, `c1`…), all editable; * - Elementor: one unit per Heading / Text editor widget (`w:`), text only — the layout * (sections, columns, styles) is never touched. Other widgets are not listed. * `replace_text` (blocks and classic) changes one phrase of a text in place, e.g. to put a link on it. * * CUSTOM FIELDS (ACF): pages that keep their text in custom fields (one page per agent of a network: * content empty, fields `ville`, `presentation`…) are edited the same way, whatever their editor. When ACF * is active and the target is a post, the outline also holds * "fields": { "": { "label": "", "type": "text"|"textarea"|"wysiwyg", "value": "" } } * and, at the END of `units`, one unit per field: * { "id": "f:", "kind": "field", "editable": true, "level": null, "text": "", "length": n } * Listed: the ACF fields of the post (values stored, or empty fields of the groups shown on its edit * screen) of type text, textarea or wysiwyg, named `[a-z0-9_-]{1,40}` (never `_…`), whose value is a * string. Never listed: coordinates and identity (name or label holding a word such as téléphone, e-mail, * adresse, url, site, lien, linkedin, photo, logo, nom, siret, carte… see EXCLUDED_FIELD_PATTERNS; filter * `agentsia_content_excluded_field_patterns`). The final list goes through the filter * `agentsia_content_field_units` ( $fields, $post ), so a site can adjust it without code on our side. * The field values are part of `hash`. A post with no content of its own but with fields is read through * its fields only, even when a template of the theme shows it. * { "op": "set_field", "field": "f:", "text": "..." } (5000 characters at most) * text: one line, `sanitize_text_field()`; textarea: lines kept, tags removed; wysiwyg: plain text whose * paragraphs are separated by a blank line, turned into HTML by `wpautop()` then `wp_kses_post()`. Written * with ACF `update_field()` by the field KEY. set_field mixes with the other operations of a request; as * for them, everything is checked before the first write. Unknown field: 400 `agentsia_content_invalid`. * * `restore_fields` (undo, alone or with `restore_raw`): { "base_hash": "...", "restore_fields": { "": * "" } }. Only a value this plugin itself replaced is accepted (403 `agentsia_content_restore_unknown` * otherwise): set_field keeps the hash of the value it overwrites (`_agentsia_field_versions`, name => last * 20 hashes). A restored value is cleaned like a set_field of that type. * * TEMPLATES (block themes): when what is shown at an address comes from a template of the theme rather * than from a post (home page, archive of a post type, page built in the Site Editor), that template is * read and edited instead — only when it serves that single address (front-page, home, archive-, * page-…); a template shared by several pages is refused. Writing it creates or updates its * `wp_template` post, exactly like the Site Editor (the theme's file is never touched): * POST /wp-json/agentsia/v1/content/template/ (same body) * Divi and other builders are refused (their layout lives in shortcodes inside the text). * * `base_hash` is the hash of the version the agent read: if the page changed since, nothing is written * (409). New HTML goes through `wp_kses_post()`; the page is then saved without kses so that what was * already there (iframes, forms…) survives for an account lacking `unfiltered_html`. * * `restore_raw` (undo) is written as is, so it only accepts a version this plugin itself replaced: the * hash of every version an edit replaced is kept on the post (`_agentsia_content_versions`, last 20). * Without that, any account allowed to edit a post could write arbitrary, unfiltered HTML through it. */ final class Content { const REST_ROUTE = '/content'; private const MAX_OPS = 20; private const MAX_HTML = 60000; private const PREVIEW = 280; private const META_VERSIONS = '_agentsia_content_versions'; private const MAX_VERSIONS = 20; /** Preview length of a container (its blocks are listed after it, with their own text). */ private const CONTAINER_PREVIEW = 80; private const META_FIELD_VERSIONS = '_agentsia_field_versions'; private const MAX_FIELD_TEXT = 5000; /** ACF field types whose value is a text the agent may rewrite. */ private const FIELD_TYPES = [ 'text', 'textarea', 'wysiwyg' ]; /** * Words that make a field coordinates or identity, never listed (matched in its name or its label, case * and accents ignored, word by word, plural included: `telephone_portable`, « Adresse e-mail », `photo2`; a word of 5 * letters or more also matches as the start of a longer one: `linkedinurl`). Filter: * `agentsia_content_excluded_field_patterns`. */ private const EXCLUDED_FIELD_PATTERNS = [ 'tel', 'telephone', 'phone', 'mobile', 'portable', 'fax', 'mail', 'email', 'courriel', 'adresse', 'address', 'url', 'site', 'web', 'lien', 'link', 'linkedin', 'facebook', 'instagram', 'twitter', 'youtube', 'tiktok', 'photo', 'image', 'logo', 'nom', 'name', 'prenom', 'siret', 'siren', 'rsac', 'carte', ]; /** Blocks whose content is text and can be replaced or removed (never descended into). */ private const TEXT_BLOCKS = [ 'core/paragraph', 'core/heading', 'core/list', 'core/quote', 'core/table', 'core/separator', 'core/html', 'core/freeform', 'core/preformatted', 'core/pullquote', 'core/verse', 'core/details', ]; /** Text blocks whose words can be changed in place (`set_text`), keeping their markup. */ private const INLINE_TEXT_BLOCKS = [ 'core/paragraph', 'core/heading' ]; private static ?self $instance = null; public static function instance(): self { if ( self::$instance === null ) { self::$instance = new self(); } return self::$instance; } public function register(): void { add_action( 'rest_api_init', [ $this, 'register_routes' ] ); } public function register_routes(): void { register_rest_route( Seo_Meta::REST_NAMESPACE, self::REST_ROUTE, [ 'methods' => 'GET', 'callback' => [ $this, 'handle_read' ], 'permission_callback' => static function () { return current_user_can( 'edit_posts' ); }, ] ); register_rest_route( Seo_Meta::REST_NAMESPACE, self::REST_ROUTE . '/(?P\d+)', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_write' ], 'permission_callback' => static function ( \WP_REST_Request $request ) { return current_user_can( 'edit_post', (int) $request['id'] ); }, 'args' => [ 'id' => [ 'type' => 'integer', 'required' => true ], ], ] ); register_rest_route( Seo_Meta::REST_NAMESPACE, self::REST_ROUTE . '/template/(?P[a-z0-9_-]+)', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_write_template' ], 'permission_callback' => [ __CLASS__, 'can_edit_templates' ], 'args' => [ 'slug' => [ 'type' => 'string', 'required' => true ], ], ] ); } /** Templates: administrators (Site Editor) and the dedicated Agentsia account. */ public static function can_edit_templates(): bool { return current_user_can( 'edit_theme_options' ) || current_user_can( 'agentsia_seo' ); } /** * @return \WP_REST_Response|\WP_Error */ public function handle_read( \WP_REST_Request $request ) { $target = $this->target_of_request( $request ); if ( is_wp_error( $target ) ) { return $target; } $outline = $this->outline( $target, (bool) $request->get_param( 'include_raw' ) ); return is_wp_error( $outline ) ? $outline : new \WP_REST_Response( $outline, 200 ); } /** * @return \WP_REST_Response|\WP_Error */ public function handle_write( \WP_REST_Request $request ) { $post_id = (int) $request['id']; $post = get_post( $post_id ); if ( ! $post || ! is_post_type_viewable( $post->post_type ) || $post->post_type === 'attachment' ) { return new \WP_Error( 'agentsia_seo_meta_not_found', __( 'Page introuvable.', 'agentsia-agents' ), [ 'status' => 404 ] ); } $target = $this->target_of_post( $post, (string) get_permalink( $post ) ); if ( is_wp_error( $target ) ) { return $target; } if ( $target['kind'] === 'template' ) { // What the visitor sees comes from the template: writing the post would change nothing on screen. return new \WP_Error( 'agentsia_content_in_template', __( 'Le contenu affiché à cette adresse vient d\'un modèle du thème, pas de la page : relis l\'adresse pour modifier le modèle.', 'agentsia-agents' ), [ 'status' => 409 ] ); } return $this->write( $target, $request ); } /** * Template shown at a single address (home page, archive of a post type, template of one page). * * @return \WP_REST_Response|\WP_Error */ public function handle_write_template( \WP_REST_Request $request ) { $slug = (string) $request['slug']; $template = $this->block_template( $slug ); if ( ! $template ) { return new \WP_Error( 'agentsia_seo_meta_not_found', __( 'Modèle introuvable dans le thème actif.', 'agentsia-agents' ), [ 'status' => 404 ] ); } if ( ! $this->is_single_address_template( $slug ) ) { return $this->shared_template_error( $slug ); } return $this->write( $this->template_target( $template, (string) $request->get_param( 'link' ) ), $request ); } /** @return \WP_REST_Response|\WP_Error */ private function write( array $target, \WP_REST_Request $request ) { $params = $request->get_json_params(); $params = is_array( $params ) ? $params : []; $editor = $target['editor']; $raw = $target['raw']; $fields = $target['fields'] ?? []; if ( ! isset( $params['base_hash'] ) || ! is_string( $params['base_hash'] ) || ! hash_equals( $this->hash_of( $target ), $params['base_hash'] ) ) { return new \WP_Error( 'agentsia_content_changed', __( 'La page a changé depuis sa lecture : relis-la avant de la modifier.', 'agentsia-agents' ), [ 'status' => 409 ] ); } $updated = null; // New source of the page; null = its content stays as it is. $field_writes = []; // Field name => new value, already cleaned. if ( isset( $params['restore_raw'] ) || isset( $params['restore_fields'] ) ) { if ( isset( $params['restore_raw'] ) ) { if ( ! is_string( $params['restore_raw'] ) ) { return $this->invalid( __( 'Contenu à restaurer invalide.', 'agentsia-agents' ) ); } if ( ! in_array( md5( $params['restore_raw'] ), $this->versions( $target ), true ) ) { return new \WP_Error( 'agentsia_content_restore_unknown', __( 'Seule une version de la page remplacée par Agentsia peut être restaurée.', 'agentsia-agents' ), [ 'status' => 403 ] ); } $updated = $params['restore_raw']; } if ( isset( $params['restore_fields'] ) ) { $field_writes = $this->restored_fields( $target, $params['restore_fields'] ); if ( is_wp_error( $field_writes ) ) { return $field_writes; } if ( ! $field_writes && $updated === null ) { return $this->invalid( __( 'Aucun champ à restaurer.', 'agentsia-agents' ) ); } } } else { $ops = isset( $params['operations'] ) && is_array( $params['operations'] ) ? $params['operations'] : []; if ( empty( $ops ) || count( $ops ) > self::MAX_OPS ) { return $this->invalid( __( 'Liste d\'opérations vide ou trop longue (20 au plus).', 'agentsia-agents' ) ); } // set_field operations write custom fields; the others change the content, in their order. $content_ops = []; foreach ( $ops as $op ) { if ( ! is_array( $op ) || ( $op['op'] ?? '' ) !== 'set_field' ) { $content_ops[] = $op; continue; } $name = $this->field_name( $fields, $op['field'] ?? null ); if ( is_wp_error( $name ) ) { return $name; } $value = $this->clean_field_value( $fields[ $name ]['type'], $op['text'] ?? null, 'f:' . $name ); if ( is_wp_error( $value ) ) { return $value; } $field_writes[ $name ] = $value; } if ( $content_ops ) { $updated = $editor === 'elementor' ? $this->apply_elementor( $raw, $content_ops ) : ( $editor === 'blocks' ? $this->apply_blocks( $raw, $content_ops ) : $this->apply_units( $raw, $editor, $content_ops ) ); if ( is_wp_error( $updated ) ) { return $updated; } } } // Everything was checked: writing starts here. $saved = null; if ( $updated !== null ) { $saved = $target['kind'] === 'template' ? $this->save_template( $target['template'], $updated ) : $this->save_raw( $target['post'], $editor, $updated ); if ( is_wp_error( $saved ) ) { return $saved; } } if ( $field_writes ) { $this->save_fields( $target, $field_writes ); } if ( $target['kind'] === 'template' ) { $target = $this->template_target( $saved, $target['link'] ); } else { clean_post_cache( $target['post']->ID ); $target = $this->target_of_post( get_post( $target['post']->ID ), $target['link'] ); if ( is_wp_error( $target ) ) { return $target; } } // The version just replaced can be put back later (undo), and only those. if ( $updated !== null ) { $this->remember_version( $target, md5( $raw ) ); } $outline = $this->outline( $target, ! empty( $params['return_raw'] ) ); return is_wp_error( $outline ) ? $outline : new \WP_REST_Response( $outline, 200 ); } // ── Reading ───────────────────────────────────────────────────────────────── /** @return array|\WP_Error */ private function outline( array $target, bool $include_raw ) { $raw = $target['raw']; $units = $target['editor'] === 'elementor' ? $this->elementor_units( $raw ) : $this->units( $raw, $target['editor'] ); $fields = $target['fields'] ?? []; // Custom fields come last, after the content of the page. foreach ( $fields as $name => $field ) { $units[] = $this->unit( 'f:' . $name, 'field', $field['value'], true ); } if ( $target['kind'] === 'template' ) { $template = $target['template']; $out = [ 'id' => (int) $template->wp_id, 'type' => 'wp_template', 'status' => 'publish', 'link' => $target['link'], 'post_title' => (string) $template->title, 'template' => (string) $template->slug, 'template_source' => (string) $template->source, ]; } else { $post = $target['post']; $out = [ 'id' => (int) $post->ID, 'type' => $post->post_type, 'status' => $post->post_status, 'link' => get_permalink( $post->ID ), 'post_title' => get_the_title( $post->ID ), ]; } $out += [ 'editor' => $target['editor'], 'hash' => $this->hash_of( $target ), 'units' => array_map( static function ( $u ) { unset( $u['raw'] ); return $u; }, $units ), 'plugin_version' => AGENTSIA_AGENTS_VERSION, ]; if ( ! empty( $target['acf'] ) ) { $out['fields'] = (object) array_map( static function ( $field ) { return [ 'label' => $field['label'], 'type' => $field['type'], 'value' => $field['value'], ]; }, $fields ); } if ( $include_raw ) { $out['raw'] = $raw; } return $out; } // ── What is shown at an address: a post, or a template of the theme ────────── /** @return array|\WP_Error */ private function target_of_request( \WP_REST_Request $request ) { $post_id = Seo_Meta::resolve_post_id( $request ); $url = trim( (string) $request->get_param( 'url' ) ); if ( ! is_wp_error( $post_id ) ) { $post = get_post( $post_id ); if ( ! current_user_can( 'edit_post', $post_id ) ) { return new \WP_Error( 'agentsia_seo_meta_forbidden', __( 'Ce compte ne peut pas modifier cette page.', 'agentsia-agents' ), [ 'status' => 403 ] ); } return $this->target_of_post( $post, $url !== '' ? $url : (string) get_permalink( $post ) ); } // No post at this address: the home page listing the latest posts, or the archive of a post type. $hierarchy = $url !== '' ? $this->hierarchy_of_url( $url ) : null; $template = $hierarchy ? $this->first_template( $hierarchy ) : null; if ( ! $template ) { return $post_id; } if ( ! $this->is_single_address_template( $template->slug ) ) { return $this->shared_template_error( $template->slug ); } if ( ! self::can_edit_templates() ) { return new \WP_Error( 'agentsia_seo_meta_forbidden', __( 'Ce compte ne peut pas modifier les modèles du thème.', 'agentsia-agents' ), [ 'status' => 403 ] ); } return $this->template_target( $template, $url ); } /** * The post itself, unless what is shown at its address is a template that does not display the post's * content (a home page or a page built in the Site Editor): then that template, if it serves this * address only; a template shared by several pages is refused (one change would hit them all). * * @return array|\WP_Error */ private function target_of_post( \WP_Post $post, string $link ) { $editor = $this->editor_of( $post ); if ( is_wp_error( $editor ) ) { return $editor; } $raw = $this->raw_of( $post, $editor ); $fields = $this->fields_of( $post ); // No content of its own, its text lives in custom fields: edited through them, whatever template shows it. $fields_only = $fields && trim( $raw ) === ''; $template = $fields_only ? null : $this->first_template( $this->hierarchy_of_post( $post ) ); if ( $template && ! has_block( 'core/post-content', (string) $template->content ) ) { if ( ! $this->is_single_address_template( $template->slug, $post ) ) { return $this->shared_template_error( $template->slug ); } if ( ! self::can_edit_templates() ) { return new \WP_Error( 'agentsia_seo_meta_forbidden', __( 'Ce compte ne peut pas modifier les modèles du thème.', 'agentsia-agents' ), [ 'status' => 403 ] ); } return $this->template_target( $template, $link ); } return [ 'kind' => 'post', 'post' => $post, 'editor' => $editor, 'raw' => $raw, 'link' => $link, 'fields' => $fields, 'acf' => self::acf_active(), ]; } // ── Custom fields (ACF) ───────────────────────────────────────────────────── private static function acf_active(): bool { return function_exists( 'get_field_objects' ) && function_exists( 'get_field' ) && function_exists( 'update_field' ); } /** * Text fields of a post the agent may rewrite, in ACF order: name => { key, label, type, value (raw) }. * The fields holding a value, plus the empty fields of the groups shown on the post's edit screen. */ private function fields_of( \WP_Post $post ): array { if ( ! self::acf_active() ) { return []; } $stored = get_field_objects( $post->ID, false ); $stored = is_array( $stored ) ? $stored : []; $objects = []; // In the order of the edit screen; then the fields holding a value outside its groups. if ( function_exists( 'acf_get_field_groups' ) && function_exists( 'acf_get_fields' ) ) { foreach ( (array) acf_get_field_groups( [ 'post_id' => $post->ID, 'post_type' => $post->post_type ] ) as $group ) { foreach ( (array) acf_get_fields( $group ) as $field ) { $name = is_array( $field ) ? (string) ( $field['name'] ?? '' ) : ''; if ( $name === '' || isset( $objects[ $name ] ) || empty( $field['key'] ) ) { continue; } $field['value'] = isset( $stored[ $name ] ) ? ( $stored[ $name ]['value'] ?? null ) : get_field( $field['key'], $post->ID, false ); $objects[ $name ] = $field; } } } $objects += $stored; $patterns = apply_filters( 'agentsia_content_excluded_field_patterns', self::EXCLUDED_FIELD_PATTERNS ); $patterns = is_array( $patterns ) ? $patterns : self::EXCLUDED_FIELD_PATTERNS; $fields = []; foreach ( $objects as $key => $object ) { if ( ! is_array( $object ) ) { continue; } $field = $this->field_entry( (string) ( $object['name'] ?? $key ), $object ); if ( $field && ! $this->is_excluded_field( $field['name'], $field['label'], $patterns ) ) { $fields[ $field['name'] ] = $field['entry']; } } $fields = apply_filters( 'agentsia_content_field_units', $fields, $post ); // A filter may remove, relabel or add fields: what it returns is checked again (written by its ACF key). $checked = []; foreach ( is_array( $fields ) ? $fields : [] as $name => $entry ) { $field = is_array( $entry ) ? $this->field_entry( (string) $name, $entry ) : null; if ( $field ) { $checked[ $field['name'] ] = $field['entry']; } } return $checked; } /** * A field the agent may rewrite: text, textarea or wysiwyg, valid name, ACF key, text value (none = ''). * * @return array{name:string,label:string,entry:array}|null */ private function field_entry( string $name, array $object ): ?array { $type = (string) ( $object['type'] ?? '' ); $value = $object['value'] ?? ''; $value = $value === null || $value === false ? '' : $value; $key = $object['key'] ?? ''; if ( ! preg_match( '/^[a-z0-9_-]{1,40}$/', $name ) || $name[0] === '_' || ! in_array( $type, self::FIELD_TYPES, true ) || ! is_string( $value ) || ! is_string( $key ) || $key === '' ) { return null; } $label = is_string( $object['label'] ?? null ) ? $object['label'] : ''; return [ 'name' => $name, 'label' => $label, 'entry' => [ 'key' => $key, 'label' => $label, 'type' => $type, 'value' => $value, ], ]; } /** Coordinates or identity (phone, e-mail, address, links, photo, name, registration…): never listed. */ private function is_excluded_field( string $name, string $label, array $patterns ): bool { $texts = [ self::words_of( $name ), self::words_of( $label ) ]; foreach ( $patterns as $pattern ) { $wanted = is_string( $pattern ) ? self::words_of( $pattern ) : []; if ( ! $wanted ) { continue; } foreach ( $texts as $words ) { if ( self::has_words( $words, $wanted ) ) { return true; } } } return false; } /** Lower-case words without accents: « Téléphone portable » → telephone, portable; `tel2` → tel. */ private static function words_of( string $text ): array { $text = (string) preg_replace( '/([a-z])([A-Z])/', '$1 $2', $text ); $text = strtolower( remove_accents( $text ) ); return preg_split( '/[^a-z]+/', $text, -1, PREG_SPLIT_NO_EMPTY ); } /** * The words of a pattern, one after the other: each the same word, or its plural; the last one may also * start a longer word when it has 5 letters or more (`linkedin` in `linkedinurl`, not `nom` in `nombre`). */ private static function has_words( array $words, array $wanted ): bool { $n = count( $wanted ); $last = $wanted[ $n - 1 ]; for ( $i = 0; $i + $n <= count( $words ); $i++ ) { $match = true; for ( $j = 0; $j < $n && $match; $j++ ) { $word = $words[ $i + $j ]; $match = $word === $wanted[ $j ] || $word === $wanted[ $j ] . 's' || ( $j === $n - 1 && strlen( $last ) >= 5 && strpos( $word, $last ) === 0 ); } if ( $match ) { return true; } } return false; } /** @return string|\WP_Error Name of a field listed by the outline, from its unit id `f:`. */ private function field_name( array $fields, $id ) { $id = is_string( $id ) ? $id : ''; $name = strpos( $id, 'f:' ) === 0 ? substr( $id, 2 ) : ''; if ( $name === '' || ! isset( $fields[ $name ] ) ) { return $this->not_found( $id ); } return $name; } /** * New value of a field, cleaned for its type. text: one line; textarea: lines kept, no tags; wysiwyg: * plain text with blank-line separated paragraphs, turned into paragraphs of HTML. * * @return string|\WP_Error */ private function clean_field_value( string $type, $text, string $id ) { if ( ! is_string( $text ) ) { /* translators: %s: unit id. */ return $this->invalid( sprintf( __( 'Texte du champ %s invalide.', 'agentsia-agents' ), $id ) ); } $text = wp_check_invalid_utf8( $text ); if ( mb_strlen( $text ) > self::MAX_FIELD_TEXT ) { /* translators: 1: unit id, 2: maximum number of characters. */ return $this->invalid( sprintf( __( 'Texte du champ %1$s trop long (%2$d caractères au plus).', 'agentsia-agents' ), $id, self::MAX_FIELD_TEXT ) ); } if ( $type === 'text' ) { return sanitize_text_field( (string) preg_replace( '/[\r\n]+/', ' ', $text ) ); } if ( $type === 'textarea' ) { return sanitize_textarea_field( $text ); } return trim( $text ) === '' ? '' : trim( wp_kses_post( wpautop( $text ) ) ); } /** * Values of `restore_fields`, cleaned: only values of a listed field that this plugin itself replaced. * * @return array|\WP_Error */ private function restored_fields( array $target, $values ) { if ( ! is_array( $values ) ) { return $this->invalid( __( 'Champs à restaurer invalides.', 'agentsia-agents' ) ); } $fields = $target['fields'] ?? []; $known = $target['kind'] === 'post' ? $this->field_versions( (int) $target['post']->ID ) : []; $writes = []; foreach ( $values as $name => $value ) { $name = (string) $name; $id = 'f:' . $name; if ( ! isset( $fields[ $name ] ) ) { return $this->not_found( $id ); } if ( ! is_string( $value ) ) { /* translators: %s: unit id. */ return $this->invalid( sprintf( __( 'Valeur à restaurer du champ %s invalide.', 'agentsia-agents' ), $id ) ); } if ( ! in_array( md5( $value ), $known[ $name ] ?? [], true ) ) { return new \WP_Error( 'agentsia_content_restore_unknown', /* translators: %s: unit id. */ sprintf( __( 'Seule une valeur du champ %s remplacée par Agentsia peut être restaurée.', 'agentsia-agents' ), $id ), [ 'status' => 403 ] ); } $clean = $this->clean_field_value( $fields[ $name ]['type'], $value, $id ); if ( is_wp_error( $clean ) ) { return $clean; } $writes[ $name ] = $clean; } return $writes; } /** * Writes fields with ACF, by their key (so the `_` reference is written too, as from the editor), * and keeps the hash of each value replaced: it can be put back later (restore_fields), and only those. */ private function save_fields( array $target, array $writes ): void { $post_id = (int) $target['post']->ID; $fields = $target['fields']; $versions = $this->field_versions( $post_id ); foreach ( $writes as $name => $value ) { $previous = (string) $fields[ $name ]['value']; // update_metadata() unslashes what it stores, like ACF expects from its own form. update_field( $fields[ $name ]['key'], wp_slash( $value ), $post_id ); if ( $previous === $value ) { continue; } $hash = md5( $previous ); $kept = array_values( array_diff( $versions[ $name ] ?? [], [ $hash ] ) ); $kept[] = $hash; $versions[ $name ] = array_slice( $kept, -self::MAX_VERSIONS ); } update_post_meta( $post_id, self::META_FIELD_VERSIONS, $versions ); } /** @return array Field name => hashes of the values this plugin replaced, most recent last. */ private function field_versions( int $post_id ): array { $stored = get_post_meta( $post_id, self::META_FIELD_VERSIONS, true ); $out = []; foreach ( is_array( $stored ) ? $stored : [] as $name => $hashes ) { if ( is_array( $hashes ) ) { $out[ (string) $name ] = array_values( array_filter( $hashes, 'is_string' ) ); } } return $out; } /** Hash of what the agent read: the source of the page, and the values of its fields when it has some. */ private function hash_of( array $target ): string { $fields = $target['fields'] ?? []; if ( ! $fields ) { return md5( $target['raw'] ); } $values = []; foreach ( $fields as $name => $field ) { $values[ (string) $name ] = $field['value']; } ksort( $values, SORT_STRING ); return md5( $target['raw'] . "\0" . wp_json_encode( $values ) ); } private function template_target( \WP_Block_Template $template, string $link ): array { return [ 'kind' => 'template', 'template' => $template, 'editor' => 'blocks', 'raw' => (string) $template->content, 'link' => $link !== '' ? $link : home_url( '/' ), ]; } /** Template slugs WordPress tries for a post, most specific first (block themes only). */ private function hierarchy_of_post( \WP_Post $post ): array { if ( ! function_exists( 'wp_is_block_theme' ) || ! wp_is_block_theme() ) { return []; } $custom = (string) get_page_template_slug( $post ); $slugs = $custom !== '' ? [ $custom ] : []; if ( (int) get_option( 'page_for_posts' ) === $post->ID ) { return [ 'home', 'index' ]; } if ( get_option( 'show_on_front' ) === 'page' && (int) get_option( 'page_on_front' ) === $post->ID ) { array_unshift( $slugs, 'front-page' ); } if ( $post->post_type === 'page' ) { $slugs = array_merge( $slugs, [ 'page-' . $post->post_name, 'page-' . $post->ID, 'page' ] ); } else { $slugs = array_merge( $slugs, [ 'single-' . $post->post_type . '-' . $post->post_name, 'single-' . $post->post_type, 'single' ] ); } return array_merge( $slugs, [ 'singular', 'index' ] ); } /** Template slugs for an address without a post: home page of latest posts, archive of a post type. */ private function hierarchy_of_url( string $url ): ?array { if ( ! function_exists( 'wp_is_block_theme' ) || ! wp_is_block_theme() ) { return null; } $path = static function ( string $u ): string { return rtrim( (string) wp_parse_url( $u, PHP_URL_PATH ), '/' ); }; $asked = $path( $url ); if ( $asked === $path( home_url( '/' ) ) && get_option( 'show_on_front' ) !== 'page' ) { return [ 'front-page', 'home', 'index' ]; } foreach ( get_post_types( [ 'has_archive' => true ], 'names' ) as $type ) { $archive = get_post_type_archive_link( $type ); if ( $archive && $path( $archive ) === $asked ) { return [ 'archive-' . $type, 'archive', 'index' ]; } } return null; } /** * The block template WordPress renders for these slugs, most specific first. A PHP template of the theme * met on the way wins over the less specific block templates (WordPress keeps the PHP one, see * locate_block_template()): a theme with single.php and index.html shows its posts with single.php. */ private function first_template( array $slugs ): ?\WP_Block_Template { foreach ( $slugs as $slug ) { $template = $this->block_template( $slug ); if ( $template ) { return $template; } $file = substr( $slug, -4 ) === '.php' ? $slug : $slug . '.php'; if ( locate_template( $file ) !== '' ) { return null; } } return null; } private function block_template( string $slug ): ?\WP_Block_Template { if ( ! function_exists( 'get_block_template' ) ) { return null; } $template = get_block_template( get_stylesheet() . '//' . $slug, 'wp_template' ); return $template instanceof \WP_Block_Template ? $template : null; } /** * A template that serves one address only: home page, posts page, archive of one post type, template * of one page or one post, or a custom template assigned to a single post. */ private function is_single_address_template( string $slug, ?\WP_Post $post = null ): bool { if ( in_array( $slug, [ 'front-page', 'home' ], true ) || strpos( $slug, 'archive-' ) === 0 ) { return true; } if ( $post && in_array( $slug, [ 'page-' . $post->post_name, 'page-' . $post->ID, 'single-' . $post->post_type . '-' . $post->post_name ], true ) ) { return true; } if ( preg_match( '/^(index|page|single|singular|archive|search|404|category|tag|taxonomy|author|date)$/', $slug ) || strpos( $slug, 'single-' ) === 0 ) { return false; } // Custom template (Site Editor or theme): single address only if assigned to one post at most. $users = get_posts( [ 'post_type' => 'any', 'post_status' => 'any', 'meta_key' => '_wp_page_template', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_value' => $slug, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'fields' => 'ids', 'posts_per_page' => 2, ] ); return count( $users ) <= 1 && ( strpos( $slug, 'page-' ) === 0 || count( $users ) === 1 ); } private function shared_template_error( string $slug ): \WP_Error { return new \WP_Error( 'agentsia_content_shared_template', /* translators: %s: template slug. */ sprintf( __( 'Le contenu affiché vient du modèle « %s », partagé par plusieurs pages : une modification les changerait toutes. À modifier dans l\'Éditeur de site.', 'agentsia-agents' ), $slug ), [ 'status' => 422 ] ); } /** @return string|\WP_Error 'blocks' | 'classic' | 'elementor' */ private function editor_of( \WP_Post $post ) { if ( get_post_meta( $post->ID, '_elementor_edit_mode', true ) === 'builder' ) { return 'elementor'; } $content = (string) $post->post_content; if ( preg_match( '/\[(et_pb_|vc_row|fusion_|av_|cs_|fl_builder)/', $content ) ) { return new \WP_Error( 'agentsia_content_builder_unsupported', __( 'Page construite avec Divi, WPBakery ou un constructeur équivalent : sa mise en page est mêlée au texte, la modification ciblée n\'est pas possible.', 'agentsia-agents' ), [ 'status' => 422 ] ); } return has_blocks( $content ) ? 'blocks' : 'classic'; } private function raw_of( \WP_Post $post, string $editor ): string { return $editor === 'elementor' ? (string) get_post_meta( $post->ID, '_elementor_data', true ) : (string) $post->post_content; } /** Units of a block or classic page, in order (a container comes before the blocks it holds). */ private function units( string $raw, string $editor ): array { $units = []; if ( $editor === 'blocks' ) { $this->block_units( $this->block_tree( $raw ), $units ); return $units; } $parts = preg_split( '#(]*>.*?)#is', $raw, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY ); $i = 0; foreach ( (array) $parts as $part ) { if ( trim( $part ) === '' ) { continue; } $is_heading = (bool) preg_match( '#^\s*unit( 'c' . $i, $is_heading ? 'heading' : 'text', $part, true ); $i++; } return $units; } private function block_units( array $blocks, array &$units ): void { foreach ( $blocks as $block ) { $container = $this->is_container( $block ); $units[] = $this->unit( $block['agentsia_id'], $this->kind_of( $block ), serialize_block( $block ), $this->is_text( $block ), $container ); if ( $container ) { $this->block_units( $block['innerBlocks'], $units ); } } } /** * Blocks of a page, each tagged with its unit id (`agentsia_id`, ignored by serialize_block()). * Whitespace between top-level blocks is dropped, as the page is saved with blank lines between them. */ private function block_tree( string $raw ): array { $blocks = []; foreach ( parse_blocks( $raw ) as $block ) { if ( $block['blockName'] === null && trim( (string) $block['innerHTML'] ) === '' ) { continue; } $blocks[] = $block; } return $this->tag_ids( $blocks, '' ); } private function tag_ids( array $blocks, string $parent ): array { foreach ( array_keys( $blocks ) as $i ) { $id = $parent === '' ? 'b' . $i : $parent . '.' . $i; $blocks[ $i ]['agentsia_id'] = $id; if ( $this->is_container( $blocks[ $i ] ) ) { $blocks[ $i ]['innerBlocks'] = $this->tag_ids( $blocks[ $i ]['innerBlocks'], $id ); } } return $blocks; } private function kind_of( array $block ): string { return $block['blockName'] ?? 'core/freeform'; } private function is_text( array $block ): bool { return in_array( $this->kind_of( $block ), self::TEXT_BLOCKS, true ); } /** Layout block holding other blocks (group, columns, column, cover…): listed, its blocks too. */ private function is_container( array $block ): bool { return ! $this->is_text( $block ) && ! empty( $block['innerBlocks'] ); } private function unit( string $id, string $kind, string $html, bool $editable, bool $container = false ): array { $level = preg_match( '#]#i', $html, $m ) && in_array( $kind, [ 'core/heading', 'heading' ], true ) ? (int) $m[1] : null; $text = trim( (string) preg_replace( '/\s+/u', ' ', html_entity_decode( wp_strip_all_tags( $html ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ) ); // Element without text (map, video, form, image): say what it is, so it is not taken for an empty one. $embed = $text === '' && preg_match( '#<(iframe|img|video|form|audio|embed|object|svg)\b#i', $html, $tag ) ? '[' . strtolower( $tag[1] ) . ']' : null; $unit = [ 'id' => $id, 'kind' => $kind, 'editable' => $editable, 'level' => $level, 'text' => $embed ?? mb_substr( $text, 0, $container ? self::CONTAINER_PREVIEW : self::PREVIEW ), 'length' => mb_strlen( $text ), 'raw' => $html, ]; if ( $container ) { $unit['container'] = true; } return $unit; } /** Heading and Text editor widgets of an Elementor page (layout elements are skipped). */ private function elementor_units( string $raw ): array { $data = json_decode( $raw, true ); $units = []; $walk = function ( $elements ) use ( &$walk, &$units ) { foreach ( (array) $elements as $el ) { if ( ! is_array( $el ) ) { continue; } if ( ( $el['elType'] ?? '' ) === 'widget' && in_array( $el['widgetType'] ?? '', [ 'heading', 'text-editor' ], true ) ) { $is_heading = $el['widgetType'] === 'heading'; $html = (string) ( $is_heading ? ( $el['settings']['title'] ?? '' ) : ( $el['settings']['editor'] ?? '' ) ); $unit = $this->unit( 'w:' . ( $el['id'] ?? '' ), $is_heading ? 'elementor/heading' : 'elementor/text', $html, true ); $size = $el['settings']['header_size'] ?? 'h2'; $unit['level'] = $is_heading && preg_match( '/^h([1-6])$/', (string) $size, $m ) ? (int) $m[1] : null; $units[] = $unit; } if ( ! empty( $el['elements'] ) ) { $walk( $el['elements'] ); } } }; $walk( is_array( $data ) ? $data : [] ); return $units; } // ── Writing ───────────────────────────────────────────────────────────────── /** @return string|\WP_Error New source of a classic page. */ private function apply_units( string $raw, string $editor, array $ops ) { $units = $this->units( $raw, $editor ); foreach ( $ops as $op ) { if ( ! is_array( $op ) ) { return $this->invalid( __( 'Opération invalide.', 'agentsia-agents' ) ); } $kind = $op['op'] ?? ''; if ( $kind === 'replace' || $kind === 'delete' ) { $range = $this->range( $units, (string) ( $op['from'] ?? '' ), (string) ( $op['to'] ?? ( $op['from'] ?? '' ) ) ); if ( is_wp_error( $range ) ) { return $range; } [ $from, $to ] = $range; for ( $k = $from; $k <= $to; $k++ ) { if ( empty( $units[ $k ]['editable'] ) ) { /* translators: %s: unit id. */ return $this->invalid( sprintf( __( 'L\'élément %s est un bloc de mise en page : il ne peut être ni remplacé ni supprimé.', 'agentsia-agents' ), $units[ $k ]['id'] ) ); } } $new = []; if ( $kind === 'replace' ) { $html = $this->clean_html( $op['html'] ?? null ); if ( is_wp_error( $html ) ) { return $html; } $new[] = [ 'id' => null, 'editable' => true, 'raw' => $html ]; } array_splice( $units, $from, $to - $from + 1, $new ); } elseif ( $kind === 'replace_text' ) { $id = (string) ( $op['unit'] ?? '' ); if ( $id === '' ) { $markups = []; foreach ( $units as $u ) { if ( $u['id'] !== null ) { $markups[ $u['id'] ] = (string) $u['raw']; } } $id = $this->unit_with_phrase( $markups, $op['find'] ?? null ); if ( is_wp_error( $id ) ) { return $id; } } $pos = $this->position( $units, $id ); if ( $pos === null ) { return $this->not_found( $id ); } $markup = $this->replace_text( (string) $units[ $pos ]['raw'], $op['find'] ?? null, $op['html'] ?? null, $id ); if ( is_wp_error( $markup ) ) { return $markup; } $units[ $pos ]['raw'] = $markup; } elseif ( $kind === 'insert_after' ) { $after = (string) ( $op['after'] ?? '' ); $pos = $after === 'start' ? -1 : $this->position( $units, $after ); if ( $pos === null ) { /* translators: %s: unit id. */ return $this->invalid( sprintf( __( 'Élément %s introuvable.', 'agentsia-agents' ), $after ) ); } $html = $this->clean_html( $op['html'] ?? null ); if ( is_wp_error( $html ) ) { return $html; } array_splice( $units, $pos + 1, 0, [ [ 'id' => null, 'editable' => true, 'raw' => $html ] ] ); } else { /* translators: %s: operation name. */ return $this->invalid( sprintf( __( 'Opération %s non prise en charge sur cette page.', 'agentsia-agents' ), (string) $kind ) ); } } $glue = $editor === 'blocks' ? "\n\n" : ''; return implode( $glue, array_map( static fn( $u ) => (string) $u['raw'], $units ) ); } /** * @return string|\WP_Error New source of a block page. Ids name the blocks as read: a block added by an * earlier operation has none, a removed one is no longer found. */ private function apply_blocks( string $raw, array $ops ) { $blocks = $this->block_tree( $raw ); foreach ( $ops as $op ) { if ( ! is_array( $op ) ) { return $this->invalid( __( 'Opération invalide.', 'agentsia-agents' ) ); } $blocks = $this->apply_block_op( $blocks, $op ); if ( is_wp_error( $blocks ) ) { return $blocks; } } return implode( "\n\n", array_map( 'serialize_block', $blocks ) ); } /** @return array|\WP_Error */ private function apply_block_op( array $blocks, array $op ) { $kind = (string) ( $op['op'] ?? '' ); switch ( $kind ) { case 'replace': case 'delete': $from = $this->find_path( $blocks, (string) ( $op['from'] ?? '' ) ); $to = $this->find_path( $blocks, (string) ( $op['to'] ?? ( $op['from'] ?? '' ) ) ); if ( $from === null || $to === null || array_slice( $from, 0, -1 ) !== array_slice( $to, 0, -1 ) || end( $to ) < end( $from ) ) { return $this->invalid( __( 'Plage d\'éléments invalide (from / to) : deux éléments du même niveau, dans l\'ordre.', 'agentsia-agents' ) ); } $new = []; if ( $kind === 'replace' ) { $new = $this->fragment_blocks( $op['html'] ?? null ); if ( is_wp_error( $new ) ) { return $new; } } $a = end( $from ); $b = end( $to ); return $this->with_children( $blocks, array_slice( $from, 0, -1 ), function ( array $children ) use ( $a, $b, $new, $from ) { for ( $k = $a; $k <= $b; $k++ ) { if ( ! $this->is_text( $children[ $k ] ) ) { /* translators: %s: unit id. */ return $this->invalid( sprintf( __( 'L\'élément %s est un bloc de mise en page : il ne peut être ni remplacé ni supprimé.', 'agentsia-agents' ), $children[ $k ]['agentsia_id'] ?? '?' ) ); } } if ( count( $from ) > 1 ) { $new = $this->inherit_classes( $new, array_merge( array_slice( $children, $a, $b - $a + 1 ), $children ) ); } array_splice( $children, $a, $b - $a + 1, $new ); return $children; } ); case 'insert_after': $after = (string) ( $op['after'] ?? '' ); $path = $after === 'start' ? [ -1 ] : $this->find_path( $blocks, $after ); if ( $path === null ) { return $this->not_found( $after ); } $new = $this->fragment_blocks( $op['html'] ?? null ); if ( is_wp_error( $new ) ) { return $new; } $at = end( $path ) + 1; $nested = count( $path ) > 1; return $this->with_children( $blocks, array_slice( $path, 0, -1 ), function ( array $children ) use ( $at, $new, $nested ) { array_splice( $children, $at, 0, $nested ? $this->inherit_classes( $new, $children ) : $new ); return $children; } ); case 'insert_into': $id = (string) ( $op['container'] ?? '' ); $path = $this->find_path( $blocks, $id ); if ( $path === null ) { return $this->not_found( $id ); } if ( ! $this->is_container( $this->block_at( $blocks, $path ) ) ) { /* translators: %s: unit id. */ return $this->invalid( sprintf( __( 'L\'élément %s ne contient pas d\'autres blocs : insère après lui (insert_after).', 'agentsia-agents' ), $id ) ); } $new = $this->fragment_blocks( $op['html'] ?? null ); if ( is_wp_error( $new ) ) { return $new; } $at_start = ( $op['at'] ?? 'end' ) === 'start'; return $this->with_children( $blocks, $path, function ( array $children ) use ( $at_start, $new ) { $new = $this->inherit_classes( $new, $children ); return $at_start ? array_merge( $new, $children ) : array_merge( $children, $new ); } ); case 'set_text': $id = (string) ( $op['unit'] ?? '' ); $path = $this->find_path( $blocks, $id ); if ( $path === null ) { return $this->not_found( $id ); } return $this->set_text_at( $blocks, $path, $op['html'] ?? null ); case 'duplicate': return $this->duplicate( $blocks, (string) ( $op['from'] ?? '' ), $op['texts'] ?? null ); case 'replace_text': $id = (string) ( $op['unit'] ?? '' ); if ( $id === '' ) { $id = $this->unit_with_phrase( $this->text_markups( $blocks ), $op['find'] ?? null ); if ( is_wp_error( $id ) ) { return $id; } } $path = $this->find_path( $blocks, $id ); if ( $path === null ) { return $this->not_found( $id ); } $block = $this->block_at( $blocks, $path ); if ( ! $this->is_text( $block ) ) { /* translators: %s: unit id. */ return $this->invalid( sprintf( __( 'L\'élément %s n\'est pas un texte (bouton, image, mise en page) : il ne se modifie pas.', 'agentsia-agents' ), $id ) ); } $markup = $this->replace_text( serialize_block( $block ), $op['find'] ?? null, $op['html'] ?? null, $id ); if ( is_wp_error( $markup ) ) { return $markup; } $parsed = array_values( array_filter( parse_blocks( $markup ), static function ( $b ) { return $b['blockName'] !== null || trim( (string) $b['innerHTML'] ) !== ''; } ) ); if ( count( $parsed ) !== 1 ) { /* translators: %s: unit id. */ return $this->invalid( sprintf( __( 'Le texte de %s n\'a pas pu être modifié sans toucher à sa structure.', 'agentsia-agents' ), $id ) ); } $parsed[0]['agentsia_id'] = $id; $i = end( $path ); return $this->with_children( $blocks, array_slice( $path, 0, -1 ), static function ( array $children ) use ( $i, $parsed ) { $children[ $i ] = $parsed[0]; return $children; } ); } /* translators: %s: operation name. */ return $this->invalid( sprintf( __( 'Opération %s non prise en charge sur cette page.', 'agentsia-agents' ), $kind ) ); } /** * Copies a container right after itself, with new texts: every paragraph and heading of the copy must * get one (`texts`: [{ unit: id in the source, html }]), so that no text ends up twice on the page. * Inline HTML for a paragraph / heading (its markup is kept), block markup for another text block. * * @return array|\WP_Error */ private function duplicate( array $blocks, string $id, $texts ) { $path = $this->find_path( $blocks, $id ); if ( $path === null ) { return $this->not_found( $id ); } $source = $this->block_at( $blocks, $path ); if ( ! $this->is_container( $source ) ) { /* translators: %s: unit id. */ return $this->invalid( sprintf( __( 'Seul un bloc de mise en page qui contient d\'autres blocs (groupe, colonne…) se duplique ; %s n\'en est pas un.', 'agentsia-agents' ), $id ) ); } $given = []; foreach ( is_array( $texts ) ? $texts : [] as $text ) { if ( is_array( $text ) && isset( $text['unit'] ) ) { $given[ (string) $text['unit'] ] = $text['html'] ?? null; } } $expected = $this->text_ids( [ $source ] ); $missing = array_diff( $expected, array_keys( $given ) ); $unknown = array_diff( array_keys( $given ), $expected ); if ( $missing || $unknown ) { return $this->invalid( sprintf( /* translators: 1: ids without a new text, 2: ids not in the copied block. */ __( 'Duplication : chaque texte du bloc copié doit recevoir un nouveau texte. Manquants : %1$s. Hors du bloc : %2$s.', 'agentsia-agents' ), $missing ? implode( ', ', $missing ) : '-', $unknown ? implode( ', ', $unknown ) : '-' ) ); } $copy = [ $source ]; foreach ( $given as $unit_id => $html ) { $inner = $this->find_path( $copy, $unit_id ); if ( in_array( $this->kind_of( $this->block_at( $copy, $inner ) ), self::INLINE_TEXT_BLOCKS, true ) ) { $copy = $this->set_text_at( $copy, $inner, $html ); } else { $new = $this->fragment_blocks( $html ); $copy = is_wp_error( $new ) ? $new : $this->with_children( $copy, array_slice( $inner, 0, -1 ), function ( array $children ) use ( $inner, $new ) { $i = end( $inner ); array_splice( $children, $i, 1, $this->inherit_classes( $new, array_merge( [ $children[ $i ] ], $children ) ) ); return $children; } ); } if ( is_wp_error( $copy ) ) { return $copy; } } // The copy is a new block: its ids must not catch later operations aimed at the source. $copy = $this->untag( $copy ); $at = end( $path ) + 1; return $this->with_children( $blocks, array_slice( $path, 0, -1 ), static function ( array $children ) use ( $at, $copy ) { array_splice( $children, $at, 0, $copy ); return $children; } ); } /** * New words for a paragraph or heading: only what sits inside its element changes; the tag, classes, * style and block attributes stay (a heading styled by the theme stays styled). * * @return array|\WP_Error */ private function set_text_at( array $blocks, array $path, $html ) { $block = $this->block_at( $blocks, $path ); if ( ! in_array( $this->kind_of( $block ), self::INLINE_TEXT_BLOCKS, true ) ) { return $this->invalid( $this->is_text( $block ) /* translators: %s: unit id. */ ? sprintf( __( 'set_text ne vaut que pour un paragraphe ou un titre (%s n\'en est pas un) : utilise replace.', 'agentsia-agents' ), $block['agentsia_id'] ?? '?' ) /* translators: %s: unit id. */ : sprintf( __( 'L\'élément %s n\'est pas un texte (bouton, image, mise en page) : il ne se modifie pas.', 'agentsia-agents' ), $block['agentsia_id'] ?? '?' ) ); } $html = $this->clean_inline( $html ); if ( is_wp_error( $html ) ) { return $html; } if ( ! preg_match( '#^(\s*<(p|h[1-6])\b[^>]*>)(.*)(\s*)$#is', (string) $block['innerHTML'], $m ) ) { /* translators: %s: unit id. */ return $this->invalid( sprintf( __( 'Balisage inattendu pour %s : utilise replace.', 'agentsia-agents' ), $block['agentsia_id'] ?? '?' ) ); } $block['innerHTML'] = $m[1] . $html . $m[4]; $block['innerContent'] = [ $block['innerHTML'] ]; $i = end( $path ); return $this->with_children( $blocks, array_slice( $path, 0, -1 ), static function ( array $children ) use ( $i, $block ) { $children[ $i ] = $block; return $children; } ); } /** * Applies $change to the list of blocks at $path (empty path = top of the page, [i] = blocks of the i-th * block…). When the number of blocks changes, the parent's markup is rebuilt around the new list. * * @return array|\WP_Error */ private function with_children( array $blocks, array $path, callable $change ) { if ( empty( $path ) ) { return $change( $blocks ); } $i = array_shift( $path ); if ( ! isset( $blocks[ $i ] ) ) { return $this->invalid( __( 'Élément introuvable.', 'agentsia-agents' ) ); } $parent = $blocks[ $i ]; $children = $this->with_children( (array) ( $parent['innerBlocks'] ?? [] ), $path, $change ); if ( is_wp_error( $children ) ) { return $children; } $parent = empty( $path ) ? $this->rebuild_container( $parent, $children ) : array_merge( $parent, [ 'innerBlocks' => $children ] ); if ( is_wp_error( $parent ) ) { return $parent; } $blocks[ $i ] = $parent; return $blocks; } /** * A container's markup lists its blocks as `null` slots in `innerContent`, separated by whitespace. A * different number of blocks means rebuilding that run of slots — only when nothing but whitespace * sits between them (otherwise the block's own markup would be cut). * * @return array|\WP_Error */ private function rebuild_container( array $block, array $children ) { $content = (array) ( $block['innerContent'] ?? [] ); $slots = array_keys( $content, null, true ); $id = $block['agentsia_id'] ?? $this->kind_of( $block ); if ( count( $children ) === count( $slots ) ) { $block['innerBlocks'] = $children; return $block; } if ( empty( $children ) ) { /* translators: %s: unit id. */ return $this->invalid( sprintf( __( 'Le bloc de mise en page %s ne peut pas être vidé.', 'agentsia-agents' ), $id ) ); } if ( empty( $slots ) ) { /* translators: %s: unit id. */ return $this->invalid( sprintf( __( 'Le bloc %s est vide : rien ne peut y être ajouté sans toucher à sa mise en page.', 'agentsia-agents' ), $id ) ); } $first = $slots[0]; $last = end( $slots ); $sep = null; for ( $k = $first + 1; $k < $last; $k++ ) { if ( is_string( $content[ $k ] ) ) { if ( trim( $content[ $k ] ) !== '' ) { /* translators: %s: unit id. */ return $this->invalid( sprintf( __( 'La structure du bloc %s ne permet ni ajout ni retrait à l\'intérieur : modifie ses textes un par un.', 'agentsia-agents' ), $id ) ); } $sep = $sep ?? $content[ $k ]; } } $sep = $sep ?? "\n\n"; $inner = []; foreach ( array_keys( $children ) as $k ) { if ( $k > 0 ) { $inner[] = $sep; } $inner[] = null; } $block['innerContent'] = array_merge( array_slice( $content, 0, $first ), $inner, array_slice( $content, $last + 1 ) ); $block['innerHTML'] = implode( '', array_filter( $block['innerContent'], 'is_string' ) ); $block['innerBlocks'] = array_values( $children ); return $block; } /** * New text blocks placed inside a layout take the classes of the block they replace, or of a neighbour * of the same type (a paragraph of an FAQ answer keeps `agent-faq__a`, so the theme styles it alike). * Only `className` is carried over; a block that already has classes keeps its own. */ private function inherit_classes( array $new, array $models ): array { foreach ( array_keys( $new ) as $k ) { $block = $new[ $k ]; if ( ! in_array( $this->kind_of( $block ), [ 'core/paragraph', 'core/heading', 'core/list' ], true ) || ! empty( $block['attrs']['className'] ) ) { continue; } foreach ( $models as $model ) { $class = (string) ( $model['attrs']['className'] ?? '' ); if ( $class === '' || $this->kind_of( $model ) !== $this->kind_of( $block ) || $this->heading_level( $model ) !== $this->heading_level( $block ) ) { continue; } $first = array_search( true, array_map( 'is_string', (array) $block['innerContent'] ), true ); if ( $first === false ) { break; } $tags = new \WP_HTML_Tag_Processor( (string) $block['innerContent'][ $first ] ); if ( ! $tags->next_tag() ) { break; } foreach ( preg_split( '/\s+/', trim( $class ) ) as $name ) { $tags->add_class( $name ); } $block['innerContent'][ $first ] = $tags->get_updated_html(); $block['innerHTML'] = implode( '', array_filter( $block['innerContent'], 'is_string' ) ); $block['attrs']['className'] = $class; $new[ $k ] = $block; break; } } return $new; } private function heading_level( array $block ): ?int { return $this->kind_of( $block ) === 'core/heading' ? (int) ( $block['attrs']['level'] ?? 2 ) : null; } /** * Replaces ONE occurrence of a phrase of the visible text by new inline HTML — typically to put a link * on words already there (internal linking) without rewriting the block. The phrase is looked for in * the text between tags (typographic apostrophes and non-breaking spaces count as plain ones); it must * appear exactly once, outside any link, within a single run of text (not across bold or a link). * * @return string|\WP_Error */ private function replace_text( string $markup, $find, $html, string $id ) { $pattern = $this->phrase_pattern( $find ); if ( is_wp_error( $pattern ) ) { return $pattern; } $html = $this->clean_inline( $html ); if ( is_wp_error( $html ) ) { return $html; } [ $parts, $hits ] = $this->phrase_hits( $markup, $pattern ); $total = array_sum( $hits ); if ( $total !== 1 ) { return $this->phrase_error( (string) $find, $total, $id ); } $k = array_key_first( $hits ); $segments = preg_split( $pattern, html_entity_decode( $parts[ $k ], ENT_QUOTES | ENT_HTML5, 'UTF-8' ), 2 ); $parts[ $k ] = esc_html( $segments[0] ) . $html . esc_html( $segments[1] ); return implode( '', $parts ); } /** @return string|\WP_Error Regex of a phrase: any run of spaces, any kind of apostrophe. */ private function phrase_pattern( $find ) { $find = is_string( $find ) ? trim( $find ) : ''; if ( $find === '' || mb_strlen( $find ) > 300 ) { return $this->invalid( __( 'replace_text : texte à trouver vide ou trop long (300 caractères au plus).', 'agentsia-agents' ) ); } return '/' . implode( '', array_map( static function ( $char ) { if ( $char === ' ' ) { return '[\s\x{00A0}]+'; } if ( in_array( $char, [ "'", '’', '‘' ], true ) ) { return "['’‘]"; } return preg_quote( $char, '/' ); }, preg_split( '//u', (string) preg_replace( '/\s+/u', ' ', $find ), -1, PREG_SPLIT_NO_EMPTY ) ) ) . '/u'; } /** * Markup cut into text and tags, with the occurrences of the phrase in each run of text outside links. * * @return array{0: array, 1: array} */ private function phrase_hits( string $markup, string $pattern ): array { $parts = preg_split( '/(|<[^>]*>)/s', $markup, -1, PREG_SPLIT_DELIM_CAPTURE ); $in_link = false; $hits = []; foreach ( $parts as $k => $part ) { if ( $k % 2 === 1 ) { if ( preg_match( '#^]#i', $part ) ) { $in_link = true; } elseif ( preg_match( '#^#i', $part ) ) { $in_link = false; } continue; } $count = $in_link ? 0 : preg_match_all( $pattern, html_entity_decode( $part, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); if ( $count > 0 ) { $hits[ $k ] = $count; } } return [ $parts, $hits ]; } private function phrase_error( string $find, int $total, string $where ): \WP_Error { if ( $total === 0 ) { return $this->invalid( sprintf( /* translators: 1: phrase, 2: unit id or "la page". */ __( 'replace_text : « %1$s » introuvable tel quel dans %2$s, hors d\'un lien existant (le texte doit être d\'un seul tenant, sans gras ni lien au milieu).', 'agentsia-agents' ), $find, $where ) ); } return $this->invalid( /* translators: 1: phrase, 2: count, 3: unit id or "la page". */ sprintf( __( 'replace_text : « %1$s » apparaît %2$d fois dans %3$s : donne une phrase plus longue, qui n\'y figure qu\'une fois.', 'agentsia-agents' ), $find, $total, $where ) ); } /** * replace_text without a unit: the text block holding the phrase, which must appear once on the whole * page (long blocks are only previewed, so the agent may not know which one holds it). * * @return string|\WP_Error Unit id. */ private function unit_with_phrase( array $markups, $find ) { $pattern = $this->phrase_pattern( $find ); if ( is_wp_error( $pattern ) ) { return $pattern; } $found = []; $total = 0; foreach ( $markups as $id => $markup ) { $count = array_sum( $this->phrase_hits( $markup, $pattern )[1] ); if ( $count > 0 ) { $found[] = $id; $total += $count; } } return $total === 1 ? (string) $found[0] : $this->phrase_error( (string) $find, $total, __( 'la page', 'agentsia-agents' ) ); } /** Markup of every text block of the page, by unit id. */ private function text_markups( array $blocks ): array { $out = []; foreach ( $blocks as $block ) { if ( $this->is_text( $block ) ) { if ( isset( $block['agentsia_id'] ) ) { $out[ (string) $block['agentsia_id'] ] = serialize_block( $block ); } } elseif ( ! empty( $block['innerBlocks'] ) ) { $out += $this->text_markups( $block['innerBlocks'] ); } } return $out; } /** Index path of a block by unit id (null when absent). */ private function find_path( array $blocks, string $id ): ?array { if ( $id === '' ) { return null; } foreach ( $blocks as $i => $block ) { if ( ( $block['agentsia_id'] ?? null ) === $id ) { return [ $i ]; } if ( ! empty( $block['innerBlocks'] ) ) { $inner = $this->find_path( $block['innerBlocks'], $id ); if ( $inner !== null ) { return array_merge( [ $i ], $inner ); } } } return null; } private function block_at( array $blocks, array $path ): array { $block = []; foreach ( $path as $i ) { $block = $blocks[ $i ]; $blocks = (array) ( $block['innerBlocks'] ?? [] ); } return $block; } /** Ids of the text blocks (paragraphs, headings, lists…) held by these blocks, at any depth. */ private function text_ids( array $blocks ): array { $ids = []; foreach ( $blocks as $block ) { if ( $this->is_text( $block ) ) { $ids[] = (string) ( $block['agentsia_id'] ?? '' ); } elseif ( ! empty( $block['innerBlocks'] ) ) { $ids = array_merge( $ids, $this->text_ids( $block['innerBlocks'] ) ); } } return $ids; } private function untag( array $blocks ): array { foreach ( array_keys( $blocks ) as $i ) { unset( $blocks[ $i ]['agentsia_id'] ); if ( ! empty( $blocks[ $i ]['innerBlocks'] ) ) { $blocks[ $i ]['innerBlocks'] = $this->untag( $blocks[ $i ]['innerBlocks'] ); } } return $blocks; } /** @return array|\WP_Error Blocks of a new fragment (cleaned block markup). */ private function fragment_blocks( $html ) { $html = $this->clean_html( $html ); if ( is_wp_error( $html ) ) { return $html; } $blocks = []; foreach ( parse_blocks( $html ) as $block ) { if ( $block['blockName'] === null && trim( (string) $block['innerHTML'] ) === '' ) { continue; } $blocks[] = $block; } return $blocks ? $blocks : $this->invalid( __( 'Contenu vide.', 'agentsia-agents' ) ); } /** @return string|\WP_Error Inline HTML (bold, links…) for the inside of a paragraph or heading. */ private function clean_inline( $html ) { $html = $this->clean_html( $html ); if ( is_wp_error( $html ) ) { return $html; } if ( preg_match( '#<(/?)(p|div|h[1-6]|ul|ol|li|table|blockquote|figure|section)\b|