urls = $urls; } /** * Creates a dashboard. * * Accepted keys: `title`, `feeds`, `settings` and `enabled` (default false). * * @param array $data Dashboard data. * @return Dashboard|\WP_Error An error with code `pulsecast_invalid_feeds` carries the per-row error codes in its data. */ public function create( array $data ) { $feeds = Feed_List::sanitize( $data['feeds'] ?? array(), $this->urls ); if ( array() !== $feeds['errors'] ) { return self::invalid_feeds_error( $feeds['errors'] ); } $enabled = ! empty( $data['enabled'] ); $post_id = wp_insert_post( array( 'post_type' => Dashboard_Post_Type::NAME, 'post_title' => self::title( $data['title'] ?? '' ), 'post_name' => self::generate_key(), 'post_status' => $enabled ? 'publish' : 'draft', ), true ); if ( is_wp_error( $post_id ) ) { return $post_id; } update_post_meta( $post_id, self::META_FEEDS, $feeds['feeds'] ); update_post_meta( $post_id, self::META_SETTINGS, Settings_Schema::sanitize( is_array( $data['settings'] ?? null ) ? $data['settings'] : array() ) ); /** This action is documented in Dashboard_Screen::save(). */ do_action( 'pulsecast_dashboard_saved', $post_id ); return $this->find( $post_id ); } /** * Updates a dashboard. Only the keys present in `$changes` are changed: `title`, `feeds`, * `settings` (merged over the stored settings) and `enabled`. * * @param int $id Dashboard ID. * @param array $changes Changes. * @return Dashboard|\WP_Error */ public function update( int $id, array $changes ) { $existing = $this->find( $id ); if ( null === $existing ) { return new \WP_Error( 'pulsecast_not_found', 'Dashboard not found.' ); } $feeds = null; if ( array_key_exists( 'feeds', $changes ) ) { $result = Feed_List::sanitize( $changes['feeds'], $this->urls ); if ( array() !== $result['errors'] ) { return self::invalid_feeds_error( $result['errors'] ); } $feeds = $result['feeds']; } $post = array( 'ID' => $id ); if ( array_key_exists( 'title', $changes ) ) { $post['post_title'] = self::title( $changes['title'] ); } if ( array_key_exists( 'enabled', $changes ) ) { $post['post_status'] = $changes['enabled'] ? 'publish' : 'draft'; } if ( count( $post ) > 1 ) { $updated = wp_update_post( $post, true ); if ( is_wp_error( $updated ) ) { return $updated; } } if ( null !== $feeds ) { update_post_meta( $id, self::META_FEEDS, $feeds ); } if ( array_key_exists( 'settings', $changes ) && is_array( $changes['settings'] ) ) { update_post_meta( $id, self::META_SETTINGS, Settings_Schema::sanitize( array_merge( $existing->settings, $changes['settings'] ) ) ); } do_action( 'pulsecast_dashboard_saved', $id ); return $this->find( $id ); } /** * Replaces a dashboard's feeds with the valid submitted rows and reports the rejected ones. * * A feed that is already stored is kept if it only fails to resolve right now, so a DNS * hiccup while saving cannot remove a working feed. Every URL is validated again before each * fetch regardless. * * @param int $id Dashboard ID. * @param mixed $raw Submitted rows of `url` and `label`. * @return array Error code by submitted row index. Empty when every row was accepted. */ public function set_feeds( int $id, $raw ): array { $current = $this->find( $id ); $known = null === $current ? array() : array_column( $current->feeds, 'url' ); $result = Feed_List::sanitize( $raw, $this->urls ); $feeds = $result['feeds']; $errors = $result['errors']; $rows = is_array( $raw ) ? array_values( $raw ) : array(); $listed = array_column( $feeds, 'url' ); foreach ( $errors as $index => $code ) { $row = $rows[ $index ] ?? null; $url = is_array( $row ) && is_string( $row['url'] ?? null ) ? trim( $row['url'] ) : ''; if ( Url_Validator::UNRESOLVABLE === $code && in_array( $url, $known, true ) && ! in_array( $url, $listed, true ) ) { $feeds[] = Feed_List::feed( $url, Feed_Normalizer::plain_text( (string) ( $row['label'] ?? '' ), Feed_List::MAX_LABEL ), Feed_List::sanitize_color( $row['color'] ?? '' ) ); $listed[] = $url; unset( $errors[ $index ] ); } } update_post_meta( $id, self::META_FEEDS, $feeds ); return $errors; } /** * Merges submitted settings over the stored ones and validates the result. * * @param int $id Dashboard ID. * @param array $raw Submitted settings. */ public function set_settings( int $id, array $raw ): void { $current = $this->find( $id ); update_post_meta( $id, self::META_SETTINGS, Settings_Schema::sanitize( array_merge( null === $current ? array() : $current->settings, $raw ) ) ); } /** * Finds a dashboard by ID, whether enabled or not. Trashed dashboards are not found. * * @param int $id Dashboard ID. */ public function find( int $id ): ?Dashboard { $post = get_post( $id ); if ( ! $post instanceof \WP_Post || Dashboard_Post_Type::NAME !== $post->post_type || ! in_array( $post->post_status, array( 'publish', 'draft', 'pending', 'private', 'future' ), true ) ) { return null; } return $this->hydrate( $post ); } /** * Finds an enabled dashboard by its public key. Disabled and trashed dashboards are never * returned, so disabling takes effect on the next call. * * @param string $key Public key. */ public function find_by_key( string $key ): ?Dashboard { if ( 1 !== preg_match( self::KEY_PATTERN, $key ) ) { return null; } $posts = get_posts( array( 'post_type' => Dashboard_Post_Type::NAME, 'post_status' => 'publish', 'name' => $key, 'numberposts' => 1, 'no_found_rows' => true, ) ); return array() === $posts ? null : $this->hydrate( $posts[0] ); } /** * All enabled dashboards. * * @return array */ public function all_enabled(): array { $posts = get_posts( array( 'post_type' => Dashboard_Post_Type::NAME, 'post_status' => 'publish', 'numberposts' => -1, 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => true, ) ); return array_map( array( $this, 'hydrate' ), $posts ); } /** * Enables or disables a dashboard. * * @param int $id Dashboard ID. * @param bool $enabled Whether it should serve. */ public function set_enabled( int $id, bool $enabled ): bool { if ( null === $this->find( $id ) ) { return false; } return ! is_wp_error( wp_update_post( array( 'ID' => $id, 'post_status' => $enabled ? 'publish' : 'draft', ), true ) ); } /** * Replaces a dashboard's public key, which revokes the old one. * * @param int $id Dashboard ID. * @return string|null The new key, or null if the dashboard does not exist. */ public function regenerate_key( int $id ): ?string { if ( null === $this->find( $id ) ) { return null; } $key = self::generate_key(); $updated = wp_update_post( array( 'ID' => $id, 'post_name' => $key, ), true ); return is_wp_error( $updated ) ? null : $key; } /** * Permanently deletes a dashboard. * * @param int $id Dashboard ID. */ public function delete( int $id ): bool { if ( null === $this->find( $id ) ) { return false; } return false !== wp_delete_post( $id, true ); } /** * Generates a public key: 80 bits from a CSPRNG, as 20 hex characters. */ public static function generate_key(): string { return bin2hex( random_bytes( 10 ) ); } /** * Builds a dashboard from its post, re-validating the stored structure. * * @param \WP_Post $post Dashboard post. */ private function hydrate( \WP_Post $post ): Dashboard { $settings = get_post_meta( $post->ID, self::META_SETTINGS, true ); return new Dashboard( $post->ID, $post->post_name, $post->post_title, 'publish' === $post->post_status, Feed_List::from_storage( get_post_meta( $post->ID, self::META_FEEDS, true ) ), Settings_Schema::sanitize( is_array( $settings ) ? $settings : array() ) ); } /** * A clean dashboard title. * * @param mixed $title Submitted title. */ private static function title( $title ): string { $title = Feed_Normalizer::plain_text( is_string( $title ) ? $title : '', self::MAX_TITLE ); return '' !== $title ? $title : __( 'Untitled dashboard', 'feedmarquee' ); } /** * Error for rejected feed rows. * * @param array $errors Error code by row index. */ private static function invalid_feeds_error( array $errors ): \WP_Error { return new \WP_Error( 'pulsecast_invalid_feeds', 'One or more feed URLs were rejected.', array( 'errors' => $errors ) ); } }