diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e43b0f9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.DS_Store
diff --git a/includes/class-plugin.php b/includes/class-plugin.php
index 5fc9279..9709087 100644
--- a/includes/class-plugin.php
+++ b/includes/class-plugin.php
@@ -24,6 +24,8 @@ public function init() {
( new REST_Acf_Controller() )->init();
( new REST_Posts_Controller() )->init();
( new REST_User_Controller() )->init();
+ ( new REST_Ical_Controller() )->init();
+ ( new REST_Project_Images_Controller() )->init();
$this->update_check();
}
diff --git a/includes/class-rest-ical-controller.php b/includes/class-rest-ical-controller.php
new file mode 100644
index 0000000..c1adf55
--- /dev/null
+++ b/includes/class-rest-ical-controller.php
@@ -0,0 +1,291 @@
+ \WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => '__return_true',
+ ),
+ )
+ );
+ }
+
+ public function get_item( $request ) {
+ // ?debug=1 dumps raw post/meta data as JSON — admin only.
+ if ( ! empty( $request['debug'] ) && current_user_can( 'manage_options' ) ) {
+ $this->output_debug();
+ }
+
+ $ics = $this->generate_ics();
+
+ // Hook into rest_pre_serve_request instead of calling exit() directly.
+ // Priority 15 runs after rest_send_cors_headers (priority 10), so our
+ // wildcard header wins even if WordPress narrowed it to a specific origin.
+ add_filter(
+ 'rest_pre_serve_request',
+ function () use ( $ics ) {
+ header( 'Content-Type: text/calendar; charset=utf-8' );
+ header( 'Content-Disposition: inline; filename="pearl-events.ics"' );
+ header( 'Cache-Control: no-cache, must-revalidate' );
+ header( 'Access-Control-Allow-Origin: *' );
+ header( 'Access-Control-Allow-Methods: GET' );
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ echo $ics;
+ return true; // tells WP_REST_Server we handled the response; skips JSON output.
+ },
+ 15
+ );
+
+ return new \WP_REST_Response( null, 200 );
+ }
+
+ private function output_debug() {
+ $events = get_posts(
+ array(
+ 'post_type' => self::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => 3,
+ )
+ );
+
+ $out = array(
+ 'post_type' => self::POST_TYPE,
+ 'event_count' => count( $events ),
+ 'field_keys' => array(
+ 'start' => self::FIELD_START_DATE,
+ 'end' => self::FIELD_END_DATE,
+ 'venue' => self::FIELD_VENUE_NAME,
+ 'addr' => self::FIELD_VENUE_ADDR,
+ ),
+ 'events' => array(),
+ );
+
+ foreach ( $events as $event ) {
+ $all_meta = get_post_meta( $event->ID );
+ // Strip ACF field-key entries (underscore-prefixed) to keep output readable.
+ $readable_meta = array();
+ foreach ( $all_meta as $key => $values ) {
+ if ( strpos( $key, '_' ) !== 0 ) {
+ $readable_meta[ $key ] = $values[0];
+ }
+ }
+
+ $out['events'][] = array(
+ 'id' => $event->ID,
+ 'title' => $event->post_title,
+ 'start_raw' => get_post_meta( $event->ID, self::FIELD_START_DATE, true ),
+ 'end_raw' => get_post_meta( $event->ID, self::FIELD_END_DATE, true ),
+ 'venue_name_raw' => get_post_meta( $event->ID, self::FIELD_VENUE_NAME, true ),
+ 'addr_raw' => get_post_meta( $event->ID, self::FIELD_VENUE_ADDR, true ),
+ 'all_meta_keys' => $readable_meta,
+ );
+ }
+
+ header( 'Content-Type: application/json; charset=utf-8' );
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ echo wp_json_encode( $out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
+ exit;
+ }
+
+ private function generate_ics() {
+ $events = get_posts(
+ array(
+ 'post_type' => self::POST_TYPE,
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'orderby' => 'date',
+ 'order' => 'ASC',
+ )
+ );
+
+ $tz = wp_timezone();
+
+ // wp_timezone_string() returns a UTC offset (e.g. "+00:00") when WordPress is
+ // set to a manual offset rather than an IANA city name. RFC 5545 requires an
+ // IANA timezone identifier for TZID, so fall back to the PHP DateTimeZone name.
+ $tz_str = $tz->getName();
+ if ( str_starts_with( $tz_str, '+' ) || str_starts_with( $tz_str, '-' ) ) {
+ $tz_str = 'UTC';
+ }
+
+ $domain = wp_parse_url( home_url(), PHP_URL_HOST );
+
+ $lines = array(
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:-//' . get_bloginfo( 'name' ) . '//Events//EN',
+ 'CALSCALE:GREGORIAN',
+ 'METHOD:PUBLISH',
+ 'X-WR-CALNAME:' . $this->escape_text( get_bloginfo( 'name' ) . ' Events' ),
+ 'X-WR-TIMEZONE:' . $tz_str,
+ );
+
+ foreach ( $events as $event ) {
+ $start_raw = get_post_meta( $event->ID, self::FIELD_START_DATE, true );
+ $end_raw = get_post_meta( $event->ID, self::FIELD_END_DATE, true );
+ $venue_name = get_post_meta( $event->ID, self::FIELD_VENUE_NAME, true );
+ $venue_addr = get_post_meta( $event->ID, self::FIELD_VENUE_ADDR, true );
+
+ if ( ! $start_raw ) {
+ continue;
+ }
+
+ $start_dt = $this->parse_datetime( $start_raw, $tz );
+ if ( ! $start_dt ) {
+ continue;
+ }
+
+ $end_dt = $end_raw ? $this->parse_datetime( $end_raw, $tz ) : null;
+
+ // Build LOCATION from venue name + address.
+ $location_parts = array_filter( array( $venue_name, $venue_addr ) );
+ $location = implode( ', ', $location_parts );
+
+ $uid = 'event-' . $event->ID . '@' . $domain;
+ $dtstamp = gmdate( 'Ymd\THis\Z' );
+
+ // For UTC, RFC 5545 requires the Z suffix with no TZID parameter.
+ $is_utc = ( 'UTC' === $tz_str );
+ $dt_format = $is_utc ? 'Ymd\THis\Z' : 'Ymd\THis';
+ $dtstart_key = $is_utc ? 'DTSTART' : 'DTSTART;TZID=' . $tz_str;
+ $dtend_key = $is_utc ? 'DTEND' : 'DTEND;TZID=' . $tz_str;
+
+ $vevent_lines = array(
+ 'BEGIN:VEVENT',
+ 'UID:' . $uid,
+ 'DTSTAMP:' . $dtstamp,
+ $dtstart_key . ':' . $start_dt->format( $dt_format ),
+ );
+
+ if ( $end_dt ) {
+ $vevent_lines[] = $dtend_key . ':' . $end_dt->format( $dt_format );
+ }
+
+ $vevent_lines[] = 'SUMMARY:' . $this->escape_text( $event->post_title );
+
+ if ( $location ) {
+ $vevent_lines[] = 'LOCATION:' . $this->escape_text( $location );
+ }
+
+ $url = get_permalink( $event->ID );
+ if ( $url ) {
+ $vevent_lines[] = 'URL:' . $url;
+ }
+
+ $vevent_lines[] = 'END:VEVENT';
+
+ $lines = array_merge( $lines, $vevent_lines );
+ }
+
+ $lines[] = 'END:VCALENDAR';
+
+ $folded = array_map( array( $this, 'fold_line' ), $lines );
+
+ return implode( "\r\n", $folded ) . "\r\n";
+ }
+
+ /**
+ * Parse a datetime string in any common ACF format.
+ * ACF datetime pickers can store/return in various formats depending on field config.
+ */
+ private function parse_datetime( $raw, $tz ) {
+ $formats = array(
+ 'm/d/Y g:i a', // "06/06/2026 9:00 am" — ACF US datetime return format
+ 'm/d/Y G:i', // "06/06/2026 9:00"
+ 'Y-m-d H:i:s', // "2026-06-06 09:00:00" — ACF default save format
+ 'Y-m-d H:i', // "2026-06-06 09:00"
+ 'Ymd\THis', // "20260606T090000"
+ );
+
+ foreach ( $formats as $format ) {
+ $dt = \DateTime::createFromFormat( $format, $raw, $tz );
+ if ( $dt !== false ) {
+ return $dt;
+ }
+ }
+
+ // Last resort — let PHP try to parse it (handles ISO 8601 and others).
+ $ts = strtotime( $raw );
+ if ( $ts !== false ) {
+ $dt = new \DateTime( '@' . $ts );
+ $dt->setTimezone( $tz );
+ return $dt;
+ }
+
+ return null;
+ }
+
+ /**
+ * Escape special characters per RFC 5545 §3.3.11 (TEXT).
+ */
+ private function escape_text( $text ) {
+ $text = str_replace( '\\', '\\\\', $text );
+ $text = str_replace( ';', '\\;', $text );
+ $text = str_replace( ',', '\\,', $text );
+ $text = str_replace( "\r\n", '\\n', $text );
+ $text = str_replace( "\n", '\\n', $text );
+ $text = str_replace( "\r", '', $text );
+ return $text;
+ }
+
+ /**
+ * Fold a long iCalendar content line at 75 octets per RFC 5545 §3.1.
+ * Continuation lines begin with a single space.
+ */
+ private function fold_line( $line ) {
+ if ( strlen( $line ) <= 75 ) {
+ return $line;
+ }
+
+ $output = '';
+ while ( strlen( $line ) > 75 ) {
+ $output .= substr( $line, 0, 75 ) . "\r\n ";
+ $line = substr( $line, 75 );
+ }
+
+ return $output . $line;
+ }
+}
diff --git a/includes/class-rest-posts-controller.php b/includes/class-rest-posts-controller.php
index b0f3c7b..24bf979 100644
--- a/includes/class-rest-posts-controller.php
+++ b/includes/class-rest-posts-controller.php
@@ -83,7 +83,21 @@ public function get_collection_params() {
'type' => 'string',
),
'term_slug' => array(
- 'description' => __( 'Terms slug', 'fuxt-api' ),
+ 'description' => __( 'Term slug(s). Comma-separated for multiple.', 'fuxt-api' ),
+ 'type' => 'string',
+ ),
+ 'taxonomy' => array(
+ 'description' => __( 'Limit term_slug lookups to specific taxonomies (comma separated). Needed when a slug exists in multiple taxonomies.', 'fuxt-api' ),
+ 'type' => 'string',
+ ),
+ 'term_operator' => array(
+ 'description' => __( 'How multiple term_slug values within one taxonomy combine: IN (any) or AND (all).', 'fuxt-api' ),
+ 'type' => 'string',
+ 'default' => 'IN',
+ 'enum' => array( 'IN', 'AND' ),
+ ),
+ 'priority_term_slug' => array(
+ 'description' => __( 'Terms slug used for priority ordering. Matching posts are returned first, then remaining posts that match all other filters.', 'fuxt-api' ),
'type' => 'string',
),
'orderby' => array(
@@ -122,6 +136,12 @@ public function get_collection_params() {
'description' => __( 'Page number', 'fuxt-api' ),
'type' => 'integer',
),
+ 'include' => array(
+ 'description' => __( 'Limit result set to specific post IDs (comma separated). Spans all exposed post types unless post_type is given; results preserve the given ID order.', 'fuxt-api' ),
+ 'type' => 'array',
+ 'items' => array( 'type' => 'integer' ),
+ 'sanitize_callback' => 'wp_parse_id_list',
+ ),
'post_type' => array(
'description' => __( 'Post type', 'fuxt-api' ),
'type' => 'string',
diff --git a/includes/class-rest-project-images-controller.php b/includes/class-rest-project-images-controller.php
new file mode 100644
index 0000000..852b13d
--- /dev/null
+++ b/includes/class-rest-project-images-controller.php
@@ -0,0 +1,459 @@
+ \WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'upload_item' ),
+ 'permission_callback' => array( $this, 'upload_permissions_check' ),
+ 'args' => array(),
+ ),
+ array(
+ 'methods' => \WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ 'schema' => array( $this, 'get_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Register the API key setting.
+ */
+ public function register_setting() {
+ register_setting(
+ 'fuxt_project_images',
+ self::OPTION_API_KEY,
+ array(
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ )
+ );
+ }
+
+ /**
+ * Add settings page for API key.
+ */
+ public function add_settings_page() {
+ add_options_page(
+ __( 'Project Images API', 'fuxt-api' ),
+ __( 'Project Images API', 'fuxt-api' ),
+ 'manage_options',
+ 'fuxt-project-images',
+ array( $this, 'render_settings_page' )
+ );
+ }
+
+ /**
+ * Render the settings page.
+ */
+ public function render_settings_page() {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ return;
+ }
+ if ( isset( $_GET['settings-updated'] ) ) {
+ add_settings_error(
+ 'fuxt_project_images_messages',
+ 'fuxt_project_images_message',
+ __( 'Settings saved.', 'fuxt-api' ),
+ 'success'
+ );
+ }
+ settings_errors( 'fuxt_project_images_messages' );
+ $api_key = $this->get_api_key();
+ ?>
+
+
+
+
+
+ POST
+
+
+
+ GET
+
+
+ get_api_key();
+ if ( $configured === '' ) {
+ return false;
+ }
+ $header = $request->get_header( 'X-Fuxt-Api-Key' );
+ if ( $header !== null && $header !== '' && hash_equals( $configured, $header ) ) {
+ return true;
+ }
+ $auth = $request->get_header( 'Authorization' );
+ if ( $auth && preg_match( '/^\s*Bearer\s+(.+)$/i', $auth, $m ) ) {
+ return hash_equals( $configured, trim( $m[1] ) );
+ }
+ return false;
+ }
+
+ /**
+ * Rate limit by IP. Returns true if under limit, false if exceeded.
+ *
+ * @param string $ip Client IP.
+ * @return bool
+ */
+ protected function check_rate_limit( $ip ) {
+ $key = self::RATE_LIMIT_TRANSIENT_PREFIX . md5( $ip );
+ $count = (int) get_transient( $key );
+ if ( $count >= self::RATE_LIMIT_MAX_UPLOADS ) {
+ return false;
+ }
+ if ( $count === 0 ) {
+ set_transient( $key, 1, self::RATE_LIMIT_WINDOW_SECONDS );
+ } else {
+ set_transient( $key, $count + 1, self::RATE_LIMIT_WINDOW_SECONDS );
+ }
+ return true;
+ }
+
+ /**
+ * Permission check for upload: valid API key + rate limit.
+ *
+ * @param \WP_REST_Request $request Request.
+ * @return bool|\WP_Error
+ */
+ public function upload_permissions_check( $request ) {
+ if ( ! $this->validate_api_key( $request ) ) {
+ return new \WP_Error(
+ 'fuxt_rest_project_images_unauthorized',
+ __( 'Invalid or missing API key. Send X-Fuxt-Api-Key header or Authorization: Bearer <key>.', 'fuxt-api' ),
+ array( 'status' => 401 )
+ );
+ }
+ $ip = $this->get_client_ip( $request );
+ if ( ! $this->check_rate_limit( $ip ) ) {
+ return new \WP_Error(
+ 'fuxt_rest_project_images_rate_limited',
+ __( 'Too many uploads. Please try again later.', 'fuxt-api' ),
+ array( 'status' => 429 )
+ );
+ }
+ return true;
+ }
+
+ /**
+ * Permission check for listing images (public read; no key required).
+ *
+ * @param \WP_REST_Request $request Request.
+ * @return true
+ */
+ public function get_items_permissions_check( $request ) {
+ return true;
+ }
+
+ /**
+ * Get client IP from request.
+ *
+ * @param \WP_REST_Request $request Request.
+ * @return string
+ */
+ protected function get_client_ip( $request ) {
+ $ip = $request->get_header( 'X-Forwarded-For' );
+ if ( $ip ) {
+ $ip = trim( explode( ',', $ip )[0] );
+ }
+ if ( empty( $ip ) && isset( $_SERVER['REMOTE_ADDR'] ) ) {
+ $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
+ }
+ return $ip ? $ip : '0.0.0.0';
+ }
+
+ /**
+ * Collection params for GET.
+ *
+ * @return array
+ */
+ public function get_collection_params() {
+ return array(
+ 'per_page' => array(
+ 'description' => __( 'Maximum number of items to return.', 'fuxt-api' ),
+ 'type' => 'integer',
+ 'default' => 20,
+ 'minimum' => 1,
+ 'maximum' => 100,
+ 'sanitize_callback' => 'absint',
+ ),
+ 'page' => array(
+ 'description' => __( 'Page number.', 'fuxt-api' ),
+ 'type' => 'integer',
+ 'default' => 1,
+ 'minimum' => 1,
+ 'sanitize_callback' => 'absint',
+ ),
+ 'submissions_only' => array(
+ 'description' => __( 'If true or 1, return only images uploaded via the project-images POST endpoint.', 'fuxt-api' ),
+ 'type' => 'boolean',
+ 'default' => false,
+ 'sanitize_callback' => function ( $value ) {
+ return rest_sanitize_boolean( $value );
+ },
+ ),
+ );
+ }
+
+ /**
+ * Schema for project image.
+ *
+ * @return array
+ */
+ public function get_item_schema() {
+ return array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'fuxt_project_image',
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'description' => __( 'Attachment ID.', 'fuxt-api' ),
+ 'type' => 'integer',
+ ),
+ 'url' => array(
+ 'description' => __( 'Full URL to the image.', 'fuxt-api' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ ),
+ 'alt' => array(
+ 'description' => __( 'Alt text.', 'fuxt-api' ),
+ 'type' => 'string',
+ ),
+ 'width' => array(
+ 'description' => __( 'Image width in pixels.', 'fuxt-api' ),
+ 'type' => 'integer',
+ ),
+ 'height' => array(
+ 'description' => __( 'Image height in pixels.', 'fuxt-api' ),
+ 'type' => 'integer',
+ ),
+ 'mime_type' => array(
+ 'description' => __( 'MIME type.', 'fuxt-api' ),
+ 'type' => 'string',
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Handle POST: upload one image (form field "image" or "file").
+ *
+ * @param \WP_REST_Request $request Request.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ public function upload_item( $request ) {
+ $files = $request->get_file_params();
+ if ( empty( $files ) && ! empty( $_FILES ) ) {
+ $files = $_FILES;
+ }
+ $file = null;
+ $key = null;
+ foreach ( array( 'image', 'file' ) as $field ) {
+ if ( ! empty( $files[ $field ] ) && ! empty( $files[ $field ]['tmp_name'] ) && is_uploaded_file( $files[ $field ]['tmp_name'] ) ) {
+ $file = $files[ $field ];
+ $key = $field;
+ break;
+ }
+ }
+ if ( ! $file ) {
+ return new \WP_Error(
+ 'fuxt_rest_project_images_no_file',
+ __( 'No image file provided. Send multipart/form-data with field "image" or "file".', 'fuxt-api' ),
+ array( 'status' => 400 )
+ );
+ }
+ // Check real file size on disk -- the client-supplied $file['size'] is untrusted.
+ $actual_size = filesize( $file['tmp_name'] );
+ if ( false === $actual_size || $actual_size > self::MAX_FILE_SIZE_BYTES ) {
+ return new \WP_Error(
+ 'fuxt_rest_project_images_too_large',
+ __( 'File too large. Maximum 5 MB.', 'fuxt-api' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // Verify the file is actually a readable image of an allowed type --
+ // the client-supplied MIME type/extension can be spoofed, so this
+ // re-checks the real file contents instead of trusting $file['type'].
+ $image_info = @getimagesize( $file['tmp_name'] );
+ if ( false === $image_info || empty( $image_info['mime'] ) || ! in_array( $image_info['mime'], self::ALLOWED_MIME_TYPES, true ) ) {
+ return new \WP_Error(
+ 'fuxt_rest_project_images_invalid_type',
+ __( 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP.', 'fuxt-api' ),
+ array( 'status' => 400 )
+ );
+ }
+ require_once ABSPATH . 'wp-admin/includes/image.php';
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ require_once ABSPATH . 'wp-admin/includes/media.php';
+ $attachment_id = media_handle_upload( $key, 0 );
+ if ( is_wp_error( $attachment_id ) ) {
+ return new \WP_Error(
+ 'fuxt_rest_project_images_upload_failed',
+ $attachment_id->get_error_message(),
+ array( 'status' => 500 )
+ );
+ }
+ $attachment_id = (int) $attachment_id;
+ update_post_meta( $attachment_id, self::META_PROJECT_SUBMISSION, '1' );
+ return rest_ensure_response( $this->format_attachment_response( $attachment_id ) );
+ }
+
+ /**
+ * Format attachment as response item.
+ *
+ * @param int $attachment_id Attachment ID.
+ * @return array
+ */
+ protected function format_attachment_response( $attachment_id ) {
+ $url = wp_get_attachment_image_url( $attachment_id, 'full' );
+ $meta = wp_get_attachment_metadata( $attachment_id );
+ return array(
+ 'id' => $attachment_id,
+ 'url' => $url ? $url : wp_get_attachment_url( $attachment_id ),
+ 'alt' => (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ),
+ 'width' => isset( $meta['width'] ) ? (int) $meta['width'] : 0,
+ 'height' => isset( $meta['height'] ) ? (int) $meta['height'] : 0,
+ 'mime_type' => get_post_mime_type( $attachment_id ),
+ );
+ }
+
+ /**
+ * Handle GET: list recent project images (attachments).
+ *
+ * @param \WP_REST_Request $request Request.
+ * @return \WP_REST_Response
+ */
+ public function get_items( $request ) {
+ $per_page = $request->get_param( 'per_page' );
+ $page = $request->get_param( 'page' );
+ $submissions_only = $request->get_param( 'submissions_only' );
+ if ( is_string( $submissions_only ) ) {
+ $submissions_only = rest_sanitize_boolean( $submissions_only );
+ }
+ $query_args = array(
+ 'post_type' => 'attachment',
+ 'post_status' => 'inherit',
+ 'post_mime_type' => 'image',
+ 'posts_per_page' => $per_page,
+ 'paged' => $page,
+ 'orderby' => 'date',
+ 'order' => 'DESC',
+ );
+ if ( $submissions_only ) {
+ $query_args['meta_query'] = array(
+ array(
+ 'key' => self::META_PROJECT_SUBMISSION,
+ 'compare' => 'EXISTS',
+ ),
+ );
+ }
+ $query = new \WP_Query( $query_args );
+ $items = array();
+ foreach ( $query->posts as $post ) {
+ $items[] = $this->format_attachment_response( (int) $post->ID );
+ }
+ $response = rest_ensure_response( $items );
+ $response->header( 'X-WP-Total', (int) $query->found_posts );
+ $response->header( 'X-WP-TotalPages', (int) $query->max_num_pages );
+ return $response;
+ }
+}
diff --git a/includes/utils/class-post.php b/includes/utils/class-post.php
index 68b0d65..430ccd1 100644
--- a/includes/utils/class-post.php
+++ b/includes/utils/class-post.php
@@ -329,12 +329,15 @@ public static function can_user_read_post( $user_id, $post_id ) {
/**
* Get posts.
*
- * @param \WP_REST_Request $params Parameters
+ * @param \WP_REST_Request $params Parameters.
+ * @param array $additional_fields Additional fields.
+ * @param array $query_options Optional query options.
*
- * @return array
+ * @return array|null
*/
- public static function get_posts( $params, $additional_fields ) {
+ public static function get_posts( $params, $additional_fields, $query_options = array() ) {
$query_params = array();
+ $parent_post = null;
if ( isset( $params['post_parent_uri'] ) ) {
$parent_post = self::get_post_by_uri( $params['post_parent_uri'] );
@@ -354,41 +357,166 @@ public static function get_posts( $params, $additional_fields ) {
}
if ( ! empty( $params['term_slug'] ) ) {
+ if ( isset( $params['post_type'] ) ) {
+ $requested_types = array_map( 'trim', explode( ',', $params['post_type'] ) );
+ $valid_types = Utils::get_post_types();
+ $validated_types = array_values( array_filter( $requested_types, fn( $t ) => in_array( $t, $valid_types, true ) ) );
+ if ( empty( $validated_types ) ) {
+ return null;
+ }
+ $searchable_taxonomies = array_unique( array_merge( ...array_map( 'get_object_taxonomies', $validated_types ) ) );
+ } else {
+ $searchable_taxonomies = get_taxonomies();
+ }
+
+ // Optional comma-separated `taxonomy` param narrows term lookups --
+ // required when the same slug exists in multiple taxonomies (e.g.
+ // price-1 lives in both `pearl-feature` and `price`).
+ if ( ! empty( $params['taxonomy'] ) ) {
+ $requested_taxonomies = array_filter( array_map( 'trim', explode( ',', $params['taxonomy'] ) ) );
+ $searchable_taxonomies = array_values( array_intersect( (array) $searchable_taxonomies, $requested_taxonomies ) );
+ if ( empty( $searchable_taxonomies ) ) {
+ return null;
+ }
+ }
+
+ $term_slugs = array_values(
+ array_unique(
+ array_filter( array_map( 'trim', explode( ',', $params['term_slug'] ) ) )
+ )
+ );
+ if ( empty( $term_slugs ) ) {
+ return null;
+ }
+
+ // `term_operator=AND` requires posts to have every listed term
+ // within a taxonomy; default `IN` matches any of them.
+ $term_operator = 'IN';
+ if ( isset( $params['term_operator'] ) && 'AND' === strtoupper( (string) $params['term_operator'] ) ) {
+ $term_operator = 'AND';
+ }
+
$terms = get_terms(
array(
- 'taxonomy' => get_taxonomies(),
- 'slug' => $params['term_slug'],
+ 'taxonomy' => $searchable_taxonomies,
+ 'slug' => $term_slugs,
+ 'hide_empty' => false,
)
);
- if ( ! empty( $terms ) ) {
- $query_params['tax_query'] = array(
- array(
- 'taxonomy' => $terms[0]->taxonomy,
- 'field' => 'slug',
- 'terms' => $terms[0]->slug,
- ),
+ if ( is_wp_error( $terms ) || empty( $terms ) ) {
+ return null;
+ }
+
+ $by_taxonomy = array();
+ foreach ( $terms as $term ) {
+ $by_taxonomy[ $term->taxonomy ][] = $term->slug;
+ }
+
+ // One clause per taxonomy; multiple taxonomies always combine with AND.
+ $clauses = array();
+ foreach ( $by_taxonomy as $taxonomy => $slugs ) {
+ $clauses[] = array(
+ 'taxonomy' => $taxonomy,
+ 'field' => 'slug',
+ 'terms' => array_values( array_unique( $slugs ) ),
+ 'operator' => $term_operator,
);
+ }
- $taxonomy = get_taxonomy( $terms[0]->taxonomy );
- $query_params['post_type'] = $taxonomy->object_type;
+ if ( count( $clauses ) > 1 ) {
+ $query_params['tax_query'] = array_merge(
+ array( 'relation' => 'AND' ),
+ $clauses
+ );
+ } else {
+ $query_params['tax_query'] = $clauses;
+ }
- // Default order is menu_order for hierarchical post types such as page.
- if ( is_post_type_hierarchical( $parent_post->post_type ) ) {
- $query_params['orderby'] = 'menu_order';
- $query_params['order'] = 'ASC';
+ if ( ! isset( $params['post_type'] ) ) {
+ $taxonomy_obj = get_taxonomy( array_key_first( $by_taxonomy ) );
+ if ( $taxonomy_obj && ! empty( $taxonomy_obj->object_type ) ) {
+ $query_params['post_type'] = count( $taxonomy_obj->object_type ) === 1
+ ? reset( $taxonomy_obj->object_type )
+ : array_values( $taxonomy_obj->object_type );
+ } else {
+ $query_params['post_type'] = 'post';
+ }
+ }
+
+ if ( $parent_post instanceof \WP_Post && is_post_type_hierarchical( $parent_post->post_type ) ) {
+ $query_params['orderby'] = 'menu_order';
+ $query_params['order'] = 'ASC';
+ }
+ }
+
+ $priority_by_taxonomy = array();
+ if ( ! empty( $params['priority_term_slug'] ) ) {
+ $priority_post_type = null;
+ if ( isset( $params['post_type'] ) ) {
+ $priority_post_type = $params['post_type'];
+ } elseif ( isset( $query_params['post_type'] ) ) {
+ $priority_post_type = is_array( $query_params['post_type'] )
+ ? implode( ',', $query_params['post_type'] )
+ : $query_params['post_type'];
+ }
+
+ if ( null !== $priority_post_type ) {
+ $requested_types = array_map( 'trim', explode( ',', (string) $priority_post_type ) );
+ $valid_types = Utils::get_post_types();
+ $validated_types = array_values( array_filter( $requested_types, fn( $t ) => in_array( $t, $valid_types, true ) ) );
+ if ( empty( $validated_types ) ) {
+ return null;
}
+ $searchable_taxonomies = array_unique( array_merge( ...array_map( 'get_object_taxonomies', $validated_types ) ) );
} else {
+ $searchable_taxonomies = get_taxonomies();
+ }
+
+ $priority_slugs = array_values(
+ array_unique(
+ array_filter( array_map( 'trim', explode( ',', $params['priority_term_slug'] ) ) )
+ )
+ );
+ if ( empty( $priority_slugs ) ) {
+ return null;
+ }
+
+ $priority_terms = get_terms(
+ array(
+ 'taxonomy' => $searchable_taxonomies,
+ 'slug' => $priority_slugs,
+ 'hide_empty' => false,
+ )
+ );
+ if ( is_wp_error( $priority_terms ) || empty( $priority_terms ) ) {
return null;
}
+
+ foreach ( $priority_terms as $priority_term ) {
+ $priority_by_taxonomy[ $priority_term->taxonomy ][] = $priority_term->slug;
+ }
+ foreach ( $priority_by_taxonomy as $taxonomy => $slugs ) {
+ $priority_by_taxonomy[ $taxonomy ] = array_values( array_unique( $slugs ) );
+ }
}
if ( ! isset( $query_params['post_type'] ) ) {
if ( isset( $params['post_type'] ) ) {
- if ( ! in_array( $params['post_type'], Utils::get_post_types() ) ) {
+ $requested_types = array_map( 'trim', explode( ',', $params['post_type'] ) );
+ $valid_types = Utils::get_post_types();
+ $validated_types = array_filter( $requested_types, fn( $t ) => in_array( $t, $valid_types ) );
+
+ if ( empty( $validated_types ) ) {
return null;
}
- $query_params['post_type'] = $params['post_type'];
+
+ $query_params['post_type'] = count( $validated_types ) === 1
+ ? reset( $validated_types )
+ : array_values( $validated_types );
+ } elseif ( ! empty( $params['include'] ) ) {
+ // ID lookups span every exposed post type by default.
+ $query_params['post_type'] = array_values( Utils::get_post_types() );
} else {
$query_params['post_type'] = 'post';
}
@@ -410,8 +538,98 @@ public static function get_posts( $params, $additional_fields ) {
$query_params['order'] = $params['order'];
}
- $posts_query = new \WP_Query();
- $posts = $posts_query->query( $query_params );
+ if ( ! empty( $params['include'] ) ) {
+ $include_ids = array_slice( array_filter( wp_parse_id_list( $params['include'] ) ), 0, 100 );
+
+ if ( empty( $include_ids ) ) {
+ return null;
+ }
+
+ $query_params['post__in'] = $include_ids;
+ $query_params['posts_per_page'] = count( $include_ids );
+ $query_params['ignore_sticky_posts'] = true;
+ $query_params['post_status'] = 'publish';
+
+ // Preserve requested ID order unless the caller explicitly set an orderby.
+ $explicit_params = $params instanceof \WP_REST_Request ? $params->get_query_params() : array();
+ if ( ! isset( $explicit_params['orderby'] ) ) {
+ $query_params['orderby'] = 'post__in';
+ }
+ }
+
+ $featured_image = isset( $query_options['featured_image'] ) ? (string) $query_options['featured_image'] : 'any';
+ if ( ! in_array( $featured_image, array( 'any', 'priority' ), true ) ) {
+ $featured_image = 'any';
+ }
+
+ $priority_term_orderby = '';
+ if ( ! empty( $priority_by_taxonomy ) ) {
+ global $wpdb;
+ $priority_taxonomy_clauses = array();
+ foreach ( $priority_by_taxonomy as $taxonomy => $slugs ) {
+ $placeholders = implode( ', ', array_fill( 0, count( $slugs ), '%s' ) );
+ $priority_taxonomy_clauses[] = $wpdb->prepare(
+ "(tt.taxonomy = %s AND t.slug IN ({$placeholders}))",
+ array_merge( array( $taxonomy ), $slugs )
+ );
+ }
+
+ if ( ! empty( $priority_taxonomy_clauses ) ) {
+ $required_taxonomy_count = count( $priority_taxonomy_clauses );
+ $priority_term_orderby = "(CASE WHEN (SELECT COUNT(DISTINCT tt.taxonomy) FROM {$wpdb->term_relationships} tr INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id INNER JOIN {$wpdb->terms} t ON tt.term_id = t.term_id WHERE tr.object_id = {$wpdb->posts}.ID AND (" . implode( ' OR ', $priority_taxonomy_clauses ) . ")) = {$required_taxonomy_count} THEN 0 ELSE 1 END) ASC";
+ }
+ }
+
+ $priority_featured = ( 'priority' === $featured_image );
+ $join_filter = null;
+ $orderby_filter = null;
+ $posts_query = new \WP_Query();
+ $alias = 'fuxt_featimg_sort';
+
+ if ( $priority_featured ) {
+ global $wpdb;
+
+ $join_filter = static function ( $join, $query ) use ( $wpdb, $alias, $posts_query ) {
+ if ( $query !== $posts_query ) {
+ return $join;
+ }
+ $join .= " LEFT JOIN {$wpdb->postmeta} AS {$alias} ON ({$wpdb->posts}.ID = {$alias}.post_id AND {$alias}.meta_key = '_thumbnail_id') ";
+ return $join;
+ };
+ add_filter( 'posts_join', $join_filter, 10, 2 );
+ }
+
+ if ( $priority_featured || '' !== $priority_term_orderby ) {
+ $orderby_filter = static function ( $orderby, $query ) use ( $alias, $posts_query, $priority_featured, $priority_term_orderby ) {
+ if ( $query !== $posts_query ) {
+ return $orderby;
+ }
+ $pieces = array();
+ if ( $priority_featured ) {
+ $pieces[] = "(CASE WHEN {$alias}.meta_id IS NOT NULL AND {$alias}.meta_value IS NOT NULL AND {$alias}.meta_value != '' AND {$alias}.meta_value != '0' THEN 0 ELSE 1 END) ASC";
+ }
+ if ( '' !== $priority_term_orderby ) {
+ $pieces[] = $priority_term_orderby;
+ }
+
+ if ( empty( $pieces ) ) {
+ return $orderby;
+ }
+
+ $priority_orderby = implode( ', ', $pieces );
+ return $orderby ? $priority_orderby . ', ' . $orderby : $priority_orderby;
+ };
+ add_filter( 'posts_orderby', $orderby_filter, 10, 2 );
+ }
+
+ $posts = $posts_query->query( $query_params );
+
+ if ( $priority_featured && $join_filter ) {
+ remove_filter( 'posts_join', $join_filter, 10 );
+ }
+ if ( $orderby_filter ) {
+ remove_filter( 'posts_orderby', $orderby_filter, 10 );
+ }
$post_list = array();
$post_params = array();