repository = $repository; $this->builder = $builder; $this->query = new Content_Query(); $this->discovery = new Meta_Discovery(); $this->taxonomies = new Taxonomy_Discovery(); } /** * Hooks. */ public function register(): void { add_action( 'admin_menu', array( $this, 'add_menu' ) ); add_filter( 'admin_title', array( $this, 'filter_admin_title' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); add_action( 'admin_post_feedweaver_save', array( $this, 'handle_save' ) ); add_action( 'admin_post_feedweaver_delete', array( $this, 'handle_delete' ) ); add_action( 'wp_ajax_feedweaver_preview', array( $this, 'handle_preview' ) ); add_action( 'wp_ajax_feedweaver_meta_keys', array( $this, 'handle_meta_keys' ) ); add_action( 'wp_ajax_feedweaver_taxonomies', array( $this, 'handle_taxonomies' ) ); add_action( 'wp_ajax_feedweaver_terms', array( $this, 'handle_terms' ) ); add_action( 'wp_ajax_feedweaver_date_example', array( $this, 'handle_date_example' ) ); add_action( 'wp_ajax_feedweaver_token', array( $this, 'handle_token' ) ); } /** * Menu. */ public function add_menu(): void { add_menu_page( __( 'FeedWeaver', 'feedweaver' ), __( 'FeedWeaver', 'feedweaver' ), self::CAPABILITY, self::PAGE_LIST, array( $this, 'render_list' ), 'dashicons-rss', 80 ); add_submenu_page( self::PAGE_LIST, __( 'All Feeds', 'feedweaver' ), __( 'All Feeds', 'feedweaver' ), self::CAPABILITY, self::PAGE_LIST, array( $this, 'render_list' ) ); add_submenu_page( self::PAGE_LIST, __( 'Add New Feed', 'feedweaver' ), __( 'Add New', 'feedweaver' ), self::CAPABILITY, self::PAGE_EDIT, array( $this, 'render_edit' ) ); add_submenu_page( self::PAGE_LIST, __( 'Help & Documentation', 'feedweaver' ), __( 'Help & Documentation', 'feedweaver' ), self::CAPABILITY, self::PAGE_HELP, array( $this, 'render_help' ) ); } /** * Browser tab title: "Edit Feed" when editing an existing feed. * * @param string $title Title. * @return string */ public function filter_admin_title( $title ) { $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only. if ( self::PAGE_EDIT === $page && ! empty( $_GET['feed'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return str_replace( __( 'Add New Feed', 'feedweaver' ), __( 'Edit Feed', 'feedweaver' ), (string) $title ); } return $title; } /** * Loads assets on FeedWeaver screens only. * * @param string $hook Admin page hook. */ public function enqueue( $hook ): void { if ( false === strpos( (string) $hook, 'feedweaver' ) ) { return; } wp_enqueue_style( 'feedweaver-admin', FEEDWEAVER_URL . 'assets/admin.css', array(), FEEDWEAVER_VERSION ); wp_enqueue_script( 'feedweaver-copy', FEEDWEAVER_URL . 'assets/copy.js', array(), FEEDWEAVER_VERSION, true ); wp_localize_script( 'feedweaver-copy', 'feedweaverCopy', array( 'copied' => __( 'Copied.', 'feedweaver' ), 'failed' => __( 'Copy failed. Select the text and copy it manually.', 'feedweaver' ), ) ); wp_enqueue_script( 'feedweaver-admin', FEEDWEAVER_URL . 'assets/admin.js', array(), FEEDWEAVER_VERSION, true ); wp_localize_script( 'feedweaver-admin', 'feedweaverAdmin', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'feedweaver_preview' ), 'metaNonce' => wp_create_nonce( 'feedweaver_meta_keys' ), 'discoveryNonce' => wp_create_nonce( 'feedweaver_discovery' ), 'tokenNonce' => wp_create_nonce( 'feedweaver_token' ), 'termPage' => Taxonomy_Discovery::PAGE, 'manualValue' => Edit_Screen::MANUAL, 'maxMappings' => Mapping_Engine::MAX_MAPPINGS, 'i18n' => array( 'loading' => __( 'Generating preview…', 'feedweaver' ), 'ready' => __( 'Preview updated.', 'feedweaver' ), 'failed' => __( 'The preview request failed.', 'feedweaver' ), 'copied' => __( 'Copied.', 'feedweaver' ), 'copyFailed' => __( 'Copy failed. Select the text and copy it manually.', 'feedweaver' ), 'removeField' => __( 'Remove field', 'feedweaver' ), 'field' => __( 'Field', 'feedweaver' ), 'items' => __( 'items shown', 'feedweaver' ), 'selectMeta' => __( 'Select custom field…', 'feedweaver' ), 'manualMeta' => __( 'Enter custom field key manually…', 'feedweaver' ), 'metaLoading' => __( 'Loading custom fields…', 'feedweaver' ), 'metaFailed' => __( 'Could not load custom fields. You can still type a key manually.', 'feedweaver' ), 'metaUpdated' => __( 'Custom field list updated for this content type.', 'feedweaver' ), 'maxReached' => __( 'You have reached the maximum number of fields.', 'feedweaver' ), 'selectTax' => __( 'Select taxonomy…', 'feedweaver' ), 'taxUnavailable' => __( 'not used by the selected content types', 'feedweaver' ), 'taxLoading' => __( 'Loading filters…', 'feedweaver' ), 'taxFailed' => __( 'Could not refresh the filters. Save the feed and reload the page to try again.', 'feedweaver' ), 'taxUpdated' => __( 'Filters updated for the selected content types.', 'feedweaver' ), 'taxRemoved' => __( 'Removed filters that the selected content types do not use:', 'feedweaver' ), 'taxEmpty' => __( 'The selected content types have no public taxonomies to filter by.', 'feedweaver' ), 'searchTerms' => __( 'Search terms', 'feedweaver' ), 'searching' => __( 'Searching…', 'feedweaver' ), 'noMatches' => __( 'No matching terms.', 'feedweaver' ), 'showing' => __( 'Showing the first terms. Search to find others.', 'feedweaver' ), 'selectedTerms' => __( 'Selected:', 'feedweaver' ), 'removeTerm' => __( 'Remove', 'feedweaver' ), 'noTypes' => __( 'Choose at least one content type.', 'feedweaver' ), 'terms' => __( 'terms', 'feedweaver' ), 'dateChecking' => __( 'Checking…', 'feedweaver' ), 'dateFailed' => __( 'Could not check the format.', 'feedweaver' ), 'summaryNone' => __( 'Choose at least one content type.', 'feedweaver' ), 'summaryStart' => __( 'The newest', 'feedweaver' ), /* translators: %d: field number. */ 'moveUp' => __( 'Move field %d up', 'feedweaver' ), /* translators: %d: field number. */ 'moveDown' => __( 'Move field %d down', 'feedweaver' ), 'moved' => __( 'Field moved. Position:', 'feedweaver' ), 'confirmRssDefaults' => __( 'Replace your field mapping with the standard RSS fields? Your current rows will be removed.', 'feedweaver' ), 'itemElement' => __( 'The item element', 'feedweaver' ), 'chooseTaxonomyField' => __( 'Choose a taxonomy field…', 'feedweaver' ), 'pathMissing' => __( '(no longer in the field mapping)', 'feedweaver' ), 'errorLabel' => __( 'Error:', 'feedweaver' ), 'tokenWorking' => __( 'Generating token…', 'feedweaver' ), 'tokenFailed' => __( 'Could not generate a token. Reload the page and try again.', 'feedweaver' ), 'tokenNew' => __( 'Token generated. Copy it now: it is shown only once.', 'feedweaver' ), 'tokenRotated' => __( 'New token generated. The previous token no longer works.', 'feedweaver' ), 'tokenSet' => __( 'A token is set. For security it cannot be shown again.', 'feedweaver' ), 'tokenRemoveNote' => __( 'Saving as Public removes this feed\'s token. Protecting it again needs a new token.', 'feedweaver' ), 'regenerate' => __( 'Regenerate token', 'feedweaver' ), 'tokenSaveFirst' => __( 'Save the feed first, then generate its token.', 'feedweaver' ), 'summaryFrom' => __( 'items from', 'feedweaver' ), 'summaryFilter' => __( 'filtered by', 'feedweaver' ), ), ) ); } // Screens. /** * Feed list. */ public function render_list(): void { $this->require_capability(); $feeds = $this->repository->all(); ?>


render_flash_notice(); ?>

name ); ?>
|
format ) ); ?> post_types ) ); ?> enabled ? esc_html__( 'Enabled', 'feedweaver' ) : esc_html__( 'Disabled', 'feedweaver' ); ?> access ? esc_html__( 'Protected', 'feedweaver' ) : esc_html__( 'Public', 'feedweaver' ); ?> slug ); $protected = Feed_Config::ACCESS_PROTECTED === $config->access; ?> enabled && ! $protected ) : ?>
enabled ) { esc_html_e( 'Switched off: shows "not found".', 'feedweaver' ); } elseif ( $protected ) { esc_html_e( 'Needs a token.', 'feedweaver' ); } ?>
require_capability(); $feed_id = isset( $_GET['feed'] ) ? absint( $_GET['feed'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of an admin screen. $errors = array(); $config = null; $stash_key = 'feedweaver_form_' . get_current_user_id(); $stash = get_transient( $stash_key ); if ( is_array( $stash ) && isset( $stash['data'], $stash['id'] ) && (int) $stash['id'] === $feed_id ) { $config = Feed_Config::from_array( $stash['data'] ); $errors = isset( $stash['errors'] ) ? (array) $stash['errors'] : array(); delete_transient( $stash_key ); } if ( ! $config && $feed_id ) { $config = $this->repository->find( $feed_id ); if ( ! $config ) { wp_die( esc_html__( 'That feed does not exist.', 'feedweaver' ), '', array( 'response' => 404 ) ); } } if ( ! $config ) { $config = new Feed_Config(); $config->mappings = Feed_Config::default_mappings(); } $post_types = $this->query->eligible_post_types(); $active = array_values( array_intersect( $config->post_types, array_keys( $post_types ) ) ); $taxonomies = $this->taxonomies->for_post_types( $active, $config->tax_filters ); $meta_keys = $this->discovery->discover_many( $active ); $title = $feed_id ? __( 'Edit Feed', 'feedweaver' ) : __( 'Add New Feed', 'feedweaver' ); ?>

render_flash_notice( $config ); ?> $feed_id && $this->repository->has_verifier( $feed_id ), 'https_ok' => Access_Policy::site_supports_protected_feeds(), 'local_site' => Access_Policy::is_local_site(), ); ( new Edit_Screen() )->render( $config, $feed_id, $post_types, $taxonomies, $meta_keys, $access, $this->builder->site_defaults() ); ?>
require_capability(); ( new Help_Page() )->render(); } // Handlers. /** * Save handler (admin-post). */ public function handle_save(): void { $this->require_capability(); check_admin_referer( 'feedweaver_save', 'feedweaver_nonce' ); $feed_id = isset( $_POST['feed_id'] ) ? absint( $_POST['feed_id'] ) : 0; $raw = isset( $_POST['fw'] ) && is_array( $_POST['fw'] ) ? Form_Input::shape( map_deep( wp_unslash( $_POST['fw'] ), 'sanitize_text_field' ) ) : array(); $outcome = $this->process_save( $raw, $feed_id ); if ( $outcome['errors'] ) { wp_safe_redirect( $this->edit_url( $feed_id ) ); exit; } wp_safe_redirect( add_query_arg( 'feedweaver_notice', 'saved', $this->edit_url( $outcome['id'] ) ) ); exit; } /** * Validates and stores a submitted feed. Capability and nonce checks are the caller's job. * * On validation failure nothing is stored; the submission is stashed briefly so the * form can be redisplayed with the user's input. * * @param array $raw Unslashed submission (the "fw" array). * @param int $feed_id Existing feed ID, or 0 for a new feed. * @return array{id:int,errors:string[]} */ public function process_save( array $raw, int $feed_id ): array { $config = Feed_Config::from_array( $raw ); $existing = $feed_id ? $this->repository->find( $feed_id ) : null; if ( $feed_id && ! $existing ) { wp_die( esc_html__( 'That feed does not exist.', 'feedweaver' ), '', array( 'response' => 404 ) ); } // A submission that does not mention access (an older form, a script) must never // silently make a protected feed public. if ( $existing && ! array_key_exists( 'access', $raw ) ) { $config->access = $existing->access; } $errors = $this->builder->validate_for_save( $config ); if ( ! $errors && $this->repository->slug_conflicts( $config->slug, $feed_id ) ) { $errors[] = __( 'Another feed already uses this URL slug.', 'feedweaver' ); } if ( ! $errors ) { $saved = $this->repository->save( $config, $feed_id ); if ( ! is_wp_error( $saved ) ) { if ( Feed_Config::ACCESS_PROTECTED !== $config->access ) { // A public feed keeps no token, so a stale one can never come back to life. $this->repository->clear_verifier( (int) $saved ); } return array( 'id' => (int) $saved, 'errors' => array(), ); } $errors[] = $saved->get_error_message(); } set_transient( 'feedweaver_form_' . get_current_user_id(), array( 'data' => $config->to_array(), 'errors' => $errors, 'id' => $feed_id, ), 120 ); return array( 'id' => $feed_id, 'errors' => $errors, ); } /** * Delete handler (admin-post). */ public function handle_delete(): void { $this->require_capability(); $feed_id = isset( $_GET['feed'] ) ? absint( $_GET['feed'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified on the next line. check_admin_referer( 'feedweaver_delete_' . $feed_id ); $this->repository->delete( $feed_id ); wp_safe_redirect( add_query_arg( 'feedweaver_notice', 'deleted', admin_url( 'admin.php?page=' . self::PAGE_LIST ) ) ); exit; } /** * Preview handler (admin-ajax, logged-in users only). * * Renders an unsaved configuration with the same builder as public feeds. */ public function handle_preview(): void { check_ajax_referer( 'feedweaver_preview' ); if ( ! current_user_can( self::CAPABILITY ) ) { wp_send_json_error( array( 'errors' => array( __( 'You are not allowed to preview feeds.', 'feedweaver' ) ) ), 403 ); } $raw = isset( $_POST['fw'] ) && is_array( $_POST['fw'] ) ? Form_Input::shape( map_deep( wp_unslash( $_POST['fw'] ), 'sanitize_text_field' ) ) : array(); $config = Feed_Config::from_array( $raw ); // Same pipeline as the public feed. Only the first few items are written out, but the // diagnostics cover every item the feed would contain. $result = $this->builder->build( $config, false, true, 5 ); if ( ! $result->ok ) { wp_send_json_success( array( 'ok' => false, 'errors' => $result->errors, 'error_targets' => Error_Targets::describe( $result->errors ), ) ); } wp_send_json_success( array( 'ok' => true, 'errors' => array(), 'output' => $result->body, 'content_type' => $result->content_type, 'count' => $result->count, 'diagnostics' => Feed_Diagnostics::describe( $result->diagnostics ), ) ); } /** * Custom field discovery (admin-ajax, logged-in administrators only). * * Returns key names and labels for the chosen content types; never values. */ public function handle_meta_keys(): void { check_ajax_referer( 'feedweaver_meta_keys' ); $this->require_ajax_capability(); wp_send_json_success( array( 'keys' => $this->discovery->discover_many( $this->requested_post_types( 'feedweaver_meta_keys' ) ) ) ); } /** * Taxonomy discovery for the chosen content types (admin-ajax, administrators only). */ public function handle_taxonomies(): void { check_ajax_referer( 'feedweaver_discovery' ); $this->require_ajax_capability(); wp_send_json_success( array( 'taxonomies' => $this->taxonomies->for_post_types( $this->requested_post_types( 'feedweaver_discovery' ) ) ) ); } /** * Term search within one taxonomy (admin-ajax, administrators only). */ public function handle_terms(): void { check_ajax_referer( 'feedweaver_discovery' ); $this->require_ajax_capability(); $types = $this->requested_post_types( 'feedweaver_discovery' ); $taxonomy = isset( $_POST['taxonomy'] ) && is_string( $_POST['taxonomy'] ) ? Form_Input::identifier( sanitize_text_field( wp_unslash( $_POST['taxonomy'] ) ) ) : ''; $search = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : ''; if ( ! array_key_exists( $taxonomy, Eligibility::taxonomies_for( $types ) ) ) { wp_send_json_error( array( 'message' => __( 'Choose a taxonomy used by the selected content types.', 'feedweaver' ) ), 400 ); } wp_send_json_success( array( 'terms' => $this->taxonomies->terms( $taxonomy, Text_Sanitizer::truncate_bytes( $search, 100 ) ) ) ); } /** * Generates or rotates a feed's access token (admin-ajax, administrators only). * * The raw token is returned in this one response and is never stored; only its SHA-256 * verifier is. Calling this again replaces the verifier, so the previous token stops * working immediately. Generating a token also marks the feed as protected, so the * token is never issued for a feed that is still public. */ public function handle_token(): void { check_ajax_referer( 'feedweaver_token' ); $this->require_ajax_capability(); $feed_id = isset( $_POST['feed_id'] ) ? absint( $_POST['feed_id'] ) : 0; $config = $feed_id ? $this->repository->find( $feed_id ) : null; if ( ! $config ) { wp_send_json_error( array( 'message' => __( 'Save the feed first.', 'feedweaver' ) ), 400 ); } try { $token = Token_Service::generate(); } catch ( \Throwable $e ) { wp_send_json_error( array( 'message' => __( 'The server could not generate a secure token.', 'feedweaver' ) ), 500 ); } $rotated = $this->repository->has_verifier( $feed_id ); $this->repository->set_verifier( $feed_id, Token_Service::verifier( $token ) ); if ( Feed_Config::ACCESS_PROTECTED !== $config->access ) { $config->access = Feed_Config::ACCESS_PROTECTED; $this->repository->save( $config, $feed_id ); } nocache_headers(); wp_send_json_success( array( 'token' => $token, 'rotated' => $rotated, ) ); } /** * Formats the current time with a custom date pattern, so the editor can show a live example. */ public function handle_date_example(): void { check_ajax_referer( 'feedweaver_discovery' ); $this->require_ajax_capability(); $pattern = isset( $_POST['pattern'] ) && is_string( $_POST['pattern'] ) ? sanitize_text_field( wp_unslash( $_POST['pattern'] ) ) : ''; $errors = Date_Formatter::pattern_errors( $pattern ); if ( $errors ) { wp_send_json_success( array( 'ok' => false, 'message' => $errors[0], ) ); } wp_send_json_success( array( 'ok' => true, 'example' => (string) Date_Formatter::format( time(), Date_Formatter::CUSTOM, $pattern ), ) ); } // Helpers. /** * Ends an AJAX request unless the user can manage FeedWeaver. */ private function require_ajax_capability(): void { if ( ! current_user_can( self::CAPABILITY ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'feedweaver' ) ), 403 ); } } /** * Eligible post types named in the request (post_types[], or the single post_type of earlier versions). * Ends the request with 400 when none is eligible. * * The nonce is checked here, ahead of reading anything, so this helper is safe on its own. * * @param string $nonce_action Nonce action the calling endpoint uses. * @return string[] */ private function requested_post_types( string $nonce_action ): array { check_ajax_referer( $nonce_action ); $raw = array(); if ( isset( $_POST['post_types'] ) && is_array( $_POST['post_types'] ) ) { $raw = Form_Input::identifiers( map_deep( wp_unslash( $_POST['post_types'] ), 'sanitize_text_field' ), Feed_Config::MAX_TYPES + 1 ); } elseif ( isset( $_POST['post_type'] ) && is_string( $_POST['post_type'] ) ) { $raw = array( Form_Input::identifier( sanitize_text_field( wp_unslash( $_POST['post_type'] ) ) ) ); } $eligible = array_keys( $this->query->eligible_post_types() ); $types = array(); foreach ( $raw as $type ) { if ( ! is_string( $type ) || ! in_array( $type, $eligible, true ) ) { wp_send_json_error( array( 'message' => __( 'Choose public content types.', 'feedweaver' ) ), 400 ); } $types[ $type ] = $type; } if ( ! $types ) { wp_send_json_error( array( 'message' => __( 'Choose at least one content type.', 'feedweaver' ) ), 400 ); } return array_values( $types ); } /** * Aborts unless the user can manage FeedWeaver. */ private function require_capability(): void { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to manage feeds.', 'feedweaver' ), '', array( 'response' => 403 ) ); } } /** * Edit screen URL. * * @param int $feed_id Feed ID or 0. * @return string */ private function edit_url( int $feed_id ): string { $url = admin_url( 'admin.php?page=' . self::PAGE_EDIT ); return $feed_id ? add_query_arg( 'feed', $feed_id, $url ) : $url; } /** * Shows a one-off confirmation notice. * * @param Feed_Config|null $config Feed being displayed, if any. */ private function render_flash_notice( $config = null ): void { $notice = isset( $_GET['feedweaver_notice'] ) ? sanitize_key( wp_unslash( $_GET['feedweaver_notice'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only. $map = array( 'saved' => __( 'Feed saved.', 'feedweaver' ), 'deleted' => __( 'Feed deleted.', 'feedweaver' ), ); if ( isset( $map[ $notice ] ) ) { echo '

' . esc_html( $map[ $notice ] ) . '

'; } if ( 'saved' === $notice && $config instanceof Feed_Config && ! $config->enabled ) { echo '

' . esc_html__( 'This feed is saved but disabled, so its public URL does not work yet. Tick "Enabled" and save again to publish it.', 'feedweaver' ) . '

'; } } }