types = array_values($types); } /** * Register, taking only the content types nobody has claimed yet. * * The claimed set is computed from the `$sources` array as passed in, never by asking the * registry. The registry builds itself by running this very filter, so consulting it here would recurse * into a half-built object — the kind of failure that shows up as an unexplained fatal on one site and * never on another. */ public static function register(array $sources): array { if (!class_exists('Catalog')) { return $sources; } $claimed = []; foreach ($sources as $s) { if ($s instanceof \AumViso_Product_Source) { foreach ($s->get_post_types() as $pt) { $claimed[$pt] = true; } } } $mine = array_diff(array_keys(Catalog::adapters()), array_keys($claimed)); if ($mine) { $sources[] = new self($mine); } return $sources; } /* Identity */ public function get_id(): string { return 'aumcreate-catalog'; } public function get_label(): string { return __('AumCreate catalogue', 'aumframe'); } public function is_available(): bool { return class_exists('Catalog') && $this->types !== []; } public function get_post_types(): array { return $this->types; } public function supports_schema(): bool { return true; } /* Querying */ public function count(): int { $n = 0; foreach ($this->types as $pt) { $counts = (array) wp_count_posts($pt); $n += (int) ($counts['publish'] ?? 0); } return $n; } public function query(array $args): array { return get_posts([ 'post_type' => $this->types, 'post_status' => 'publish', 'posts_per_page' => (int) ($args['per_page'] ?? 20), 'paged' => (int) ($args['paged'] ?? 1), 's' => (string) ($args['search'] ?? ''), 'fields' => 'ids', 'orderby' => 'date', 'order' => 'DESC', ]); } /* One product */ public function get(int $id): ?array { if (!in_array(get_post_type($id), $this->types, true)) { return null; } $adapter = Catalog::adapter_for($id); if (!$adapter) { return null; } $f = static function ($key) use ($adapter, $id) { $v = $adapter->field($key, $id); return $v === null ? '' : $v; }; return [ 'source' => $this->get_id(), 'id' => $id, 'title' => get_the_title($id), 'permalink' => (string) get_permalink($id), 'description' => $this->description($id), 'sku' => (string) $f('sku'), 'price' => $this->price($f), 'attributes' => $this->attributes($f), 'images' => $this->images($id), 'categories' => $this->categories($id), 'in_stock' => $this->in_stock($f), ]; } /** * Prose for the product. * * The excerpt first, then `_aumframe_search_text` — the rendered words this stack already extracts from the * baked layout for site search. On a page the editor renders, `post_content` is empty, so without that * fallback the description would be blank on exactly the products that have the most designed copy. */ private function description(int $id): string { $excerpt = trim((string) get_the_excerpt($id)); if ($excerpt !== '') { return $excerpt; } $post = get_post($id); $body = $post ? trim(wp_strip_all_tags((string) $post->post_content)) : ''; if ($body === '') { $body = trim((string) get_post_meta($id, '_aumframe_search_text', true)); } return $body === '' ? '' : wp_trim_words($body, 60, ''); } /** * Money, in the shape AumViso normalises to. * * `price_from` / `price_to` / `price` come out of the adapters already formatted for people — * "$120.00", "From €500". Schema.org wants a number, so each is parsed back, and **anything that does * not parse cleanly is reported as `text` rather than guessed at**. A wrong `price` in structured data * is worse than no price: it is what a search result shows, and it is machine-read as fact. */ private function price(callable $f): array { $kind = (string) $f('price_kind'); $currency = (string) $f('currency'); $blank = ['type' => 'hidden', 'min' => '', 'max' => '', 'currency' => $currency, 'text' => '']; if ($kind === 'none' || $kind === '') { return $blank; } if ($kind === 'text') { return ['type' => 'text', 'min' => '', 'max' => '', 'currency' => $currency, 'text' => (string) $f('price_html')]; } if ($kind === 'range') { $min = self::number($f('price_from')); $max = self::number($f('price_to')); if ($min === null) { return ['type' => 'text', 'min' => '', 'max' => '', 'currency' => $currency, 'text' => (string) $f('price_html')]; } return ['type' => 'range', 'min' => $min, 'max' => $max ?? '', 'currency' => $currency, 'text' => '']; } $one = self::number($f('price')); if ($one === null) { return ['type' => 'text', 'min' => '', 'max' => '', 'currency' => $currency, 'text' => (string) $f('price_html')]; } return ['type' => 'fixed', 'min' => $one, 'max' => '', 'currency' => $currency, 'text' => '']; } /** * "$1,299.50" → 1299.5, and null when it is not a plain number. * * Returning null rather than a best guess is the point: a string like "Contact for price" or * "500–800" must fall through to the `text` shape, not become the number 500. */ private static function number($raw): ?float { $s = trim((string) $raw); if ($s === '') { return null; } // Strip currency symbols and thin/space separators, then thousands separators. $s = preg_replace('/[^\d.,\-]/u', '', $s); $s = str_replace(',', '', (string) $s); return is_numeric($s) ? (float) $s : null; } /** `specs` rows are label/value; AumViso wants key/value. */ private function attributes(callable $f): array { $out = []; foreach ((array) $f('specs') as $row) { if (!is_array($row)) { continue; } $k = trim((string) ($row['label'] ?? '')); $v = trim((string) ($row['value'] ?? '')); if ($k !== '' || $v !== '') { $out[] = ['key' => $k, 'value' => $v]; } } return $out; } /** Featured image first — that is the one AumViso treats as the main image. */ private function images(int $id): array { $out = []; $main = get_the_post_thumbnail_url($id, 'full'); if ($main) { $out[] = $main; } $adapter = Catalog::adapter_for($id); foreach ((array) ($adapter ? $adapter->field('gallery', $id) : []) as $row) { $url = is_array($row) ? ($row['url'] ?? '') : $row; $url = is_string($url) ? $url : ''; if ($url !== '' && !in_array($url, $out, true)) { $out[] = $url; } } return $out; } private function categories(int $id): array { $out = []; foreach (get_object_taxonomies(get_post_type($id), 'objects') as $tax) { if (!$tax->public) { continue; } foreach ((array) get_the_terms($id, $tax->name) as $term) { if ($term instanceof \WP_Term) { $out[] = ['name' => $term->name, 'slug' => $term->slug]; } } } return $out; } /** * `null`, not `false`, when the engine has no opinion. * * The adapters return null for "unresolvable" — AumReserva cannot say whether a service is bookable * without a date — and AumViso's shape reserves `null` for exactly that. Collapsing it to `false` would * publish "out of stock" as structured data about a product that is perfectly available. */ private function in_stock(callable $f): ?bool { $v = $f('in_stock'); if ($v === '' || $v === null) { return null; } return (bool) $v; } }