prefix . 'smsqr_scans'; $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table_name} ( code_id bigint(20) unsigned NOT NULL, scan_date date NOT NULL, scans bigint(20) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (code_id,scan_date), KEY scan_date (scan_date) ) {$charset_collate};"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); update_option( self::DB_VERSION_OPTION, SMSQR_DB_VERSION, false ); } /** * Keep the scan table in sync after plugin updates. */ public function maybe_upgrade_database() { if ( SMSQR_DB_VERSION !== get_option( self::DB_VERSION_OPTION ) ) { self::install_database(); } } /** * Register the QR code post type. */ public function register_post_type() { self::register_post_type_definition(); } /** * Shared post type registration for init and activation. */ private static function register_post_type_definition() { $labels = array( 'name' => __( 'QR Codes', 'same-square' ), 'singular_name' => __( 'QR Code', 'same-square' ), 'menu_name' => __( 'Same Square', 'same-square' ), 'name_admin_bar' => __( 'QR Code', 'same-square' ), 'add_new' => __( 'Add New', 'same-square' ), 'add_new_item' => __( 'Add New QR Code', 'same-square' ), 'edit_item' => __( 'Edit QR Code', 'same-square' ), 'new_item' => __( 'New QR Code', 'same-square' ), 'view_item' => __( 'View QR Code', 'same-square' ), 'search_items' => __( 'Search QR Codes', 'same-square' ), 'not_found' => __( 'No QR codes found.', 'same-square' ), 'not_found_in_trash' => __( 'No QR codes found in Trash.', 'same-square' ), 'all_items' => __( 'All QR Codes', 'same-square' ), ); register_post_type( self::POST_TYPE, array( 'labels' => $labels, 'public' => false, 'publicly_queryable' => false, 'show_ui' => true, 'show_in_menu' => true, 'show_in_admin_bar' => true, 'show_in_rest' => false, 'exclude_from_search' => true, 'menu_icon' => 'dashicons-grid-view', 'menu_position' => 58, 'supports' => array( 'title' ), 'map_meta_cap' => true, 'capability_type' => 'post', ) ); } /** * Register public and editor assets. */ public function register_assets() { wp_register_script( 'smsqr-qrcode', SMSQR_PLUGIN_URL . 'assets/js/vendor/qrcode.js', array(), '2.0.4', true ); wp_register_script( 'smsqr-frontend', SMSQR_PLUGIN_URL . 'assets/js/frontend.js', array( 'smsqr-qrcode' ), SMSQR_VERSION, true ); wp_register_script( 'smsqr-admin', SMSQR_PLUGIN_URL . 'assets/js/admin.js', array( 'smsqr-frontend' ), SMSQR_VERSION, true ); wp_register_script( 'smsqr-block-editor', SMSQR_PLUGIN_URL . 'assets/js/block.js', array( 'wp-blocks', 'wp-block-editor', 'wp-components', 'wp-element', 'wp-i18n', 'smsqr-frontend' ), SMSQR_VERSION, true ); wp_register_style( 'smsqr-frontend', SMSQR_PLUGIN_URL . 'assets/css/frontend.css', array(), SMSQR_VERSION ); wp_register_style( 'smsqr-admin', SMSQR_PLUGIN_URL . 'assets/css/admin.css', array(), SMSQR_VERSION ); } /** * Register the dynamic QR block from metadata. */ public function register_block() { register_block_type( SMSQR_PLUGIN_DIR . 'blocks/qr-code', array( 'editor_script' => 'smsqr-block-editor', 'render_callback' => array( $this, 'render_block' ), ) ); } /** * Register the public redirect route. */ public function register_rewrite_rules() { self::register_rewrite_rules_definition(); } /** * Shared rewrite rule registration. */ private static function register_rewrite_rules_definition() { add_rewrite_rule( '^same-square/([a-z0-9]{12})/?$', 'index.php?' . self::QUERY_VAR . '=$matches[1]', 'top' ); } /** * Add the redirect token to public query variables. * * @param string[] $query_vars Public query variables. * @return string[] */ public function register_query_var( $query_vars ) { $query_vars[] = self::QUERY_VAR; return $query_vars; } /** * Redirect a valid QR scan and record an aggregate count. */ public function handle_redirect() { $token = get_query_var( self::QUERY_VAR ); // The public scan token cannot have a nonce; it is validated against a strict 12-character pattern below. // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! $token && isset( $_GET[ self::QUERY_VAR ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $token = sanitize_key( wp_unslash( $_GET[ self::QUERY_VAR ] ) ); } if ( ! is_string( $token ) || ! preg_match( '/^[a-z0-9]{12}$/', $token ) ) { return; } $code_ids = get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'ignore_sticky_posts' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, 'meta_key' => self::META_TOKEN, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- A unique token lookup is required for the redirect route. 'meta_value' => $token, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- A unique token lookup is required for the redirect route. ) ); if ( empty( $code_ids ) ) { $this->stop_scan( __( 'This QR code is not available.', 'same-square' ), 404 ); } $code_id = (int) $code_ids[0]; $status = $this->get_code_status( $code_id ); if ( 'scheduled' === $status ) { $this->stop_scan( __( 'This QR code is not active yet.', 'same-square' ), 404 ); } if ( 'expired' === $status ) { $this->stop_scan( __( 'This QR code has expired.', 'same-square' ), 410 ); } if ( 'active' !== $status ) { $this->stop_scan( __( 'This QR code is not available.', 'same-square' ), 404 ); } $destination = get_post_meta( $code_id, self::META_DESTINATION, true ); if ( ! is_string( $destination ) || ! $this->is_valid_destination( $destination ) ) { $this->stop_scan( __( 'This QR code does not have a valid destination.', 'same-square' ), 404 ); } if ( ! defined( 'DONOTCACHEPAGE' ) ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Standard cache-plugin interoperability constant. define( 'DONOTCACHEPAGE', true ); } $this->record_scan( $code_id ); nocache_headers(); header( 'X-Robots-Tag: noindex, nofollow, noarchive', true ); wp_redirect( $destination, 302, 'Same Square' ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- External destinations are the purpose of this redirect endpoint. exit; } /** * End a scan request with a small error response. * * @param string $message Message displayed to the visitor. * @param int $status HTTP status code. */ private function stop_scan( $message, $status ) { nocache_headers(); header( 'X-Robots-Tag: noindex, nofollow, noarchive', true ); wp_die( esc_html( $message ), esc_html__( 'QR code unavailable', 'same-square' ), array( 'response' => (int) $status ) ); } /** * Increment the privacy-friendly daily scan counter. * * @param int $code_id QR code post ID. */ private function record_scan( $code_id ) { global $wpdb; $table_name = $wpdb->prefix . 'smsqr_scans'; $scan_date = wp_date( 'Y-m-d' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- An atomic aggregate counter requires one direct write and stores no personal data. $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (code_id, scan_date, scans) VALUES (%d, %s, 1) ON DUPLICATE KEY UPDATE scans = scans + 1', $table_name, $code_id, $scan_date ) ); } /** * Return the total scans recorded for a code. * * @param int $code_id QR code post ID. * @return int */ public function get_total_scans( $code_id ) { global $wpdb; $table_name = $wpdb->prefix . 'smsqr_scans'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Aggregate data changes on every scan and must be current in the admin list. $total = $wpdb->get_var( $wpdb->prepare( 'SELECT SUM(scans) FROM %i WHERE code_id = %d', $table_name, $code_id ) ); return max( 0, (int) $total ); } /** * Return daily aggregate scans, including zero-value days. * * @param int $code_id QR code post ID. * @param int $days Number of recent days to return. * @return array */ public function get_daily_scans( $code_id, $days = 30 ) { global $wpdb; $days = min( 90, max( 1, absint( $days ) ) ); $table_name = $wpdb->prefix . 'smsqr_scans'; $today = new DateTimeImmutable( 'today', wp_timezone() ); $start = $today->modify( '-' . ( $days - 1 ) . ' days' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Admin analytics must reflect the latest aggregate scan counts. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT scan_date, scans FROM %i WHERE code_id = %d AND scan_date >= %s ORDER BY scan_date ASC', $table_name, $code_id, $start->format( 'Y-m-d' ) ), ARRAY_A ); $stored = array(); foreach ( $rows as $row ) { $stored[ $row['scan_date'] ] = max( 0, (int) $row['scans'] ); } $daily = array(); for ( $offset = 0; $offset < $days; $offset++ ) { $date = $start->modify( '+' . $offset . ' days' )->format( 'Y-m-d' ); $daily[ $date ] = isset( $stored[ $date ] ) ? $stored[ $date ] : 0; } return $daily; } /** * Return daily aggregate scans across every QR code. * * @param int $days Number of recent days to return. * @return array */ public function get_site_daily_scans( $days = 30 ) { global $wpdb; $days = min( 90, max( 1, absint( $days ) ) ); $table_name = $wpdb->prefix . 'smsqr_scans'; $today = new DateTimeImmutable( 'today', wp_timezone() ); $start = $today->modify( '-' . ( $days - 1 ) . ' days' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Admin analytics must reflect the latest aggregate scan counts. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT scan_date, SUM(scans) AS scans FROM %i WHERE scan_date >= %s GROUP BY scan_date ORDER BY scan_date ASC', $table_name, $start->format( 'Y-m-d' ) ), ARRAY_A ); $stored = array(); foreach ( $rows as $row ) { $stored[ $row['scan_date'] ] = max( 0, (int) $row['scans'] ); } $daily = array(); for ( $offset = 0; $offset < $days; $offset++ ) { $date = $start->modify( '+' . $offset . ' days' )->format( 'Y-m-d' ); $daily[ $date ] = isset( $stored[ $date ] ) ? $stored[ $date ] : 0; } return $daily; } /** * Return totals, recent activity, and last scan for every tracked code. * * @param int $days Recent period used for the comparison total. * @return array> */ public function get_code_scan_metrics( $days = 30 ) { global $wpdb; $days = min( 90, max( 1, absint( $days ) ) ); $table_name = $wpdb->prefix . 'smsqr_scans'; $today = new DateTimeImmutable( 'today', wp_timezone() ); $start = $today->modify( '-' . ( $days - 1 ) . ' days' )->format( 'Y-m-d' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Admin analytics must reflect the latest aggregate scan counts. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT code_id, SUM(scans) AS total_scans, SUM(CASE WHEN scan_date >= %s THEN scans ELSE 0 END) AS period_scans, MAX(scan_date) AS last_scan FROM %i GROUP BY code_id', $start, $table_name ), ARRAY_A ); $metrics = array(); foreach ( $rows as $row ) { $code_id = (int) $row['code_id']; $metrics[ $code_id ] = array( 'total' => max( 0, (int) $row['total_scans'] ), 'period' => max( 0, (int) $row['period_scans'] ), 'last_scan' => sanitize_text_field( $row['last_scan'] ), ); } return $metrics; } /** * Render the main scan analytics screen. */ public function render_analytics_page() { if ( ! current_user_can( 'edit_others_posts' ) ) { wp_die( esc_html__( 'You do not have permission to view scan analytics.', 'same-square' ) ); } $daily = $this->get_site_daily_scans( 30 ); $daily_values = array_values( $daily ); $metrics = $this->get_code_scan_metrics( 30 ); $total_scans = 0; foreach ( $metrics as $code_metrics ) { $total_scans += (int) $code_metrics['total']; } $today_scans = empty( $daily_values ) ? 0 : (int) end( $daily_values ); $seven_scans = array_sum( array_slice( $daily_values, -7 ) ); $month_scans = array_sum( $daily_values ); $posts = get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future' ), 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC', ) ); $rows = array(); foreach ( $posts as $post ) { $code_metrics = isset( $metrics[ $post->ID ] ) ? $metrics[ $post->ID ] : array( 'total' => 0, 'period' => 0, 'last_scan' => '', ); $rows[] = array( 'post' => $post, 'total' => (int) $code_metrics['total'], 'period' => (int) $code_metrics['period'], 'last_scan' => (string) $code_metrics['last_scan'], 'destination' => (string) get_post_meta( $post->ID, self::META_DESTINATION, true ), 'status' => $this->get_code_status( $post->ID ), ); } usort( $rows, static function ( $first, $second ) { if ( $first['period'] !== $second['period'] ) { return $second['period'] <=> $first['period']; } if ( $first['total'] !== $second['total'] ) { return $second['total'] <=> $first['total']; } return strcasecmp( $first['post']->post_title, $second['post']->post_title ); } ); ?>

