__NAMESPACE__ . '\\render_archive_block' ) ); } ); /** * Enqueue the Archive frontend assets. */ function enqueue_assets() { wp_enqueue_style( 'content-trees' ); wp_enqueue_script( 'content-trees' ); } /** * Normalize the archive date order. * * @param mixed $value Value to normalize. */ function archive_order( $value ) { return 'ASC' === $value ? 'ASC' : 'DESC'; } /** * Normalize Archive options for every entry point. * * @param array $args Tree options or theme widget wrappers. */ function archive_options( $args = array() ) { $defaults = array( 'title' => __( 'Archives', 'content-trees' ), 'open_to' => 0, 'show_title' => true, 'show_counts' => true, 'show_archive_links' => true, 'show_controls' => false, 'auto_open' => true, 'show_posts' => true, 'group_years' => true, 'close_siblings' => false, 'order' => 'DESC', 'excluded_posts' => array(), ); $args = array_intersect_key( wp_parse_args( $args, $defaults ), $defaults ); $args['title'] = is_string( $args['title'] ) ? sanitize_text_field( $args['title'] ) : $defaults['title']; foreach ( array( 'show_title', 'show_counts', 'show_archive_links', 'show_controls', 'auto_open', 'show_posts', 'group_years', 'close_siblings' ) as $flag ) { $args[ $flag ] = enabled( $args[ $flag ] ); } $args['show_title'] = $args['show_title'] && '' !== $args['title']; $args['excluded_posts'] = exclusion_ids( $args['excluded_posts'] ); $args['open_to'] = exclusion_ids( array( $args['open_to'] ) )[0] ?? 0; $args['order'] = archive_order( $args['order'] ); if ( ! $args['show_posts'] ) { $args['show_archive_links'] = true; } return $args; } /** * Adapt saved block attributes to Archive options. * * @param array $attributes Saved block attributes. */ function render_archive_block( $attributes ) { $map = array( 'title' => 'title', 'showCounts' => 'show_counts', 'showArchiveLinks' => 'show_archive_links', 'showControls' => 'show_controls', 'autoOpen' => 'auto_open', 'showPosts' => 'show_posts', 'groupYears' => 'group_years', 'closeSiblings' => 'close_siblings', 'order' => 'order', 'exclude' => 'excluded_posts', ); $args = array(); foreach ( $map as $attribute => $option ) { if ( array_key_exists( $attribute, $attributes ) ) { $args[ $option ] = $attributes[ $attribute ]; } } return render( $args ); } /** * Apply shortcode defaults and its standard WordPress filter. * * @param array $attributes Saved block attributes. */ function render_archive_shortcode( $attributes ) { return render( shortcode_atts( archive_options(), (array) $attributes, 'content_trees_archive' ) ); } /** * Reuse the single cached outline; subtract only excluded public posts, with no cache variants. * * @param int[] $exclude Excluded public post IDs. * @param string $order Sort direction. */ function filtered_outline( $exclude, $order ) { $months = outline(); if ( $exclude ) { $query = new \WP_Query( array( 'post_type' => 'post', 'post_status' => 'publish', 'has_password' => false, 'post__in' => $exclude, 'posts_per_page' => -1, 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); $removed = array(); foreach ( $query->posts as $post ) { $key = substr( $post->post_date, 0, 7 ); $removed[ $key ] = ( $removed[ $key ] ?? 0 ) + 1; } foreach ( $months as &$month ) { $key = sprintf( '%04d-%02d', $month['year'], $month['month'] ); $month['count'] -= $removed[ $key ] ?? 0; } unset( $month ); $months = array_values( array_filter( $months, static function ( $month ) { return $month['count'] > 0; } ) ); } return 'ASC' === $order ? array_reverse( $months ) : $months; } /** Cache plain date/count data, independently of the current visitor or page. */ function outline() { global $wpdb; $cached = get_transient( 'ctrees_month_index_v1' ); if ( is_array( $cached ) ) { return $cached; } // wp_get_archives() returns formatted output, not structured month/count rows. // The prepared query uses only core's posts table and fixed public-post criteria. // Cached above/below in ctrees_month_index_v1 for 12 hours; clean_post_cache invalidates it. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Isolated prepared aggregation; transient cache and invalidation are implemented here. $months = $wpdb->get_results( $wpdb->prepare( "SELECT YEAR(post_date) AS year, MONTH(post_date) AS month, COUNT(*) AS count FROM %i WHERE post_type = %s AND post_status = %s AND post_password = '' AND post_date >= '1000-01-01 00:00:00' GROUP BY YEAR(post_date), MONTH(post_date) ORDER BY year DESC, month DESC", $wpdb->posts, 'post', 'publish' ), ARRAY_A ); $months = array_map( static function ( $row ) { return array_map( 'intval', $row ); }, $months ? $months : array() ); set_transient( 'ctrees_month_index_v1', $months, 12 * HOUR_IN_SECONDS ); return $months; } // Transients persist even without an external object cache. Invalidate on post changes. add_action( 'clean_post_cache', static function ( $id, $post ) { // Core can pass the cached pre-update type, including in set_post_type(). if ( 'post' === $post->post_type || 'post' === get_post_type( $id ) ) { delete_transient( 'ctrees_month_index_v1' ); } }, 10, 2 ); /** * Fetch the complete public month, shared by the initial HTML and the REST route. * * @param int $year Four-digit year. * @param int $month Month number, or zero for a year. * @param string $order Sort direction. * @param int[] $exclude Excluded public post IDs. */ function month_posts( $year, $month, $order = 'DESC', $exclude = array() ) { $order = archive_order( $order ); $query = new \WP_Query( array( 'post_type' => 'post', 'post_status' => 'publish', 'has_password' => false, 'year' => (int) $year, 'monthnum' => (int) $month, 'posts_per_page' => -1, 'no_found_rows' => true, 'orderby' => array( 'date' => $order, 'ID' => $order, ), 'ignore_sticky_posts' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); // The complete month is already fetched; filter its IDs here instead of // adding a potentially expensive NOT IN clause to the date query. $excluded = array_fill_keys( exclusion_ids( $exclude ), true ); $posts = array_filter( $query->posts, static function ( $post ) use ( $excluded ) { return ! isset( $excluded[ $post->ID ] ); } ); return array( 'items' => array_values( array_map( __NAMESPACE__ . '\\post_node', $posts ) ) ); } add_action( 'rest_api_init', static function () { register_rest_route( 'content-trees/v1', '/months/(?P\d{4})/(?P\d{1,2})', array( 'methods' => 'GET', // Intentionally public: the provider only exposes published, unprotected posts. 'permission_callback' => '__return_true', 'args' => array( 'year' => array( 'type' => 'integer', 'minimum' => 1000, 'maximum' => 9999, 'required' => true, ), 'month' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'required' => true, ), 'order' => array( 'type' => 'string', 'enum' => array( 'ASC', 'DESC' ), 'default' => 'DESC', ), 'excluded_posts' => array( 'type' => 'string', 'pattern' => '^[0-9,]*$', 'default' => '', ), ), 'callback' => static function ( $request ) { $result = month_posts( (int) $request['year'], (int) $request['month'], $request['order'], exclusion_ids( $request['excluded_posts'] ) ); return rest_ensure_response( $result ); }, ) ); register_rest_route( 'content-trees/v1', '/post-options', array( 'methods' => 'GET', 'permission_callback' => static function () { return current_user_can( 'edit_posts' ) || current_user_can( 'edit_theme_options' ); }, 'args' => array( 'search' => array( 'type' => 'string', 'default' => '', 'maxLength' => 200, ), 'include' => array( 'type' => 'string', 'pattern' => '^[0-9,]*$', 'default' => '', ), ), 'callback' => static function ( $request ) { $args = array( 'post_type' => 'post', 'post_status' => 'publish', 'has_password' => false, 'posts_per_page' => 20, 'no_found_rows' => true, 'orderby' => array( 'date' => 'DESC', 'ID' => 'DESC', ), 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ); if ( '' !== $request['include'] ) { $args['post__in'] = exclusion_ids( $request['include'] ); if ( ! $args['post__in'] ) { return rest_ensure_response( array( 'items' => array() ) ); } $args['posts_per_page'] = -1; } else { $args['s'] = $request['search']; $args['search_columns'] = array( 'post_title' ); } $query = new \WP_Query( $args ); return rest_ensure_response( array( 'items' => array_map( __NAMESPACE__ . '\\post_node', $query->posts ) ) ); }, ) ); } ); /** * Render one escaped public post link. * * @param array $node Public link node. * @param int $current_id Current public item ID. */ function post_link( $node, $current_id ) { return '
  • ' . esc_html( $node['label'] ) . '
  • '; } /** * Output a date-name link; the native summary keeps its disclosure action. * * @param int $year Four-digit year. * @param int $month Month number, or zero for a year. * @param int $count Eligible post count. * @param bool $full_date Whether to include the year. * @param bool $filtered Whether tree exclusions affect the count. */ function archive_link( $year, $month, $count, $full_date = false, $filtered = false ) { global $wp_locale; $date = (string) $year; if ( $month ) { /* translators: 1: month name, 2: four-digit year. */ $date = sprintf( _x( '%1$s %2$s', 'archive month and year', 'content-trees' ), $wp_locale->get_month( $month ), $year ); } /* translators: 1: number of posts, 2: month and year, or year alone. */ $label = sprintf( _n( 'Browse %1$s post from %2$s', 'Browse all %1$s posts from %2$s', $count, 'content-trees' ), number_format_i18n( $count ), $date ); if ( $filtered ) { /* translators: %s: month and year, or year alone. */ $label = sprintf( __( 'Browse the full archive for %s', 'content-trees' ), $date ); } ?> get_month( $month ) : $year ) ); ?> get_month( $month ), $year ) : (string) $year; /* translators: %s: month and year, or year alone. */ return sprintf( __( 'Expand or collapse %s', 'content-trees' ), $date ); } /** * Explicit open_to IDs and the queried post use the same ancestry path. * * @param array $args Tree options or theme widget wrappers. */ function render( $args = array() ) { global $wp_locale; $args = archive_options( $args ); $current = is_singular( 'post' ) ? get_queried_object() : null; if ( $current && in_array( $current->ID, $args['excluded_posts'], true ) ) { $current = null; } $current_id = is_public_post( $current ) ? $current->ID : 0; $automatic_target = $args['auto_open'] ? $current : null; $target = ! empty( $args['open_to'] ) ? get_post( absint( $args['open_to'] ) ) : $automatic_target; if ( $target && in_array( $target->ID, $args['excluded_posts'], true ) ) { $target = null; } // An unavailable explicit target must not hide the current post's ancestry. $target = is_public_post( $target ) ? $target : ( is_public_post( $automatic_target ) ? $automatic_target : null ); $target_month = $target ? substr( $target->post_date, 0, 7 ) : ''; // Plain archive permalinks use ?m=YYYYMM rather than year/monthnum query vars. $archive_date = (string) get_query_var( 'm' ); $archive_year = (int) ( get_query_var( 'year' ) ? get_query_var( 'year' ) : substr( $archive_date, 0, 4 ) ); $archive_month = (int) ( get_query_var( 'monthnum' ) ? get_query_var( 'monthnum' ) : substr( $archive_date, 4, 2 ) ); $open_year = $target ? (int) substr( $target_month, 0, 4 ) : ( $args['auto_open'] && is_date() ? $archive_year : 0 ); if ( ! $target && $args['auto_open'] && ( is_month() || is_day() ) ) { $target_month = sprintf( '%04d-%02d', $open_year, $archive_month ); } $years = array(); foreach ( filtered_outline( $args['excluded_posts'], $args['order'] ) as $month ) { $years[ $month['year'] ][] = $month; } enqueue_assets(); $messages = array( 'loading' => __( 'Loading posts…', 'content-trees' ), 'loaded' => __( 'Posts loaded.', 'content-trees' ), 'bulkLoading' => __( 'Loading archive posts…', 'content-trees' ), 'bulkComplete' => __( 'Archive loading finished.', 'content-trees' ), 'bulkError' => __( 'Archive loading finished. Some months could not be loaded. Use Try again in those months.', 'content-trees' ), 'bulkOpened' => __( 'Archive expanded.', 'content-trees' ), 'bulkClosed' => __( 'Archive collapsed.', 'content-trees' ), 'empty' => __( 'No posts in this month.', 'content-trees' ), 'error' => $args['show_archive_links'] ? __( 'Posts could not be loaded. Try again or visit the month archive.', 'content-trees' ) : __( 'Posts could not be loaded. Try again.', 'content-trees' ), 'retry' => __( 'Try again', 'content-trees' ), ); ob_start(); ?>