'string', 'single' => true, 'show_in_rest' => true, 'auth_callback' => static function ( $allowed, $meta_key, $post_id ) { return current_user_can( 'edit_post', $post_id ); }, ) ); } } } /** * The "Short link" panel in the block editor's document sidebar. */ public static function enqueue_editor_panel() { if ( ! self::user_can_use() ) { return; } $screen = get_current_screen(); if ( ! $screen || ! in_array( $screen->post_type, self::post_types(), true ) ) { return; } wp_enqueue_script( 'useclick-editor', USECLICK_PLUGIN_URL . 'assets/js/editor.js', array( 'wp-plugins', 'wp-editor', 'wp-element', 'wp-components', 'wp-data', 'wp-i18n', 'wp-a11y', 'wp-dom-ready' ), USECLICK_VERSION, true ); wp_add_inline_script( 'useclick-editor', 'window.useclickEditor = ' . wp_json_encode( array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'useclick_nonce' ), 'dashboardUrl' => USECLICK_APP_URL . '/dashboard', ) ) . ';', 'before' ); wp_set_script_translations( 'useclick-editor', 'useclick' ); wp_enqueue_style( 'useclick-editor', USECLICK_PLUGIN_URL . 'assets/css/editor.css', array(), USECLICK_VERSION ); } /** * AJAX: click total for a post's short link (block editor panel). */ public static function ajax_post_link_stats() { UseClick_Admin::verify_ajax( self::capability() ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked in verify_ajax(). $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to edit this post.', 'useclick' ) ), 403 ); } $slug = (string) get_post_meta( $post_id, self::META_SLUG, true ); if ( '' === $slug ) { wp_send_json_success( array( 'clicks' => null ) ); } $api = new UseClick_API(); $counts = $api->get_clicks_for_slugs( array( $slug ) ); wp_send_json_success( array( 'clicks' => isset( $counts[ $slug ] ) ? (int) $counts[ $slug ] : null ) ); } /** * Post types that get the box and the column. * * @return string[] */ public static function post_types() { $types = get_post_types( array( 'public' => true, 'show_ui' => true, ) ); unset( $types['attachment'] ); /** * Filters the post types that get UseClick.io short links. * * @param string[] $types Post type names. */ return (array) apply_filters( 'useclick_post_types', array_values( $types ) ); } /** * Capability needed (in addition to editing the post) to create a short link for it. * * @return string */ public static function capability() { /** * Filters who can create short links for posts. Links count against the * site's UseClick.io plan, so the default is editors and administrators. * * @param string $capability Default 'edit_others_posts'. */ return (string) apply_filters( 'useclick_post_link_capability', 'edit_others_posts' ); } /** * Whether the current user may use the feature at all. * * @return bool */ private static function user_can_use() { return UseClick_Admin::has_api_key() && current_user_can( self::capability() ); } /** * Add the editor box. */ public static function add_meta_box() { if ( ! self::user_can_use() ) { return; } foreach ( self::post_types() as $type ) { // Classic editor only: the block editor gets the sidebar panel (assets/js/editor.js). add_meta_box( 'useclick-short-link', __( 'Short link', 'useclick' ), array( __CLASS__, 'render_meta_box' ), $type, 'side', 'default', array( '__back_compat_meta_box' => true ) ); } } /** * Render the editor box. * * @param WP_Post $post Post being edited. */ public static function render_meta_box( $post ) { $useclick_post_id = (int) $post->ID; $useclick_short_url = (string) get_post_meta( $post->ID, self::META_URL, true ); $useclick_slug = (string) get_post_meta( $post->ID, self::META_SLUG, true ); $useclick_published = 'publish' === get_post_status( $post ); $useclick_clicks = null; if ( '' !== $useclick_slug ) { $api = new UseClick_API(); $counts = $api->get_clicks_for_slugs( array( $useclick_slug ) ); $useclick_clicks = isset( $counts[ $useclick_slug ] ) ? $counts[ $useclick_slug ] : null; } include USECLICK_PLUGIN_DIR . 'templates/partials/post-link.php'; } /** * Register the list column for every supported post type. */ public static function register_columns() { if ( ! self::user_can_use() ) { return; } foreach ( self::post_types() as $type ) { add_filter( 'manage_' . $type . '_posts_columns', array( __CLASS__, 'add_column' ) ); add_action( 'manage_' . $type . '_posts_custom_column', array( __CLASS__, 'render_column' ), 10, 2 ); } } /** * Add the column after the title. * * @param array $columns Columns. * @return array */ public static function add_column( $columns ) { $out = array(); foreach ( $columns as $key => $label ) { $out[ $key ] = $label; if ( 'title' === $key ) { $out['useclick_short_link'] = __( 'Short link', 'useclick' ); } } if ( ! isset( $out['useclick_short_link'] ) ) { $out['useclick_short_link'] = __( 'Short link', 'useclick' ); } return $out; } /** * Render the column cell. * * @param string $column Column key. * @param int $post_id Post ID. */ public static function render_column( $column, $post_id ) { if ( 'useclick_short_link' !== $column ) { return; } $short_url = (string) get_post_meta( $post_id, self::META_URL, true ); $slug = (string) get_post_meta( $post_id, self::META_SLUG, true ); if ( '' !== $short_url ) { $counts = self::list_counts(); echo '
'; echo '' . esc_html( preg_replace( '#^https?://#', '', $short_url ) ) . ''; echo ''; if ( isset( $counts[ $slug ] ) ) { /* translators: %s: number of clicks */ echo '' . esc_html( sprintf( _n( '%s click', '%s clicks', $counts[ $slug ], 'useclick' ), number_format_i18n( $counts[ $slug ] ) ) ) . ''; } echo ''; echo '
'; return; } if ( 'publish' === get_post_status( $post_id ) && current_user_can( 'edit_post', $post_id ) ) { echo ''; echo ''; } else { echo ''; } } /** * Click totals for every post with a short link on the current list screen (one lookup). * * @return array slug => clicks */ private static function list_counts() { if ( null !== self::$list_counts ) { return self::$list_counts; } global $wp_query; $slugs = array(); if ( $wp_query instanceof WP_Query && ! empty( $wp_query->posts ) ) { foreach ( $wp_query->posts as $post ) { $slug = (string) get_post_meta( is_object( $post ) ? $post->ID : (int) $post, self::META_SLUG, true ); if ( '' !== $slug ) { $slugs[] = $slug; } } } $api = new UseClick_API(); self::$list_counts = $slugs ? $api->get_clicks_for_slugs( $slugs ) : array(); return self::$list_counts; } /** * Load assets on post lists and the editor. * * @param string $hook Admin page hook suffix. */ public static function enqueue_assets( $hook ) { if ( ! in_array( $hook, array( 'edit.php', 'post.php', 'post-new.php' ), true ) || ! self::user_can_use() ) { return; } $screen = get_current_screen(); if ( ! $screen || ! in_array( $screen->post_type, self::post_types(), true ) ) { return; } UseClick_Admin::register_assets(); wp_enqueue_style( 'useclick-admin' ); wp_enqueue_script( 'useclick-admin' ); } /** * AJAX: create a short link that points at a published post's permalink. */ public static function ajax_create_post_link() { UseClick_Admin::verify_ajax( self::capability() ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked in verify_ajax(). $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to edit this post.', 'useclick' ) ), 403 ); } if ( 'publish' !== get_post_status( $post_id ) ) { wp_send_json_error( array( 'message' => __( 'Publish the post first. Short links point at the published address.', 'useclick' ) ) ); } $existing = (string) get_post_meta( $post_id, self::META_URL, true ); if ( '' !== $existing ) { wp_send_json_success( array( 'short_url' => $existing, 'slug' => (string) get_post_meta( $post_id, self::META_SLUG, true ), ) ); } $raw = array( 'target_url' => (string) get_permalink( $post_id ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce checked in verify_ajax(); validated in sanitize_link_fields(). $slug = isset( $_POST['slug'] ) ? (string) wp_unslash( $_POST['slug'] ) : ''; if ( '' !== trim( $slug ) ) { $raw['slug'] = $slug; } $fields = UseClick_Admin::sanitize_link_fields( $raw, true ); if ( is_wp_error( $fields ) ) { UseClick_Admin::send_error( $fields ); } $fields['title'] = wp_strip_all_tags( get_the_title( $post_id ) ); $api = new UseClick_API(); $link = $api->create_link( $fields ); if ( is_wp_error( $link ) ) { UseClick_Admin::send_error( $link ); } if ( empty( $link['short_url'] ) || empty( $link['slug'] ) ) { UseClick_Admin::send_error( new WP_Error( 'invalid_response', __( 'UseClick.io returned an unexpected response. Please try again.', 'useclick' ) ) ); } update_post_meta( $post_id, self::META_URL, esc_url_raw( $link['short_url'] ) ); update_post_meta( $post_id, self::META_SLUG, sanitize_text_field( $link['slug'] ) ); UseClick_API::flush_cache(); wp_send_json_success( array( 'short_url' => $link['short_url'], 'slug' => $link['slug'], ) ); } /** * Remove the stored short link from any post that uses a deleted slug. * * @param string $slug Deleted slug. */ public static function forget_slug( $slug ) { $posts = get_posts( array( 'post_type' => 'any', 'post_status' => 'any', 'posts_per_page' => 20, 'fields' => 'ids', 'meta_key' => self::META_SLUG, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- runs only when a link is deleted. 'meta_value' => $slug, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value ) ); foreach ( $posts as $post_id ) { delete_post_meta( $post_id, self::META_URL ); delete_post_meta( $post_id, self::META_SLUG ); } } }