<?php
namespace Simple_History\Loggers;
use Simple_History\Event_Details\Event_Details_Group;
use Simple_History\Event_Details\Event_Details_Group_Diff_Table_Formatter;
use Simple_History\Event_Details\Event_Details_Group_Table_Formatter;
use Simple_History\Event_Details\Event_Details_Item;
use Simple_History\Helpers;
/**
* Logs changes to categories and tags and taxonomies
*/
class Categories_Logger extends Logger {
/** @var string Logger slug */
public $slug = 'SimpleCategoriesLogger';
/**
* Get array with information about this logger
*
* @return array
*/
public function get_info() {
return array(
'name' => __( 'Categories Logger', 'simple-history' ),
'description' => __( 'Logs changes to categories, tags, and taxonomies', 'simple-history' ),
'messages' => array(
'created_term' => __( 'Added term "{term_name}" in taxonomy "{term_taxonomy}"', 'simple-history' ),
'deleted_term' => __( 'Deleted term "{term_name}" from taxonomy "{term_taxonomy}"', 'simple-history' ),
'edited_term' => __( 'Edited term "{to_term_name}" in taxonomy "{to_term_taxonomy}"', 'simple-history' ),
),
'labels' => array(
'search' => array(
'label' => _x( 'Categories', 'Categories logger: search', 'simple-history' ),
'label_all' => _x( 'All category activity', 'Category logger: search', 'simple-history' ),
'options' => array(
_x( 'Term created', 'Category logger: search', 'simple-history' ) => array(
'created_term',
),
_x( 'Term deleted', 'Category logger: search', 'simple-history' ) => array(
'deleted_term',
),
_x( 'Term edited', 'Category logger: search', 'simple-history' ) => array(
'edited_term',
),
),
),
),
);
}
/**
* Called when the logger is loaded.
*/
public function loaded() {
// Fires after a new term is created, and after the term cache has been cleaned.
add_action( 'created_term', array( $this, 'on_created_term' ), 10, 3 );
// Fires after a term is deleted from the database and the cache is cleaned.
add_action( 'delete_term', array( $this, 'on_delete_term' ), 10, 4 );
// Filter the term parent.
add_action( 'wp_update_term_parent', array( $this, 'on_wp_update_term_parent' ), 10, 5 );
}
/**
* Filter the term parent.
* Only way for Simple History to get both old and new term name.
* For example 'edited_term' does not contain enough info to know what the term was called before the update.
*
* @param int $parent_term ID of the parent term.
* @param int $term_id Term ID.
* @param string $taxonomy Taxonomy slug.
* @param array $parsed_args An array of potentially altered update arguments for the given term.
* @param array $term_update_args An array of update arguments for the given term.
*/
public function on_wp_update_term_parent( $parent_term = null, $term_id = null, $taxonomy = null, $parsed_args = null, $term_update_args = null ) {
$term_before_edited = get_term_by( 'id', $term_id, $taxonomy );
if ( ! $term_before_edited || empty( $term_update_args ) ) {
return $parent_term;
}
$term_id = $term_before_edited->term_id;
$from_term_name = $term_before_edited->name;
$from_term_taxonomy = $term_before_edited->taxonomy;
$from_term_slug = $term_before_edited->slug;
$from_term_description = $term_before_edited->description;
$to_term_name = wp_unslash( $term_update_args['name'] );
$to_term_taxonomy = $term_update_args['taxonomy'];
$to_term_slug = $term_update_args['slug'];
$to_term_description = wp_unslash( $term_update_args['description'] );
$do_log_term = $this->ok_to_log_taxonomy( $from_term_taxonomy );
if ( ! $do_log_term ) {
return $parent_term;
}
$context = array(
'_occasionsID' => self::class . '/' . __FUNCTION__ . '/term_edited',
'term_id' => $term_id,
'from_term_name' => $from_term_name,
'from_term_taxonomy' => $from_term_taxonomy,
'from_term_slug' => $from_term_slug,
'from_term_description' => $from_term_description,
'to_term_name' => $to_term_name,
'to_term_taxonomy' => $to_term_taxonomy,
'to_term_slug' => $to_term_slug,
'to_term_description' => $to_term_description,
);
// Only store parent data for hierarchical taxonomies (e.g. categories).
if ( is_taxonomy_hierarchical( $from_term_taxonomy ) ) {
$none_label = __( 'None', 'simple-history' );
$old_parent = $term_before_edited->parent;
$from_parent_term = $old_parent ? get_term( $old_parent, $from_term_taxonomy ) : null;
$from_term_parent_name = $from_parent_term instanceof \WP_Term ? $from_parent_term->name : $none_label;
$to_parent_term = $parent_term ? get_term( $parent_term, $to_term_taxonomy ) : null;
$to_term_parent_name = $to_parent_term instanceof \WP_Term ? $to_parent_term->name : $none_label;
$context['from_term_parent'] = $old_parent;
$context['from_term_parent_name'] = $from_term_parent_name;
$context['to_term_parent'] = $parent_term;
$context['to_term_parent_name'] = $to_term_parent_name;
}
$this->info_message( 'edited_term', $context );
return $parent_term;
}
/**
* Fires after a new term is created, and after the term cache has been cleaned.
*
* @since 2.3.0
*
* @param int $term_id Term ID.
* @param int $tt_id Term taxonomy ID.
* @param string $taxonomy Taxonomy slug.
*/
public function on_created_term( $term_id = null, $tt_id = null, $taxonomy = null ) {
$term = get_term_by( 'id', $term_id, $taxonomy );
if ( ! $term ) {
return;
}
$term_name = $term->name;
$term_taxonomy = $term->taxonomy;
$term_id = $term->term_id;
$term_slug = $term->slug;
$term_description = $term->description;
$term_parent = $term->parent;
$do_log_term = $this->ok_to_log_taxonomy( $term_taxonomy );
if ( ! $do_log_term ) {
return;
}
$context = array(
'_occasionsID' => self::class . '/' . __FUNCTION__ . '/term_created',
'term_id' => $term_id,
'term_name' => $term_name,
'term_taxonomy' => $term_taxonomy,
'term_slug' => $term_slug,
);
// Only store description and parent when they have non-default values.
if ( ! empty( $term_description ) ) {
$context['term_description'] = $term_description;
}
if ( $term_parent ) {
$parent_term_obj = get_term( $term_parent, $term_taxonomy );
if ( $parent_term_obj instanceof \WP_Term ) {
$context['term_parent'] = $term_parent;
$context['term_parent_name'] = $parent_term_obj->name;
}
}
$this->info_message( 'created_term', $context );
}
/**
* Fires after a term is deleted from the database and the cache is cleaned.
*
* @param int $term Term ID.
* @param int $tt_id Term taxonomy ID.
* @param string $taxonomy Taxonomy slug.
* @param mixed $deleted_term Copy of the already-deleted term, in the form specified
* by the parent function. WP_Error otherwise.
*/
public function on_delete_term( $term = null, $tt_id = null, $taxonomy = null, $deleted_term = null ) {
if ( is_wp_error( $deleted_term ) ) {
return;
}
$term_name = $deleted_term->name;
$term_taxonomy = $deleted_term->taxonomy;
$term_id = $deleted_term->term_id;
$do_log_term = $this->ok_to_log_taxonomy( $term_taxonomy );
if ( ! $do_log_term ) {
return;
}
$this->info_message(
'deleted_term',
array(
'_occasionsID' => self::class . '/' . __FUNCTION__ . '/term_deleted',
'term_id' => $term_id,
'term_name' => $term_name,
'term_taxonomy' => $term_taxonomy,
)
);
}
/**
* Return details output for edited terms showing diff of changed fields.
*
* @param object $row Row data.
* @return Event_Details_Group|string
*/
public function get_log_row_details_output( $row ) {
$context = $row->context;
$message_key = $context['_message_key'] ?? null;
if ( $message_key === 'edited_term' ) {
$group = new Event_Details_Group();
$group->set_formatter( new Event_Details_Group_Diff_Table_Formatter() );
$group->add_items(
array(
new Event_Details_Item( array( 'to_term_name', 'from_term_name' ), __( 'Name', 'simple-history' ) ),
new Event_Details_Item( array( 'to_term_slug', 'from_term_slug' ), __( 'Slug', 'simple-history' ) ),
new Event_Details_Item( array( 'to_term_description', 'from_term_description' ), __( 'Description', 'simple-history' ) ),
new Event_Details_Item( array( 'to_term_parent_name', 'from_term_parent_name' ), __( 'Parent', 'simple-history' ) ),
)
);
return $group;
}
if ( $message_key === 'created_term' ) {
$group = new Event_Details_Group();
$group->set_formatter( new Event_Details_Group_Table_Formatter() );
$group->add_items(
array(
new Event_Details_Item( 'term_description', __( 'Description', 'simple-history' ) ),
new Event_Details_Item( 'term_parent_name', __( 'Parent', 'simple-history' ) ),
)
);
return $group;
}
return '';
}
/**
* Modify plain output to include link to term and taxonomy.
*
* @param object $row Row data.
*/
public function get_log_row_plain_text_output( $row ) {
$term_taxonomy = null;
$context = $row->context;
$message_key = $context['_message_key'] ?? null;
// Default to original log message.
$message = $row->message;
// Escaped up front, before the early return further down, so both
// interpolate() exits are covered.
$context = $this->esc_html_context_keys( $context, [ 'term_name', 'to_term_name', 'from_term_name' ] );
// Get term that was created, edited, or removed.
$term_id = isset( $context['term_id'] ) ? (int) $context['term_id'] : null;
// Get taxonomy for term.
if ( $message_key === 'created_term' || $message_key === 'deleted_term' ) {
$term_taxonomy = isset( $context['term_taxonomy'] ) ? (string) $context['term_taxonomy'] : null;
} elseif ( $message_key === 'edited_term' ) {
$term_taxonomy = isset( $context['from_term_taxonomy'] ) ? (string) $context['from_term_taxonomy'] : null;
}
$tax_edit_link = add_query_arg(
array(
'taxonomy' => $term_taxonomy,
),
admin_url( 'term.php' )
);
$context['tax_edit_link'] = $tax_edit_link;
$term_object = get_term( $term_id, $term_taxonomy );
if ( is_wp_error( $term_object ) ) {
return helpers::interpolate( $message, $context, $row );
}
$term_edit_link = isset( $term_object ) ? get_edit_tag_link( $term_id, $term_object->taxonomy ) : null;
$context['term_edit_link'] = $term_edit_link;
// Get taxonomy name to use in log but fall back to taxonomy slug if
// taxonomy has been deleted.
$context['termTaxonomySlugOrName'] = esc_html( $context['term_taxonomy'] ?? '' );
$context['toTermTaxonomySlugOrName'] = esc_html( $context['to_term_taxonomy'] ?? '' );
if ( isset( $context['term_taxonomy'] ) && $context['term_taxonomy'] ) {
$termTaxonomyObject = get_taxonomy( $context['term_taxonomy'] );
if ( is_a( $termTaxonomyObject, 'WP_Taxonomy' ) ) {
$termTaxonomyObjectLabels = get_taxonomy_labels( $termTaxonomyObject );
$context['termTaxonomySlugOrName'] = esc_html( $termTaxonomyObjectLabels->singular_name );
}
}
if ( isset( $context['to_term_taxonomy'] ) && $context['to_term_taxonomy'] ) {
$termTaxonomyObject = get_taxonomy( $context['to_term_taxonomy'] );
if ( is_a( $termTaxonomyObject, 'WP_Taxonomy' ) ) {
$termTaxonomyObjectLabels = get_taxonomy_labels( $termTaxonomyObject );
$context['toTermTaxonomySlugOrName'] = esc_html( $termTaxonomyObjectLabels->singular_name );
}
}
if ( $message_key === 'created_term' && ! empty( $term_edit_link ) && ! empty( $tax_edit_link ) ) {
$message = _x(
'Added term <a href="{term_edit_link}">"{term_name}"</a> in taxonomy <a href="{tax_edit_link}">"{termTaxonomySlugOrName}"</a>',
'Categories logger: detailed plain text output for created term',
'simple-history'
);
} elseif ( $message_key === 'deleted_term' && ! empty( $tax_edit_link ) ) {
$message = _x(
'Deleted term "{term_name}" from taxonomy <a href="{tax_edit_link}">"{termTaxonomySlugOrName}"</a>',
'Categories logger: detailed plain text output for deleted term',
'simple-history'
);
} elseif ( $message_key === 'edited_term' && ! empty( $term_edit_link ) && ! empty( $tax_edit_link ) ) {
$message = _x(
'Edited term <a href="{term_edit_link}">"{to_term_name}"</a> in taxonomy <a href="{tax_edit_link}">"{toTermTaxonomySlugOrName}"</a>',
'Categories logger: detailed plain text output for edited term',
'simple-history'
);
}
return helpers::interpolate( $message, $context, $row );
}
/**
* Check if it's ok to log a taxonomy.
* We skip some taxonomies, for example Polylang translation terms that fill the log with
* messages like 'Edited term "pll_5a3643a142c80" in taxonomy "post_translations"' otherwise.
*
* @since 2.21
* @param string $from_term_taxonomy Slug of taxonomy.
* @return bool True or false.
*/
public function ok_to_log_taxonomy( $from_term_taxonomy = '' ) {
if ( empty( $from_term_taxonomy ) ) {
return false;
}
$skip_taxonomies = $this->get_skip_taxonomies();
return ! in_array( $from_term_taxonomy, $skip_taxonomies, true );
}
/**
* Get taxonomies to skip.
*
* @since 2.21
* @return array Array with taxonomies.
*/
public function get_skip_taxonomies() {
$taxonomies_to_skip = array(
// Polylang taxonomies used to store translation mappings.
'post_translations',
'term_translations',
);
/**
* Filter taxonomies to not log changes to.
*
* @param array $taxonomies_to_skip Array with taxonomy slugs to skip.
*/
$taxonomies_to_skip = apply_filters( 'simple_history/categories_logger/skip_taxonomies', $taxonomies_to_skip );
return $taxonomies_to_skip;
}
}