is too big"). * * @package Pinora */ defined( 'ABSPATH' ) || exit; /** Post type holding the notes. */ const PINORA_CPT = 'pinora_note'; /** Allowed values, in display order. */ const PINORA_SEVERITIES = array( 'blocker', 'major', 'minor', 'nice' ); const PINORA_STATUSES = array( 'open', 'in_progress', 'fixed', 'wontfix' ); /** * Note fields and their type. The keys are the JSON names used by the browser. */ const PINORA_FIELDS = array( 'description' => 'text', 'author' => 'string', 'url' => 'path', 'pageTitle' => 'string', 'sectionLabel' => 'string', 'selector' => 'string', 'severity' => 'severity', 'status' => 'status', 'fixNote' => 'text', 'fixedAt' => 'string', 'x' => 'number', 'y' => 'number', 'scrollY' => 'number', 'relX' => 'number', 'relY' => 'number', 'viewportWidth' => 'number', 'viewportHeight' => 'number', 'userAgent' => 'string', ); /** * Translated labels for severities and statuses. * * @return array{severities: array, statuses: array} */ function pinora_labels() { return array( 'severities' => array( 'blocker' => __( 'Blocker', 'pinora' ), 'major' => __( 'Major', 'pinora' ), 'minor' => __( 'Minor', 'pinora' ), 'nice' => __( 'Nice to have', 'pinora' ), ), 'statuses' => array( 'open' => __( 'To do', 'pinora' ), 'in_progress' => __( 'In progress', 'pinora' ), 'fixed' => __( 'Fixed', 'pinora' ), 'wontfix' => __( "Won't fix", 'pinora' ), ), ); } /* ── Shared key ──────────────────────────────────────────────────────────── */ /** * Returns the review key. * * The `PINORA_KEY` constant wins when defined. Otherwise a key is generated on * first use and kept in a non-autoloaded option: nothing to edit in * `wp-config.php` to get started. * * @return string */ function pinora_key() { if ( defined( 'PINORA_KEY' ) && '' !== (string) PINORA_KEY ) { return (string) PINORA_KEY; } $key = (string) get_option( 'pinora_key', '' ); if ( '' === $key ) { $key = wp_generate_password( 20, false, false ); add_option( 'pinora_key', $key, '', false ); } return $key; } /** * Whether the key comes from the `PINORA_KEY` constant. * * @return bool */ function pinora_key_is_constant() { return defined( 'PINORA_KEY' ) && '' !== (string) PINORA_KEY; } /** * Capability required to manage the plugin and to see the key. * * @return string */ function pinora_capability() { /** * Filters the capability needed for the settings page and the toolbar links. * * @param string $capability Default `manage_options`. */ return (string) apply_filters( 'pinora_capability', 'manage_options' ); } /* ── Storage ─────────────────────────────────────────────────────────────── */ /** * Registers the post type. Hidden from visitors, but reachable in the admin to * inspect or fix a note by hand. */ function pinora_register_post_type() { register_post_type( PINORA_CPT, array( 'label' => __( 'Feedback notes', 'pinora' ), 'labels' => array( 'name' => __( 'Feedback notes', 'pinora' ), 'singular_name' => __( 'Feedback note', 'pinora' ), ), 'public' => false, 'publicly_queryable' => false, 'exclude_from_search' => true, 'show_ui' => true, 'show_in_menu' => false, 'show_in_rest' => false, 'supports' => array( 'title', 'custom-fields' ), 'rewrite' => false, 'query_var' => false, ) ); } add_action( 'init', 'pinora_register_post_type' ); /** * Sanitises a value according to its type in `PINORA_FIELDS`. * * @param string $type Field type. * @param mixed $value Raw value. * @return mixed|null Null when the value must be ignored. */ function pinora_sanitize( $type, $value ) { if ( null === $value || is_array( $value ) || is_object( $value ) ) { return null; } switch ( $type ) { case 'text': // Plain text kept as typed. Safe: the browser always renders it // with `textContent`, and WordPress with the `esc_*` functions. $text = wp_check_invalid_utf8( str_replace( "\0", '', (string) $value ) ); return mb_substr( trim( str_replace( "\r\n", "\n", $text ) ), 0, 5000 ); case 'string': return mb_substr( sanitize_text_field( (string) $value ), 0, 500 ); case 'path': // Only the path matters: a page groups its notes whatever the // query string. $path = wp_parse_url( (string) $value, PHP_URL_PATH ); return $path ? mb_substr( sanitize_text_field( $path ), 0, 500 ) : '/'; case 'severity': return in_array( $value, PINORA_SEVERITIES, true ) ? $value : null; case 'status': return in_array( $value, PINORA_STATUSES, true ) ? $value : null; case 'number': return is_numeric( $value ) ? round( (float) $value, 4 ) : null; } return null; } /** * Turns a post into a note, in the shape the browser expects. * * @param WP_Post $post Post of the `pinora_note` type. * @return array */ function pinora_format( $post ) { $note = array( 'id' => (int) $post->ID, 'createdAt' => mysql2date( 'c', $post->post_date_gmt, false ), 'updatedAt' => mysql2date( 'c', $post->post_modified_gmt, false ), ); foreach ( PINORA_FIELDS as $field => $type ) { $value = get_post_meta( $post->ID, '_pinora_' . $field, true ); if ( '' === $value ) { continue; } $note[ $field ] = 'number' === $type ? (float) $value : $value; } $screenshot = get_post_meta( $post->ID, '_pinora_screenshot', true ); if ( $screenshot ) { $note['screenshot'] = pinora_screenshot_url( $screenshot ); } $note['severity'] = $note['severity'] ?? 'major'; $note['status'] = $note['status'] ?? 'open'; /** * Filters a note before it is sent to the browser or to Slack. * * @param array $note Formatted note. * @param WP_Post $post Underlying post. */ return apply_filters( 'pinora_note', $note, $post ); } /** * Saves the given fields on a note. An empty string clears a text field. * * @param int $id Post ID. * @param array $data Received fields. */ function pinora_save_fields( $id, $data ) { foreach ( PINORA_FIELDS as $field => $type ) { if ( ! array_key_exists( $field, $data ) ) { continue; } if ( '' === $data[ $field ] && in_array( $type, array( 'text', 'string' ), true ) ) { delete_post_meta( $id, '_pinora_' . $field ); continue; } $value = pinora_sanitize( $type, $data[ $field ] ); if ( null !== $value ) { update_post_meta( $id, '_pinora_' . $field, wp_slash( $value ) ); } } } /** * Admin-friendly title: the start of the note. * * @param string $description Note text. * @return string */ function pinora_title( $description ) { $title = wp_strip_all_tags( $description ); return mb_strlen( $title ) > 70 ? mb_substr( $title, 0, 69 ) . '…' : $title; } /** * Number of stored notes. * * @return int */ function pinora_count() { $counts = wp_count_posts( PINORA_CPT ); return isset( $counts->publish ) ? (int) $counts->publish : 0; }