dashboards = $dashboards; $this->cache = $cache; } /** * Hooks the screen into WordPress. */ public function register(): void { $type = Dashboard_Post_Type::NAME; add_action( 'add_meta_boxes_' . $type, array( $this, 'add_meta_boxes' ) ); add_action( 'save_post_' . $type, array( $this, 'save' ), 5, 2 ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); add_action( 'admin_notices', array( $this, 'show_notices' ) ); add_filter( 'manage_' . $type . '_posts_columns', array( $this, 'columns' ) ); add_action( 'manage_' . $type . '_posts_custom_column', array( $this, 'column' ), 10, 2 ); add_filter( 'post_row_actions', array( $this, 'row_actions' ), 10, 2 ); add_filter( 'enter_title_here', array( $this, 'title_placeholder' ), 10, 2 ); add_filter( 'bulk_actions-edit-' . $type, array( $this, 'bulk_actions' ) ); } /** * Layout names shown to administrators. * * @return array */ public static function layouts(): array { return array( 'command' => __( 'Command Center', 'feedmarquee' ), 'cards' => __( 'Featured Cards', 'feedmarquee' ), 'list' => __( 'Headline Wall', 'feedmarquee' ), ); } /** * Colour palettes shown to administrators. * * @return array */ public static function palettes(): array { return array( 'midnight' => __( 'Midnight', 'feedmarquee' ), 'ocean' => __( 'Ocean', 'feedmarquee' ), 'ember' => __( 'Ember', 'feedmarquee' ), 'forest' => __( 'Forest', 'feedmarquee' ), 'light' => __( 'Light', 'feedmarquee' ), ); } /** * Queues a notice for the current user's next admin page. * * @param string $type `success`, `warning` or `error`. * @param string $message Plain-text message. */ public static function notify( string $type, string $message ): void { $key = 'pulsecast_notices_' . get_current_user_id(); $notices = get_transient( $key ); $notices = is_array( $notices ) ? $notices : array(); $notices[] = array( 'type' => $type, 'message' => $message, ); set_transient( $key, $notices, 5 * MINUTE_IN_SECONDS ); } /** * Prints and clears queued notices. */ public function show_notices(): void { $screen = get_current_screen(); // Only on FeedMarquee's own screens, so nothing appears on unrelated admin pages. if ( null === $screen || Dashboard_Post_Type::NAME !== $screen->post_type ) { return; } $key = 'pulsecast_notices_' . get_current_user_id(); $notices = get_transient( $key ); if ( ! is_array( $notices ) ) { return; } delete_transient( $key ); foreach ( $notices as $notice ) { $type = in_array( $notice['type'], array( 'success', 'warning', 'error' ), true ) ? $notice['type'] : 'info'; printf( '

%2$s

', esc_attr( $type ), esc_html( $notice['message'] ) ); } } /** * Adds the meta boxes. */ public function add_meta_boxes(): void { $type = Dashboard_Post_Type::NAME; add_meta_box( 'pulsecast_link', __( 'Display link', 'feedmarquee' ), array( $this, 'render_link' ), $type, 'side', 'high' ); add_meta_box( 'pulsecast_display', __( 'Display', 'feedmarquee' ), array( $this, 'render_display' ), $type, 'side', 'default' ); add_meta_box( 'pulsecast_feeds', __( 'RSS feeds', 'feedmarquee' ), array( $this, 'render_feeds' ), $type, 'normal', 'high' ); } /** * Placeholder for the title field. * * @param string $text Default placeholder. * @param \WP_Post $post Post. */ public function title_placeholder( string $text, \WP_Post $post ): string { return Dashboard_Post_Type::NAME === $post->post_type ? __( 'Dashboard name, for example Lobby TV', 'feedmarquee' ) : $text; } /** * Loads the admin script and styles on FeedMarquee screens only. * * @param string $hook Admin page hook. */ public function enqueue( string $hook ): void { $screen = get_current_screen(); if ( null === $screen || Dashboard_Post_Type::NAME !== $screen->post_type || ! in_array( $hook, array( 'post.php', 'post-new.php', 'edit.php' ), true ) ) { return; } // WordPress's bundled colour picker: no external dependency. wp_enqueue_style( 'wp-color-picker' ); wp_enqueue_style( 'pulsecast-admin', plugins_url( 'assets/css/admin.css', PULSECAST_FILE ), array( 'wp-color-picker' ), PULSECAST_VERSION ); wp_enqueue_script( 'pulsecast-admin', plugins_url( 'assets/js/admin.js', PULSECAST_FILE ), array( 'jquery', 'wp-color-picker' ), PULSECAST_VERSION, true ); } /** * Display link box: open button, copyable URL, or a clear "not public" message. * * @param \WP_Post $post Dashboard post. */ public function render_link( \WP_Post $post ): void { $dashboard = $this->dashboards->find( $post->ID ); if ( null === $dashboard || ! $dashboard->enabled ) { echo '

' . esc_html__( 'Not public yet.', 'feedmarquee' ) . ' ' . esc_html__( 'This dashboard is unpublished, so nobody can open it. Add your feeds, then click Publish to get its display link.', 'feedmarquee' ) . '

'; return; } $url = Display_Route::url( $dashboard->key ); $reset = wp_nonce_url( admin_url( 'admin-post.php?action=pulsecast_reset_link&dashboard=' . $post->ID ), 'pulsecast_reset_link_' . $post->ID ); echo '

' . esc_html__( 'Open dashboard', 'feedmarquee' ) . '

'; echo '

' . '

'; echo '

'; echo '

' . esc_html__( 'Open this address on a TV or browser. Anyone with the link can view the dashboard, so only share it where you want it shown.', 'feedmarquee' ) . '

'; echo '

' . esc_html__( 'Create a new link', 'feedmarquee' ) . '

'; } /** * Display settings box. * * @param \WP_Post $post Dashboard post. */ public function render_display( \WP_Post $post ): void { $dashboard = $this->dashboards->find( $post->ID ); $settings = null === $dashboard ? Settings_Schema::defaults() : $dashboard->settings; $intervals = array( 5, 10, 15, 30, 60, 120, 360, 1440 ); if ( ! in_array( (int) $settings['refresh_interval'], $intervals, true ) ) { $intervals[] = (int) $settings['refresh_interval']; sort( $intervals ); } echo '


'; echo '


'; echo '


'; echo '


' . '
' . '' . '' . '' . esc_html__( 'Command Center only.', 'feedmarquee' ) . '

'; echo '


' . '

'; } /** * Feeds box: editable rows with health, and a refresh link. * * @param \WP_Post $post Dashboard post. */ public function render_feeds( \WP_Post $post ): void { $dashboard = $this->dashboards->find( $post->ID ); $feeds = null === $dashboard ? array() : $dashboard->feeds; $interval = null === $dashboard ? 900 : (int) $dashboard->settings['refresh_interval'] * 60; $now = time(); $enabled = null !== $dashboard && $dashboard->enabled; $age = $now - (int) strtotime( $post->post_modified_gmt . ' UTC' ); wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD ); echo '

' . esc_html__( 'Add the address of each RSS or Atom feed you want to show. A label is optional and replaces the feed\'s own name on the display. To remove a feed, click Remove and then save.', 'feedmarquee' ) . '

'; echo ''; $index = 0; foreach ( $feeds as $feed ) { $this->render_row( $index++, $feed['url'], $feed['label'], $feed['color'] ?? '', $this->health( $feed['url'], $interval, $now, $enabled, $age ) ); } for ( $i = 0; $i < self::BLANK_ROWS; $i++ ) { $this->render_row( $index++, '', '', '', '' ); } echo '
' . esc_html__( 'Feed address', 'feedmarquee' ) . '' . esc_html__( 'Label (optional)', 'feedmarquee' ) . '' . esc_html__( 'Color', 'feedmarquee' ) . '' . esc_html__( 'Status', 'feedmarquee' ) . '
'; echo ''; echo '

'; if ( array() !== $feeds ) { $refresh = wp_nonce_url( admin_url( 'admin-post.php?action=pulsecast_refresh&dashboard=' . $post->ID ), 'pulsecast_refresh_' . $post->ID ); echo ' ' . esc_html__( 'Refresh feeds now', 'feedmarquee' ) . ''; } echo '

'; } /** * Saves feeds and settings when a dashboard is saved. * * @param int $post_id Post ID. * @param \WP_Post $post Post. */ public function save( int $post_id, \WP_Post $post ): void { if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || wp_is_post_revision( $post_id ) || Dashboard_Post_Type::NAME !== $post->post_type ) { return; } $nonce = isset( $_POST[ self::NONCE_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::NONCE_FIELD ] ) ) : ''; if ( '' === $nonce || ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) || ! current_user_can( 'edit_post', $post_id ) ) { return; } // Each value is validated by the repository (feeds) and the settings schema before it is stored. $feeds = isset( $_POST['pulsecast_feeds'] ) ? wp_unslash( $_POST['pulsecast_feeds'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Missing -- Nonce verified above; sanitized in Dashboard_Repository::set_feeds(). $settings = isset( $_POST['pulsecast_settings'] ) && is_array( $_POST['pulsecast_settings'] ) ? wp_unslash( $_POST['pulsecast_settings'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Missing -- As above; Settings_Schema::sanitize() validates. $this->dashboards->set_settings( $post_id, $settings ); $errors = $this->dashboards->set_feeds( $post_id, $feeds ); /** * Fires after a dashboard's feeds and settings are stored. * * @param int $post_id Dashboard ID. */ do_action( 'pulsecast_dashboard_saved', $post_id ); $rows = is_array( $feeds ) ? array_values( $feeds ) : array(); if ( 'publish' === $post->post_status ) { foreach ( null === $this->dashboards->find( $post_id ) ? array() : $this->dashboards->find( $post_id )->feeds as $feed ) { if ( null === $this->cache->get( $feed['url'] ) ) { self::notify( 'info', __( 'FeedMarquee is loading your feeds. This usually takes under a minute, and the status below will update on its own.', 'feedmarquee' ) ); break; } } } foreach ( $errors as $index => $code ) { $url = is_array( $rows[ $index ] ?? null ) && is_string( $rows[ $index ]['url'] ?? null ) ? $rows[ $index ]['url'] : ''; /* translators: 1: feed address, 2: explanation. */ self::notify( 'error', sprintf( __( 'Feed not saved: %1$s. %2$s', 'feedmarquee' ), '' !== $url ? $url : __( '(no address)', 'feedmarquee' ), Messages::feed_error( $code ) ) ); } } /** * List columns. * * @param array $columns Columns. * @return array */ public function columns( array $columns ): array { return array( 'cb' => $columns['cb'] ?? '', 'title' => __( 'Dashboard', 'feedmarquee' ), 'pulsecast_layout' => __( 'Layout', 'feedmarquee' ), 'pulsecast_feeds' => __( 'Feeds', 'feedmarquee' ), 'pulsecast_health' => __( 'Feed health', 'feedmarquee' ), 'pulsecast_display' => __( 'Display', 'feedmarquee' ), 'date' => $columns['date'] ?? __( 'Date', 'feedmarquee' ), ); } /** * Column content. * * @param string $column Column key. * @param int $post_id Post ID. */ public function column( string $column, int $post_id ): void { $dashboard = $this->dashboards->find( $post_id ); if ( null === $dashboard ) { return; } switch ( $column ) { case 'pulsecast_layout': echo esc_html( self::layouts()[ $dashboard->settings['layout'] ] ?? '' ); break; case 'pulsecast_feeds': echo esc_html( (string) count( $dashboard->feeds ) ); break; case 'pulsecast_health': echo esc_html( $this->summary( $dashboard ) ); break; case 'pulsecast_display': if ( $dashboard->enabled ) { echo '' . esc_html__( 'Open', 'feedmarquee' ) . ''; } else { echo '' . esc_html__( 'Not public', 'feedmarquee' ) . ''; } break; } } /** * Row actions: no Quick Edit (there is no slug to edit), and an Open link when published. * * @param array $actions Actions. * @param \WP_Post $post Post. * @return array */ public function row_actions( array $actions, \WP_Post $post ): array { if ( Dashboard_Post_Type::NAME !== $post->post_type ) { return $actions; } unset( $actions['inline hide-if-no-js'] ); $dashboard = $this->dashboards->find( $post->ID ); if ( null !== $dashboard && $dashboard->enabled ) { $actions['pulsecast_open'] = '' . esc_html__( 'Open', 'feedmarquee' ) . ''; } return $actions; } /** * Removes bulk edit, which would expose fields that do not apply. * * @param array $actions Bulk actions. * @return array */ public function bulk_actions( array $actions ): array { unset( $actions['edit'] ); return $actions; } /** * One table row of the feeds box. * * @param int|string $index Row index, or the template placeholder. * @param string $url Feed URL. * @param string $label Label. * @param string $color Colour (`#rrggbb`) or an empty string for automatic. * @param string $health Pre-escaped health markup. */ private function render_row( $index, string $url, string $label, string $color, string $health ): void { echo '' . '' . '' . '' . '' . $health . '' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built by health(), which escapes. . ''; } /** * Escaped health markup for a saved feed. * * @param string $url Feed URL. * @param int $interval Refresh interval in seconds. * @param int $now Current Unix time. * @param bool $enabled Whether the dashboard is published. * @param int $age Seconds since the dashboard was last saved. */ private function health( string $url, int $interval, int $now, bool $enabled, int $age ): string { $entry = $this->cache->get( $url ); $status = Refresh_Policy::status( $entry, $interval, $now ); $label = Messages::status_label( $status ); $hint = ''; if ( Refresh_Policy::STATUS_PENDING === $status ) { if ( ! $enabled ) { $hint = __( 'Feeds load when you publish.', 'feedmarquee' ); } elseif ( (bool) apply_filters( 'pulsecast_scheduled_tasks_disabled', defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) ) { $hint = __( 'Scheduled tasks are turned off on this site. Click Refresh feeds now.', 'feedmarquee' ); } elseif ( $age < self::LOADING_WINDOW ) { $status = 'loading'; $label = __( 'Loading…', 'feedmarquee' ); $hint = __( 'Fetching this feed now. This usually takes under a minute.', 'feedmarquee' ); } else { $hint = __( 'This is taking longer than expected. Click Refresh feeds now.', 'feedmarquee' ); } } $html = '' . esc_html( $label ) . ''; if ( '' !== $hint ) { $html .= '
' . esc_html( $hint ) . ''; } if ( null !== $entry && $entry['fetched_at'] > 0 ) { /* translators: 1: how long ago, 2: date and time. */ $when = sprintf( __( 'Last refreshed %1$s ago (%2$s)', 'feedmarquee' ), human_time_diff( $entry['fetched_at'], $now ), wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $entry['fetched_at'] ) ); $html .= '
' . esc_html( $when ) . ''; } if ( null !== $entry && '' !== $entry['error'] ) { $html .= '
' . esc_html( Messages::feed_error( $entry['error'] ) ) . ''; } return $html; } /** * One-line health summary for the list. * * @param Dashboard $dashboard Dashboard. */ private function summary( Dashboard $dashboard ): string { if ( array() === $dashboard->feeds ) { return __( 'No feeds', 'feedmarquee' ); } $interval = (int) $dashboard->settings['refresh_interval'] * 60; $counts = array(); foreach ( $dashboard->feeds as $feed ) { $status = Refresh_Policy::status( $this->cache->get( $feed['url'] ), $interval, time() ); $counts[ $status ] = ( $counts[ $status ] ?? 0 ) + 1; } if ( isset( $counts[ Refresh_Policy::STATUS_FAILING ] ) ) { /* translators: %d: number of feeds with a problem. */ return sprintf( _n( '%d feed has a problem', '%d feeds have a problem', $counts[ Refresh_Policy::STATUS_FAILING ], 'feedmarquee' ), $counts[ Refresh_Policy::STATUS_FAILING ] ); } if ( isset( $counts[ Refresh_Policy::STATUS_STALE ] ) ) { return __( 'Out of date', 'feedmarquee' ); } return isset( $counts[ Refresh_Policy::STATUS_PENDING ] ) ? __( 'Not fetched yet', 'feedmarquee' ) : __( 'All working', 'feedmarquee' ); } }