✏️ 编辑:class-details-text.php
路径:
/home/forge/kingkrunch.com/wp/wp-content/plugins/simple-history/inc/class-details-text.php
大小:13.8 KB · 修改:2026-09-25 00:42:29 · 权限:0644 · 可写
← 返回目录
👁 查看
⬇ 下载
<?php namespace Simple_History; /** * Turns the HTML that loggers produce for event details into plain text. * * Used for text-only surfaces: Copy as text, plain-text alerts and emails, * the abilities API. The rules: * * - A key-value pair (<dt>key</dt><dd>value</dd>, or the pre-5.33 form * <tr><td>key</td><td>value</td></tr>) becomes "key: value" on its own line. * - <ins>new</ins> next to <del>old</del>, in either order, becomes "old → new". * - A diff table row (td.diff-deletedline / td.diff-addedline) becomes * "old → new"; unchanged context rows are dropped. * - Screen-reader-only text is dropped. * - Line breaks and block elements end a line; blank lines are removed. * * Parsing is done with WP_HTML_Processor, which understands nesting, so a diff * table inside a value cell cannot be confused with the value's own markup. * WordPress 6.3 has no processor, and versions before 6.7 refuse some of the * elements used here, so a small regex converter covers those installs. */ class Details_Text { /** @var string Finished text, one entry per line. */ private $out = ''; /** @var string|null Key of the pair being read. Null when not inside one. */ private $key = null; /** @var string|null Value of the pair being read. Null when not inside one. */ private $value = null; /** @var string|null Text of an <ins> being read. */ private $ins_text = null; /** @var string|null Text of a <del> being read. */ private $del_text = null; /** @var string|null Finished <del> text waiting for its <ins> counterpart. */ private $old = null; /** @var string|null Finished <ins> text waiting for its <del> counterpart. */ private $new = null; /** @var string|null Which diff cell is being read: 'deleted' or 'added'. */ private $diff_cell = null; /** @var string|null Text of the diff cell being read. */ private $diff_text = null; /** @var string|null Deleted side of a diff row waiting for the added side. */ private $diff_old = null; /** @var int|null Depth of an element whose text is dropped (screen-reader text, diff context). */ private $skip_depth = null; /** @var int|null Depth of the open diff table, so its cells are not read as legacy key-value cells. */ private $diff_table_depth = null; /** @var int Which cell of a legacy <tr><td>key</td><td>value</td></tr> row comes next. */ private $legacy_cell = 0; /** * Convert details HTML to plain text. * * @param string $html Details HTML from a logger. * @return string Plain text, empty when there is nothing to show. */ public static function from_html( $html ) { $html = (string) $html; if ( trim( $html ) === '' ) { return ''; } $text = self::convert_with_html_api( $html ); if ( $text === null ) { $text = self::convert_with_regex( $html ); } return self::tidy( $text ); } /** * Convert using WP_HTML_Processor. * * @param string $html Details HTML. * @return string|null Text with one entry per line, or null when the * processor is unavailable or cannot parse the markup. */ public static function convert_with_html_api( $html ) { // The processor exists since 6.4, but get_current_depth() and the // other methods used here were complete first in 6.6. if ( ! class_exists( '\WP_HTML_Processor' ) || version_compare( get_bloginfo( 'version' ), '6.6', '<' ) ) { return null; } $processor = \WP_HTML_Processor::create_fragment( (string) $html ); if ( $processor === null ) { return null; } $converter = new self(); while ( $processor->next_token() ) { $converter->handle_token( $processor ); } // The processor stops on markup it does not support; then the regex // converter takes over rather than returning a partial text. if ( $processor->get_last_error() !== null ) { return null; } if ( $converter->key !== null ) { $converter->flush_pair(); } $converter->flush_change(); return $converter->out; } /** * Read one token from the processor into the text buffers. * * @param \WP_HTML_Processor $processor Processor positioned on a token. * @return void */ private function handle_token( $processor ) { $type = $processor->get_token_type(); $depth = $processor->get_current_depth(); if ( $this->skip_depth !== null && $depth < $this->skip_depth ) { $this->skip_depth = null; } if ( $type === '#text' ) { if ( $this->skip_depth === null ) { $this->append( $processor->get_modifiable_text() ); } return; } if ( $type !== '#tag' ) { return; } $name = (string) $processor->get_token_name(); $is_closer = $processor->is_tag_closer(); $class = $is_closer ? '' : (string) $processor->get_attribute( 'class' ); // Screen-reader-only text carries nothing for a text reader. if ( ! $is_closer && $this->skip_depth === null && self::has_class( $class, 'screen-reader-text' ) ) { $this->skip_depth = $depth; return; } if ( $this->skip_depth !== null ) { return; } switch ( $name ) { case 'DT': if ( ! $is_closer ) { // A <dt> without a following <dd> is a lone key. if ( $this->key !== null ) { $this->flush_pair(); } $this->key = ''; } break; case 'DD': if ( $is_closer ) { $this->flush_pair(); } else { $this->value = ''; } break; case 'INS': if ( $is_closer ) { $this->new = trim( (string) $this->ins_text ); $this->ins_text = null; if ( $this->old !== null ) { $this->flush_change(); } } else { // A second <ins> without a <del> in between: the first stood alone. if ( $this->new !== null ) { $this->flush_change(); } $this->ins_text = ''; } break; case 'DEL': if ( $is_closer ) { $this->old = trim( (string) $this->del_text ); $this->del_text = null; if ( $this->new !== null ) { $this->flush_change(); } } else { if ( $this->old !== null ) { $this->flush_change(); } $this->del_text = ''; } break; case 'TABLE': if ( $is_closer ) { if ( $this->diff_table_depth !== null && $depth <= $this->diff_table_depth ) { $this->diff_table_depth = null; } } elseif ( self::has_class( $class, 'diff' ) ) { $this->diff_table_depth = $depth; } break; case 'TR': if ( $this->diff_table_depth !== null ) { // A diff row with only a deleted side. if ( $is_closer && $this->diff_old !== null ) { $this->append( $this->diff_old . "\n" ); $this->diff_old = null; } } else { if ( $is_closer && $this->key !== null ) { $this->flush_pair(); } $this->legacy_cell = 0; } break; case 'TD': if ( $this->diff_table_depth !== null ) { $this->handle_diff_cell( $is_closer, $class, $depth ); } elseif ( ! $is_closer ) { // Legacy key-value row: first cell is the key, second the value. ++$this->legacy_cell; if ( $this->legacy_cell === 1 ) { $this->key = ''; } elseif ( $this->legacy_cell === 2 ) { $this->value = ''; } } elseif ( $this->legacy_cell === 2 && $this->value !== null ) { $this->flush_pair(); } break; case 'BR': $this->append( "\n" ); break; case 'P': case 'DIV': case 'LI': case 'H1': case 'H2': case 'H3': case 'H4': case 'H5': case 'H6': if ( $is_closer ) { $this->append( "\n" ); } break; } } /** * Read a cell of a diff table. * * @param bool $is_closer Whether the token is the closing tag. * @param string $class_attr Class attribute of the opening tag. * @param int $depth Depth of the token. * @return void */ private function handle_diff_cell( $is_closer, $class_attr, $depth ) { if ( ! $is_closer ) { if ( self::has_class( $class_attr, 'diff-deletedline' ) ) { $this->diff_cell = 'deleted'; $this->diff_text = ''; } elseif ( self::has_class( $class_attr, 'diff-addedline' ) ) { $this->diff_cell = 'added'; $this->diff_text = ''; } elseif ( self::has_class( $class_attr, 'diff-context' ) ) { // Unchanged lines add noise in plain text. $this->skip_depth = $depth; } return; } $cell = $this->diff_cell; $cell_text = trim( (string) $this->diff_text ); $this->diff_cell = null; $this->diff_text = null; if ( $cell === 'deleted' ) { $this->diff_old = $cell_text; } elseif ( $cell === 'added' ) { if ( $this->diff_old !== null || $cell_text !== '' ) { $this->append( trim( (string) $this->diff_old . ' → ' . $cell_text ) . "\n" ); } $this->diff_old = null; } } /** * Append text to the innermost open buffer. * * @param string $text Text to append. * @return void */ private function append( $text ) { if ( $this->diff_text !== null ) { $this->diff_text .= $text; } elseif ( $this->ins_text !== null ) { $this->ins_text .= $text; } elseif ( $this->del_text !== null ) { $this->del_text .= $text; } elseif ( $this->value !== null ) { $this->value .= $text; } elseif ( $this->key !== null ) { $this->key .= $text; } else { $this->out .= $text; } } /** * Write a finished "old → new" change, or a lone half, into the enclosing buffer. * * @return void */ private function flush_change() { if ( $this->old === null && $this->new === null ) { return; } if ( $this->old !== null && $this->new !== null ) { $this->append( trim( $this->old . ' → ' . $this->new ) ); } else { $this->append( (string) ( $this->old ?? $this->new ) ); } $this->old = null; $this->new = null; } /** * Write the finished key-value pair as a line. * * @return void */ private function flush_pair() { $this->flush_change(); $line = self::format_pair( (string) $this->key, (string) $this->value ); if ( $line !== '' ) { $this->out .= $line . "\n"; } $this->key = null; $this->value = null; } /** * Convert with regular expressions. * * Handles the markup the loggers produce but cannot see nesting, so the * patterns are kept to text that contains no tags of its own. Anything * they miss ends up as its plain text, unlabelled. * * @param string $html Details HTML. * @return string Text with one entry per line. */ public static function convert_with_regex( $html ) { $html = (string) $html; // Screen-reader-only text. $html = (string) preg_replace( '/<(span|h[1-6])[^>]*class=[\'"][^"\']*screen-reader-text[^"\']*[\'"][^>]*>[^<]*<\/\1>/i', '', $html ); // Unchanged diff context rows add noise in plain text. $html = (string) preg_replace( '/<tr[^>]*>\s*<td[^>]*class=[\'"][^"\']*diff-context[^"\']*[\'"][^>]*>[^<]*<\/td>\s*<td[^>]*>[^<]*<\/td>\s*<td[^>]*>[^<]*<\/td>\s*<\/tr>/i', '', $html ); // Diff rows: deleted side and added side become "old → new". $html = (string) preg_replace_callback( '/<tr[^>]*>\s*<td[^>]*class=[\'"][^"\']*diff-deletedline[^"\']*[\'"][^>]*>(.*?)<\/td>\s*<td[^>]*>\s*<\/td>\s*<td[^>]*class=[\'"][^"\']*diff-addedline[^"\']*[\'"][^>]*>(.*?)<\/td>\s*<\/tr>/is', function ( $matches ) { $deleted = trim( wp_strip_all_tags( $matches[1] ) ); $added = trim( wp_strip_all_tags( $matches[2] ) ); if ( $deleted === '' && $added === '' ) { return ''; } return trim( $deleted . ' → ' . $added ) . "\n"; }, $html ); // <del>old</del><ins>new</ins> and <ins>new</ins><del>old</del>. $html = (string) preg_replace_callback( '/<del[^>]*>([^<]*)<\/del>\s*<ins[^>]*>([^<]*)<\/ins>/i', fn( $matches ) => trim( trim( $matches[1] ) . ' → ' . trim( $matches[2] ) ), $html ); $html = (string) preg_replace_callback( '/<ins[^>]*>([^<]*)<\/ins>\s*<del[^>]*>([^<]*)<\/del>/i', fn( $matches ) => trim( trim( $matches[2] ) . ' → ' . trim( $matches[1] ) ), $html ); $pair_to_line = function ( $matches ) { $line = self::format_pair( wp_strip_all_tags( $matches[1] ), wp_strip_all_tags( $matches[2] ) ); return $line === '' ? '' : $line . "\n"; }; // Key-value pairs, current markup. $html = (string) preg_replace_callback( '/<dt[^>]*>(.*?)<\/dt>\s*<dd[^>]*>(.*?)<\/dd>/is', $pair_to_line, $html ); // Key-value pairs, pre-5.33 markup that third-party loggers may still emit. $html = (string) preg_replace_callback( '/<tr[^>]*>\s*<td[^>]*>(.*?)<\/td>\s*<td[^>]*>(.*?)<\/td>\s*<\/tr>/is', $pair_to_line, $html ); // Line breaks and block elements end a line. $html = (string) preg_replace( '/<br\s*\/?>/i', "\n", $html ); $html = (string) preg_replace( '/<\/(p|div|tr|dd|li|h[1-6])>/i', "\n", $html ); return html_entity_decode( wp_strip_all_tags( $html ), ENT_QUOTES, 'UTF-8' ); } /** * Format one key-value pair as a line of text. * * A value of "0" is a value; only an empty string counts as missing. * * @param string $key Key text. * @param string $value Value text. * @return string "key: value", a lone key or value, or "" when both are empty. */ public static function format_pair( $key, $value ) { $key = trim( (string) preg_replace( '/\s+/', ' ', (string) $key ) ); $value = trim( (string) $value ); if ( $key === '' ) { return $value; } if ( $value === '' ) { return $key; } return $key . ': ' . $value; } /** * Collapse runs of spaces, trim each line, drop blank lines. * * @param string $text Text with newlines. * @return string */ private static function tidy( $text ) { $text = (string) preg_replace( '/[ \t]+/', ' ', $text ); $lines = array_map( 'trim', explode( "\n", $text ) ); $lines = array_filter( $lines, fn( $line ) => $line !== '' ); return implode( "\n", $lines ); } /** * Whether a class attribute value contains a class name. * * @param string $class_attribute Space-separated class names. * @param string $class_name Class to look for. * @return bool */ private static function has_class( $class_attribute, $class_name ) { $classes = preg_split( '/\s+/', trim( $class_attribute ) ); return is_array( $classes ) && in_array( $class_name, $classes, true ); } }
💾 保存
取消
保存为 UTF-8,换行统一为 LF