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' ) . '
';
}
}
}