render_stat_card( __( 'All-time scans', 'same-square' ), $total_scans ); ?> render_stat_card( __( 'Scans today', 'same-square' ), $today_scans ); ?> render_stat_card( __( 'Last 7 days', 'same-square' ), $seven_scans ); ?> render_stat_card( __( 'Last 30 days', 'same-square' ), $month_scans ); ?>

render_scan_chart( $daily ); ?>

post_title ? $row['post']->post_title : __( '(Untitled QR code)', 'same-square' ); $edit_link = get_edit_post_link( $row['post']->ID ); ?>
is_valid_destination( $row['destination'] ) ) : ?> — get_status_label( $row['status'] ) ); ?> format_scan_date( $row['last_scan'] ) ); ?>

$daily Daily aggregate scan counts. * @param bool $compact Whether to render the compact editor version. */ private function render_scan_chart( $daily, $compact = false ) { $maximum = empty( $daily ) ? 0 : max( $daily ); $dates = array_keys( $daily ); ?>
format_scan_date( reset( $dates ) ) ); ?> format_scan_date( end( $dates ) ) ); ?>
getTimestamp(), wp_timezone() ); } /** * Delete a code's aggregate scan rows when it is permanently deleted. * * @param int $post_id Post ID. * @param WP_Post $post Post object. */ public function delete_scan_data( $post_id, $post ) { if ( self::POST_TYPE !== $post->post_type ) { return; } global $wpdb; $table_name = $wpdb->prefix . 'smsqr_scans'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup belongs to the deleted code and has no cache representation. $wpdb->delete( $table_name, array( 'code_id' => (int) $post_id ), array( '%d' ) ); } /** * Add the QR code details meta box. */ public function add_meta_boxes() { add_meta_box( 'smsqr-details', __( 'QR Code Details', 'same-square' ), array( $this, 'render_meta_box' ), self::POST_TYPE, 'normal', 'high' ); add_meta_box( 'smsqr-print-card', __( 'Print Card Designer', 'same-square' ), array( $this, 'render_print_card_meta_box' ), self::POST_TYPE, 'normal', 'default' ); add_meta_box( 'smsqr-analytics', __( 'Scan Analytics', 'same-square' ), array( $this, 'render_analytics_meta_box' ), self::POST_TYPE, 'side', 'high' ); } /** * Render the QR code editing interface. * * @param WP_Post $post Current QR code post. */ public function render_meta_box( $post ) { $token = $this->get_or_create_token( $post->ID ); $scan_url = $this->get_scan_url( $post->ID ); $destination = (string) get_post_meta( $post->ID, self::META_DESTINATION, true ); $foreground = $this->get_color( $post->ID, self::META_FOREGROUND, '#111827' ); $background = $this->get_color( $post->ID, self::META_BACKGROUND, '#ffffff' ); $starts_at = $this->get_local_datetime_value( (int) get_post_meta( $post->ID, self::META_STARTS_AT, true ) ); $expires_at = $this->get_local_datetime_value( (int) get_post_meta( $post->ID, self::META_EXPIRES_AT, true ) ); wp_nonce_field( 'smsqr_save_code', 'smsqr_meta_nonce' ); ?>



*/ private function get_card_settings( $post_id ) { $layout = sanitize_key( get_post_meta( $post_id, self::META_CARD_LAYOUT, true ) ); $paper = sanitize_key( get_post_meta( $post_id, self::META_CARD_PAPER, true ) ); $orientation = sanitize_key( get_post_meta( $post_id, self::META_CARD_ORIENTATION, true ) ); $heading = (string) get_post_meta( $post_id, self::META_CARD_HEADING, true ); $logo_id = absint( get_post_meta( $post_id, self::META_CARD_LOGO_ID, true ) ); if ( ! in_array( $layout, array( 'poster', 'minimal', 'table' ), true ) ) { $layout = 'poster'; } if ( ! in_array( $paper, array( 'a4', 'letter', 'a5', '4x6' ), true ) ) { $paper = 'a4'; } if ( ! in_array( $orientation, array( 'portrait', 'landscape' ), true ) ) { $orientation = 'portrait'; } return array( 'layout' => $layout, 'heading' => '' !== $heading ? $heading : __( 'Scan me', 'same-square' ), 'description' => (string) get_post_meta( $post_id, self::META_CARD_DESCRIPTION, true ), 'paper' => $paper, 'orientation' => $orientation, 'show_name' => ! metadata_exists( 'post', $post_id, self::META_CARD_SHOW_NAME ) || '1' === get_post_meta( $post_id, self::META_CARD_SHOW_NAME, true ), 'show_url' => ! metadata_exists( 'post', $post_id, self::META_CARD_SHOW_URL ) || '1' === get_post_meta( $post_id, self::META_CARD_SHOW_URL, true ), 'logo_id' => $logo_id, 'logo_url' => $logo_id ? (string) wp_get_attachment_image_url( $logo_id, 'medium' ) : '', 'background' => $this->get_color( $post_id, self::META_CARD_BACKGROUND, '#ffffff' ), 'text' => $this->get_color( $post_id, self::META_CARD_TEXT, '#111827' ), ); } /** * Render the print card designer. * * @param WP_Post $post Current QR code post. */ public function render_print_card_meta_box( $post ) { $settings = $this->get_card_settings( $post->ID ); $scan_url = $this->get_scan_url( $post->ID ); $foreground = $this->get_color( $post->ID, self::META_FOREGROUND, '#111827' ); $background = $this->get_color( $post->ID, self::META_BACKGROUND, '#ffffff' ); $title = '' !== get_the_title( $post ) ? get_the_title( $post ) : __( 'Your QR code', 'same-square' ); ?>



>

>

>

>

>

get_daily_scans( $post->ID, 30 ); $daily_values = array_values( $daily ); $metrics = $this->get_code_scan_metrics( 30 ); $code_metrics = isset( $metrics[ $post->ID ] ) ? $metrics[ $post->ID ] : array( 'total' => 0, 'period' => 0, 'last_scan' => '', ); $today_scans = empty( $daily_values ) ? 0 : (int) end( $daily_values ); ?>
render_scan_chart( $daily, true ); ?>

format_scan_date( $code_metrics['last_scan'] ) ); ?>

→

post_type ) { return; } $this->get_or_create_token( $post_id ); $destination_input = isset( $_POST['smsqr_destination'] ) ? sanitize_text_field( wp_unslash( $_POST['smsqr_destination'] ) ) : ''; $destination = esc_url_raw( $destination_input, array( 'http', 'https' ) ); if ( '' !== $destination_input && ! $this->is_valid_destination( $destination ) ) { delete_post_meta( $post_id, self::META_DESTINATION ); $this->set_admin_notice( __( 'Enter a complete destination URL beginning with http:// or https://.', 'same-square' ), 'error' ); } elseif ( '' === $destination ) { delete_post_meta( $post_id, self::META_DESTINATION ); } else { update_post_meta( $post_id, self::META_DESTINATION, $destination ); } $foreground = isset( $_POST['smsqr_foreground'] ) ? sanitize_hex_color( wp_unslash( $_POST['smsqr_foreground'] ) ) : ''; $background = isset( $_POST['smsqr_background'] ) ? sanitize_hex_color( wp_unslash( $_POST['smsqr_background'] ) ) : ''; update_post_meta( $post_id, self::META_FOREGROUND, $foreground ? $foreground : '#111827' ); update_post_meta( $post_id, self::META_BACKGROUND, $background ? $background : '#ffffff' ); $starts_input = isset( $_POST['smsqr_starts_at'] ) ? sanitize_text_field( wp_unslash( $_POST['smsqr_starts_at'] ) ) : ''; $expires_input = isset( $_POST['smsqr_expires_at'] ) ? sanitize_text_field( wp_unslash( $_POST['smsqr_expires_at'] ) ) : ''; $starts_at = $this->parse_local_datetime( $starts_input ); $expires_at = $this->parse_local_datetime( $expires_input ); $this->save_timestamp( $post_id, self::META_STARTS_AT, $starts_at ); if ( $starts_at && $expires_at && $expires_at <= $starts_at ) { delete_post_meta( $post_id, self::META_EXPIRES_AT ); $this->set_admin_notice( __( 'The expiration must be later than the start time. The expiration was not saved.', 'same-square' ), 'warning' ); } else { $this->save_timestamp( $post_id, self::META_EXPIRES_AT, $expires_at ); } if ( isset( $_POST['smsqr_card_layout'] ) ) { $layout = sanitize_key( wp_unslash( $_POST['smsqr_card_layout'] ) ); $paper = isset( $_POST['smsqr_card_paper'] ) ? sanitize_key( wp_unslash( $_POST['smsqr_card_paper'] ) ) : 'a4'; $orient = isset( $_POST['smsqr_card_orientation'] ) ? sanitize_key( wp_unslash( $_POST['smsqr_card_orientation'] ) ) : 'portrait'; update_post_meta( $post_id, self::META_CARD_LAYOUT, in_array( $layout, array( 'poster', 'minimal', 'table' ), true ) ? $layout : 'poster' ); update_post_meta( $post_id, self::META_CARD_PAPER, in_array( $paper, array( 'a4', 'letter', 'a5', '4x6' ), true ) ? $paper : 'a4' ); update_post_meta( $post_id, self::META_CARD_ORIENTATION, in_array( $orient, array( 'portrait', 'landscape' ), true ) ? $orient : 'portrait' ); update_post_meta( $post_id, self::META_CARD_HEADING, isset( $_POST['smsqr_card_heading'] ) ? sanitize_text_field( wp_unslash( $_POST['smsqr_card_heading'] ) ) : '' ); update_post_meta( $post_id, self::META_CARD_DESCRIPTION, isset( $_POST['smsqr_card_description'] ) ? sanitize_textarea_field( wp_unslash( $_POST['smsqr_card_description'] ) ) : '' ); update_post_meta( $post_id, self::META_CARD_SHOW_NAME, isset( $_POST['smsqr_card_show_name'] ) ? '1' : '0' ); update_post_meta( $post_id, self::META_CARD_SHOW_URL, isset( $_POST['smsqr_card_show_url'] ) ? '1' : '0' ); $card_background = isset( $_POST['smsqr_card_background'] ) ? sanitize_hex_color( wp_unslash( $_POST['smsqr_card_background'] ) ) : ''; $card_text = isset( $_POST['smsqr_card_text'] ) ? sanitize_hex_color( wp_unslash( $_POST['smsqr_card_text'] ) ) : ''; $logo_id = isset( $_POST['smsqr_card_logo_id'] ) ? absint( $_POST['smsqr_card_logo_id'] ) : 0; update_post_meta( $post_id, self::META_CARD_BACKGROUND, $card_background ? $card_background : '#ffffff' ); update_post_meta( $post_id, self::META_CARD_TEXT, $card_text ? $card_text : '#111827' ); if ( $logo_id && wp_attachment_is_image( $logo_id ) ) { update_post_meta( $post_id, self::META_CARD_LOGO_ID, $logo_id ); } else { delete_post_meta( $post_id, self::META_CARD_LOGO_ID ); } } } /** * Store or remove a timestamp meta value. * * @param int $post_id Post ID. * @param string $meta_key Meta key. * @param int $value UTC timestamp, or zero. */ private function save_timestamp( $post_id, $meta_key, $value ) { if ( $value > 0 ) { update_post_meta( $post_id, $meta_key, $value ); } else { delete_post_meta( $post_id, $meta_key ); } } /** * Convert a datetime-local value in the site timezone to a UTC timestamp. * * @param string $value Datetime-local input value. * @return int */ public function parse_local_datetime( $value ) { if ( '' === $value ) { return 0; } $date = DateTimeImmutable::createFromFormat( '!Y-m-d\TH:i', $value, wp_timezone() ); if ( false === $date || $date->format( 'Y-m-d\TH:i' ) !== $value ) { return 0; } return $date->getTimestamp(); } /** * Format a stored timestamp for a datetime-local field. * * @param int $timestamp UTC timestamp. * @return string */ private function get_local_datetime_value( $timestamp ) { if ( $timestamp <= 0 ) { return ''; } return wp_date( 'Y-m-d\TH:i', $timestamp, wp_timezone() ); } /** * Validate an HTTP or HTTPS destination. * * @param string $destination Destination URL. * @return bool */ public function is_valid_destination( $destination ) { $scheme = wp_parse_url( $destination, PHP_URL_SCHEME ); $host = wp_parse_url( $destination, PHP_URL_HOST ); return in_array( $scheme, array( 'http', 'https' ), true ) && is_string( $host ) && '' !== $host; } /** * Get a saved color or its fallback. * * @param int $post_id Post ID. * @param string $meta_key Color meta key. * @param string $fallback Default color. * @return string */ private function get_color( $post_id, $meta_key, $fallback ) { $color = sanitize_hex_color( get_post_meta( $post_id, $meta_key, true ) ); return $color ? $color : $fallback; } /** * Return or create a stable public token. * * @param int $post_id Post ID. * @return string */ private function get_or_create_token( $post_id ) { $token = (string) get_post_meta( $post_id, self::META_TOKEN, true ); if ( preg_match( '/^[a-z0-9]{12}$/', $token ) ) { return $token; } $token = $this->generate_unique_token(); update_post_meta( $post_id, self::META_TOKEN, $token ); return $token; } /** * Generate a unique 12-character token. * * @return string */ public function generate_unique_token() { for ( $attempt = 0; $attempt < 10; $attempt++ ) { try { $token = bin2hex( random_bytes( 6 ) ); } catch ( Exception $exception ) { $token = strtolower( wp_generate_password( 12, false, false ) ); } $existing = get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => 'any', 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, 'meta_key' => self::META_TOKEN, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Token uniqueness must include every saved QR code. 'meta_value' => $token, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Token uniqueness must include every saved QR code. ) ); if ( empty( $existing ) ) { return $token; } } return substr( md5( wp_generate_uuid4() ), 0, 12 ); } /** * Return the stable scan URL for a code. * * @param int $post_id QR code post ID. * @return string */ public function get_scan_url( $post_id ) { $token = (string) get_post_meta( $post_id, self::META_TOKEN, true ); if ( ! preg_match( '/^[a-z0-9]{12}$/', $token ) ) { return ''; } if ( get_option( 'permalink_structure' ) ) { return home_url( user_trailingslashit( 'same-square/' . $token ) ); } return add_query_arg( self::QUERY_VAR, $token, home_url( '/' ) ); } /** * Return the operational status of a code. * * @param int $post_id QR code post ID. * @return string active, draft, missing, scheduled, or expired. */ public function get_code_status( $post_id ) { if ( 'publish' !== get_post_status( $post_id ) ) { return 'draft'; } $destination = (string) get_post_meta( $post_id, self::META_DESTINATION, true ); if ( ! $this->is_valid_destination( $destination ) ) { return 'missing'; } $now = time(); $starts_at = (int) get_post_meta( $post_id, self::META_STARTS_AT, true ); $expires_at = (int) get_post_meta( $post_id, self::META_EXPIRES_AT, true ); if ( $starts_at > $now ) { return 'scheduled'; } if ( $expires_at > 0 && $expires_at <= $now ) { return 'expired'; } return 'active'; } /** * Human-readable status label. * * @param string $status Status key. * @return string */ private function get_status_label( $status ) { $labels = array( 'active' => __( 'Active', 'same-square' ), 'draft' => __( 'Draft', 'same-square' ), 'missing' => __( 'Needs URL', 'same-square' ), 'scheduled' => __( 'Scheduled', 'same-square' ), 'expired' => __( 'Expired', 'same-square' ), ); return isset( $labels[ $status ] ) ? $labels[ $status ] : $labels['draft']; } /** * Load assets on Same Square admin screens. * * @param string $hook_suffix Current admin page hook. */ public function enqueue_admin_assets( $hook_suffix ) { $screen = get_current_screen(); $is_code_page = $screen && self::POST_TYPE === $screen->post_type; $is_analytics = 'smsqr_code_page_smsqr-analytics' === $hook_suffix; $is_edit_page = in_array( $hook_suffix, array( 'post.php', 'post-new.php' ), true ); if ( ! $is_code_page && ! $is_analytics ) { return; } wp_enqueue_style( 'smsqr-admin' ); if ( ! $is_code_page ) { return; } wp_enqueue_script( 'smsqr-admin' ); wp_enqueue_style( 'smsqr-frontend' ); if ( $is_edit_page ) { wp_enqueue_media(); } wp_localize_script( 'smsqr-admin', 'smsqrAdmin', array( 'copied' => __( 'Copied', 'same-square' ), 'copy' => __( 'Copy', 'same-square' ), 'fileName' => __( 'same-square-qr-code', 'same-square' ), 'printHeading' => __( 'Scan me', 'same-square' ), 'printFallback' => __( 'Open this address:', 'same-square' ), 'printStylesheet' => SMSQR_PLUGIN_URL . 'assets/css/print-card.css', 'chooseLogo' => __( 'Choose a card logo', 'same-square' ), 'useLogo' => __( 'Use this logo', 'same-square' ), 'hookSuffix' => $hook_suffix, ) ); } /** * Supply QR codes to the block editor and load preview styles. */ public function prepare_block_editor() { $posts = get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC', ) ); $codes = array(); foreach ( $posts as $post ) { $scan_url = $this->get_scan_url( $post->ID ); if ( '' === $scan_url ) { continue; } $codes[] = array( 'id' => $post->ID, 'title' => get_the_title( $post ), 'url' => $scan_url, 'foreground' => $this->get_color( $post->ID, self::META_FOREGROUND, '#111827' ), 'background' => $this->get_color( $post->ID, self::META_BACKGROUND, '#ffffff' ), ); } wp_localize_script( 'smsqr-block-editor', 'smsqrBlockData', array( 'codes' => $codes ) ); wp_enqueue_style( 'smsqr-frontend' ); } /** * Customize the QR code list columns. * * @param array $columns Existing columns. * @return array */ public function manage_columns( $columns ) { $new_columns = array(); foreach ( $columns as $key => $label ) { $new_columns[ $key ] = $label; if ( 'title' === $key ) { $new_columns['smsqr_qr'] = __( 'QR Code', 'same-square' ); $new_columns['smsqr_destination'] = __( 'Destination', 'same-square' ); $new_columns['smsqr_status'] = __( 'Status', 'same-square' ); $new_columns['smsqr_scans'] = __( 'Scans', 'same-square' ); $new_columns['smsqr_shortcode'] = __( 'Shortcode', 'same-square' ); } } return $new_columns; } /** * Render a custom QR code list column. * * @param string $column Column key. * @param int $post_id Post ID. */ public function render_column( $column, $post_id ) { if ( 'smsqr_qr' === $column ) { $scan_url = $this->get_scan_url( $post_id ); if ( $scan_url ) { printf( '
', esc_attr( $scan_url ), esc_attr( $this->get_color( $post_id, self::META_FOREGROUND, '#111827' ) ), esc_attr( $this->get_color( $post_id, self::META_BACKGROUND, '#ffffff' ) ), esc_attr( get_the_title( $post_id ) ) ); } return; } if ( 'smsqr_destination' === $column ) { $destination = (string) get_post_meta( $post_id, self::META_DESTINATION, true ); if ( $this->is_valid_destination( $destination ) ) { printf( '%2$s', esc_url( $destination ), esc_html( $destination ) ); } else { echo '—'; } return; } if ( 'smsqr_status' === $column ) { $status = $this->get_code_status( $post_id ); printf( '%2$s', esc_attr( $status ), esc_html( $this->get_status_label( $status ) ) ); return; } if ( 'smsqr_scans' === $column ) { printf( '%2$s', esc_url( get_edit_post_link( $post_id ) . '#smsqr-analytics' ), esc_html( number_format_i18n( $this->get_total_scans( $post_id ) ) ) ); return; } if ( 'smsqr_shortcode' === $column ) { printf( '[same_square_qr id="%d"]', (int) $post_id ); } } /** * Change the title field placeholder for QR codes. * * @param string $placeholder Current placeholder. * @param WP_Post $post Current post. * @return string */ public function title_placeholder( $placeholder, $post ) { if ( self::POST_TYPE === $post->post_type ) { return __( 'Name this QR code, for example “Lunch menu”', 'same-square' ); } return $placeholder; } /** * Customize post update messages. * * @param array> $messages Post type messages. * @return array> */ public function post_updated_messages( $messages ) { $messages[ self::POST_TYPE ] = array( 0 => '', 1 => __( 'QR code updated.', 'same-square' ), 2 => __( 'Custom field updated.', 'same-square' ), 3 => __( 'Custom field deleted.', 'same-square' ), 4 => __( 'QR code updated.', 'same-square' ), 5 => isset( $_GET['revision'] ) ? sprintf( /* translators: %s: Revision date. */ __( 'QR code restored to revision from %s.', 'same-square' ), wp_post_revision_title( (int) $_GET['revision'], false ) ) : false, // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Core provides this display-only revision value. 6 => __( 'QR code published.', 'same-square' ), 7 => __( 'QR code saved.', 'same-square' ), 8 => __( 'QR code submitted.', 'same-square' ), 9 => __( 'QR code scheduled.', 'same-square' ), 10 => __( 'QR code draft updated.', 'same-square' ), ); return $messages; } /** * Render a QR code from shortcode attributes. * * @param array $attributes Shortcode attributes. * @return string */ public function render_shortcode( $attributes ) { $attributes = shortcode_atts( array( 'id' => 0, 'size' => 256, 'label' => 'false', ), $attributes, 'same_square_qr' ); return $this->render_qr( absint( $attributes['id'] ), $this->sanitize_size( $attributes['size'] ), filter_var( $attributes['label'], FILTER_VALIDATE_BOOLEAN ), '' ); } /** * Render the dynamic block. * * @param array $attributes Block attributes. * @return string */ public function render_block( $attributes ) { $code_id = isset( $attributes['codeId'] ) ? absint( $attributes['codeId'] ) : 0; $size = isset( $attributes['size'] ) ? $this->sanitize_size( $attributes['size'] ) : 256; $show_label = ! empty( $attributes['showLabel'] ); $classes = 'wp-block-same-square-qr-code'; if ( ! empty( $attributes['align'] ) ) { $classes .= ' align' . sanitize_html_class( $attributes['align'] ); } return $this->render_qr( $code_id, $size, $show_label, $classes ); } /** * Render a QR code figure. * * @param int $code_id QR code post ID. * @param int $size Display size in pixels. * @param bool $show_label Whether to show the saved title. * @param string $classes Additional CSS classes. * @return string */ private function render_qr( $code_id, $size, $show_label, $classes ) { if ( self::POST_TYPE !== get_post_type( $code_id ) || 'publish' !== get_post_status( $code_id ) ) { return ''; } $scan_url = $this->get_scan_url( $code_id ); if ( '' === $scan_url ) { return ''; } $title = get_the_title( $code_id ); $foreground = $this->get_color( $code_id, self::META_FOREGROUND, '#111827' ); $background = $this->get_color( $code_id, self::META_BACKGROUND, '#ffffff' ); $class_name = trim( 'smsqr-figure ' . $classes ); wp_enqueue_script( 'smsqr-frontend' ); wp_enqueue_style( 'smsqr-frontend' ); ob_start(); ?>
$message, 'type' => $type, ), MINUTE_IN_SECONDS ); } /** * Display a saved administrator notice. */ public function render_admin_notice() { $key = 'smsqr_notice_' . get_current_user_id(); $notice = get_transient( $key ); if ( ! is_array( $notice ) || empty( $notice['message'] ) ) { return; } delete_transient( $key ); $type = isset( $notice['type'] ) && in_array( $notice['type'], array( 'error', 'warning', 'success', 'info' ), true ) ? $notice['type'] : 'info'; printf( '

%2$s

', esc_attr( $type ), esc_html( $notice['message'] ) ); } /** * Add a QR codes link to the Plugins screen. * * @param string[] $links Existing links. * @return string[] */ public function add_plugin_action_links( $links ) { $url = admin_url( 'edit.php?post_type=' . self::POST_TYPE ); array_unshift( $links, '' . esc_html__( 'QR Codes', 'same-square' ) . '' ); return $links; } }