*/ private array $manufacturerCache = []; public const DENIS3D_ENDPOINT = 'https://denis3d.eu/wp-json/wp/v2/filament'; private const CACHE_TTL = 3600; public function __construct( private readonly string $providerId = 'remote_rest', private readonly string $endpointUrl = self::DENIS3D_ENDPOINT, private readonly string $statusOption = 'ds82_3dps_remote_rest_status' ) {} public function get_id(): string { return $this->providerId; } public function get_materials(bool $forceRefresh = false): array { if (!$forceRefresh) { $cached = $this->get_cached_materials(); if ($cached) { return $cached; } } $result = $this->synchronize(); return $result['materials']; } public function get_cached_materials(): array { $cached = get_transient($this->cache_key()); if (!is_array($cached)) { return []; } return $this->hydrate($cached); } public function get_material(string $identifier): ?Material { $materials = $this->get_materials(); if (isset($materials[$identifier])) { return $materials[$identifier]; } $needle = strtolower(trim($identifier)); foreach ($materials as $material) { if (!$material instanceof Material) continue; if ($needle === strtolower($material->slug) || $needle === strtolower($material->display_id())) { return $material; } } return null; } public function clear_cache(): void { delete_transient($this->cache_key()); } public function status(): array { $status = get_option($this->statusOption, []); return is_array($status) ? $status : []; } public function test_connection(): array { $endpoint = $this->endpoint(); $response = $this->request_page(1); if (is_wp_error($response)) { $status = ['ok' => false, 'message' => $response->get_error_message(), 'time' => time()]; update_option($this->statusOption, $status, false); return $status; } $records = json_decode((string) wp_remote_retrieve_body($response), true); if (!is_array($records)) { $status = ['ok' => false, 'message' => __('The external database returned invalid JSON.', '3d-print-showcase'), 'time' => time()]; update_option($this->statusOption, $status, false); return $status; } $total = absint(wp_remote_retrieve_header($response, 'x-wp-total')); $pages = absint(wp_remote_retrieve_header($response, 'x-wp-totalpages')); $status = [ 'ok' => true, 'message' => sprintf( __('External database connection is working. Endpoint: %1$s. Total records reported by WordPress: %2$d. Total pages: %3$d.', '3d-print-showcase'), $endpoint, $total ?: count($records), $pages ?: 1 ), 'count' => $total ?: count($records), 'pages' => $pages ?: 1, 'time' => time(), ]; update_option($this->statusOption, $status, false); return $status; } /** @return array{success:bool,message:string,count:int,pages:int,materials:array} */ private function synchronize(): array { $allRecords = []; $page = 1; $knownTotalPages = 0; $maxPages = 1000; while ($page <= $maxPages) { $response = $this->request_page($page); if (is_wp_error($response)) { return $this->failure($response->get_error_message()); } $code = (int) wp_remote_retrieve_response_code($response); $body = (string) wp_remote_retrieve_body($response); $records = json_decode($body, true); if ($code < 200 || $code >= 300) { return $this->failure(sprintf(__('External database request failed on page %1$d with HTTP status %2$d.', '3d-print-showcase'), $page, $code)); } if (!is_array($records)) { return $this->failure(sprintf(__('External database returned invalid JSON on page %d.', '3d-print-showcase'), $page)); } if ($page === 1) { $knownTotalPages = absint(wp_remote_retrieve_header($response, 'x-wp-totalpages')); } foreach ($records as $record) { if (is_array($record)) { $allRecords[] = $record; } } if ($knownTotalPages > 0) { if ($page >= $knownTotalPages) break; } elseif (count($records) < 100) { break; } $page++; } $materials = []; $skipped = 0; foreach ($allRecords as $record) { $material = $this->normalize_record($record); if (!$material) { $skipped++; continue; } $materials[$material->identifier()] = $material; } if (!$materials) { return $this->failure(__('The external database was reached, but no records contained a usable filament color.', '3d-print-showcase')); } $serialized = []; foreach ($materials as $identifier => $material) { $serialized[$identifier] = $this->serialize_material($material); } set_transient($this->cache_key(), $serialized, self::CACHE_TTL); $pagesLoaded = max(1, $knownTotalPages ?: $page); $status = [ 'ok' => true, 'message' => sprintf( __('External database synchronized. Loaded %1$d REST records across %2$d page(s), created %3$d materials, skipped %4$d records without a usable color.', '3d-print-showcase'), count($allRecords), $pagesLoaded, count($materials), $skipped ), 'count' => count($materials), 'records' => count($allRecords), 'pages' => $pagesLoaded, 'skipped' => $skipped, 'time' => time(), ]; update_option($this->statusOption, $status, false); return ['success' => true, 'message' => (string) $status['message'], 'count' => count($materials), 'pages' => $pagesLoaded, 'materials' => $materials]; } private function request_page(int $page): array|\WP_Error { $url = add_query_arg([ 'per_page' => 100, 'page' => max(1, $page), ], $this->endpoint()); return wp_safe_remote_get($url, [ 'timeout' => 20, 'redirection' => 3, 'headers' => [ 'Accept' => 'application/json', 'User-Agent' => '3D Print Showcase/' . (defined('DS82_3DPS_VERSION') ? DS82_3DPS_VERSION : 'unknown'), ], ]); } private function endpoint(): string { return untrailingslashit($this->endpointUrl ?: self::DENIS3D_ENDPOINT); } private function cache_key(): string { return 'ds82_3dps_remote_' . substr(md5($this->endpoint()), 0, 20); } private function normalize_record(array $record): ?Material { $acf = is_array($record['acf'] ?? null) ? $record['acf'] : []; $value = static function (string $key) use ($record, $acf): mixed { if (array_key_exists($key, $acf)) return $acf[$key]; return $record[$key] ?? null; }; $id = absint($record['id'] ?? 0); $slug = sanitize_key((string) ($value('filament_id') ?: ($record['slug'] ?? ''))); if ($slug === '') { $slug = $id > 0 ? (string) $id : ''; } $title = ''; if (is_array($record['title'] ?? null)) { $title = wp_strip_all_tags((string) ($record['title']['rendered'] ?? '')); } elseif (isset($record['title'])) { $title = wp_strip_all_tags((string) $record['title']); } $name = sanitize_text_field((string) ($value('filament_nazev_barvy_vyrobce') ?: $title ?: $slug)); $parsedColors = []; $opacity = 1.0; foreach (['filament_hex_barva', 'filament_hex_barva2', 'filament_hex_barva3', 'filament_hex_barva4'] as $index => $field) { $parsed = $this->parse_color($value($field)); if (!$parsed) continue; $parsedColors[] = $parsed['hex']; if ($index === 0) { $opacity = $parsed['alpha']; } } if (!$parsedColors) { return null; } $count = min(4, count($parsedColors)); $colorMode = match ($count) { 2 => 'dual', 3 => 'triple', 4 => 'quad', default => 'single', }; $remoteUrl = ''; if (is_array($record['_links']['self'] ?? null) && is_array($record['_links']['self'][0] ?? null)) { $remoteUrl = esc_url_raw((string) ($record['_links']['self'][0]['href'] ?? '')); } $materialType = $this->acf_choice_label('filament_material', $value('filament_material')); $surfaceLabel = $this->acf_choice_label('filament_material_varianta', $value('filament_material_varianta')); $appearanceType = $this->map_appearance($surfaceLabel); $manufacturer = $this->resolve_manufacturer($value('filament_vyrobce')); return new Material( source: $this->providerId, sourceId: $id, slug: $slug, name: $name, materialType: $materialType !== '' ? $materialType : null, appearanceType: $appearanceType, colorMode: $colorMode, colorModeType: $this->map_layout((string) $value('filament_typ_barevneho_rezimu')), manufacturer: $manufacturer !== '' ? $manufacturer : null, baseColor: $parsedColors[0], secondaryColors: array_slice($parsedColors, 1, 3), opacity: $opacity, transparent: $opacity < 0.999, sourceData: [ 'remote_endpoint' => $this->endpoint(), 'remote_record_url' => $remoteUrl, 'remote_post_id' => $id, 'remote_color_type' => sanitize_text_field((string) $value('filament_barevny_rezim')), 'remote_material' => $materialType, 'remote_material_variant' => $surfaceLabel, 'remote_manufacturer' => $manufacturer, 'remote_manufacturer_id' => is_numeric($value('filament_vyrobce')) ? absint($value('filament_vyrobce')) : 0, 'remote_record' => $record, ] ); } private function acf_choice_label(string $field, mixed $raw): string { $choices = [ 'filament_material' => [ 'pla' => 'PLA', 'pla+' => 'PLA+', 'petg' => 'PETG', 'asa' => 'ASA', 'abs' => 'ABS', 'tpu68d' => 'TPU 68D', 'tpe' => 'TPE', 'pc' => 'PC', 'pa' => 'PA (Nylon)', 'hips' => 'HIPS', 'pva' => 'PVA', 'podpurny' => 'Podpůrný', 'ostatni' => 'Ostatní', ], 'filament_material_varianta' => [ 'standardni' => 'Standardní', 'silk' => 'Silk / Lesklá', 'matny' => 'Matná', 'chameleon' => 'Chameleon', 'reflexni' => 'Reflexní', 'uvreaktivni' => 'UV reaktivní', 'drevo' => 'Dřevo', 'metalicka' => 'Metalická', 'kovovy' => 'Kovový efekt', 'svitici' => 'Svítící ve tmě', 'termochromicka' => 'Termochromická', 'mramor' => 'Mramor', 'mramorcihla' => 'Mramor cihla', 'galaxy' => 'Galaxy', 'castecnetransparentni' => 'Částečně transparentní', 'transparentni' => 'Transparentní', 'carbon' => 'Carbon', 'skelne_vlakno' => 'Skelné vlákno', 'high_speed' => 'High Speed', 'zvysena_odolnost' => 'Zvýšená odolnost', 'lehceny' => 'Lehčený', 'flexibilni' => 'Flexibilní', 'ostatni' => 'Ostatní', ], ]; $items = is_array($raw) ? $raw : [$raw]; $labels = []; foreach ($items as $item) { if (!is_scalar($item)) continue; $text = trim((string) $item); if ($text === '') continue; // If the REST API already returns the ACF label, keep it exactly as received. if (isset($choices[$field]) && array_key_exists($text, $choices[$field])) { $text = $choices[$field][$text]; } $labels[] = sanitize_text_field($text); } return implode(', ', array_values(array_unique(array_filter($labels)))); } private function normalize_scalar_label(mixed $value): string { if (is_array($value)) { foreach ($value as $item) { if (is_scalar($item) && trim((string) $item) !== '') { return sanitize_text_field((string) $item); } } return ''; } return is_scalar($value) ? sanitize_text_field((string) $value) : ''; } private function map_appearance(mixed $raw): string { $values = is_array($raw) ? $raw : [$raw]; if (!$values) return 'standard'; // ACF field filament_material_varianta is a checkbox and may return // either stored keys or human-readable labels. Prefer the first // recognized surface-style variant, but keep a deterministic order. foreach ($values as $item) { $text = remove_accents(strtolower(trim((string) $item))); $key = sanitize_key($text); if ($key === '' || in_array($key, ['standardni', 'standard'], true)) return 'standard'; if (str_contains($text, 'silk') || str_contains($text, 'leskl')) return 'silk'; if (str_contains($text, 'matn') || $key === 'matny') return 'matte'; if (str_contains($text, 'metalick')) return 'metallic'; if (str_contains($text, 'kovov')) return 'metallic'; if (str_contains($text, 'transparent') || str_contains($text, 'pruhled')) return 'transparent'; if (str_contains($text, 'carbon')) return 'carbon'; if (str_contains($text, 'drevo') || str_contains($text, 'wood')) return 'wood'; if (str_contains($text, 'chameleon') || str_contains($text, 'perlet')) return 'pearl'; if (str_contains($text, 'reflex') || str_contains($text, 'galaxy') || str_contains($text, 'trpyt')) return 'glitter'; } return 'standard'; } private function resolve_manufacturer(mixed $raw): string { // Some REST configurations may expose an expanded post object. if (is_array($raw)) { foreach (['post_title', 'title', 'name', 'label'] as $key) { if (!isset($raw[$key])) continue; $candidate = $raw[$key]; if (is_array($candidate)) $candidate = $candidate['rendered'] ?? ''; $candidate = sanitize_text_field(wp_strip_all_tags((string) $candidate)); if ($candidate !== '') return $candidate; } $raw = $raw['ID'] ?? $raw['id'] ?? 0; } $manufacturerId = absint($raw); if (!$manufacturerId) return ''; if (array_key_exists($manufacturerId, $this->manufacturerCache)) { return $this->manufacturerCache[$manufacturerId]; } $url = 'https://denis3d.eu/wp-json/wp/v2/vyrobci-filamentu/' . $manufacturerId; $response = wp_safe_remote_get($url, [ 'timeout' => 15, 'redirection' => 3, 'headers' => [ 'Accept' => 'application/json', 'User-Agent' => '3D Print Showcase/' . (defined('DS82_3DPS_VERSION') ? DS82_3DPS_VERSION : 'unknown'), ], ]); $name = ''; if (!is_wp_error($response) && (int) wp_remote_retrieve_response_code($response) >= 200 && (int) wp_remote_retrieve_response_code($response) < 300) { $data = json_decode((string) wp_remote_retrieve_body($response), true); if (is_array($data)) { if (is_array($data['title'] ?? null)) { $name = sanitize_text_field(wp_strip_all_tags((string) ($data['title']['rendered'] ?? ''))); } elseif (isset($data['title'])) { $name = sanitize_text_field(wp_strip_all_tags((string) $data['title'])); } } } $this->manufacturerCache[$manufacturerId] = $name; return $name; } private function map_layout(string $value): string { $value = sanitize_key($value); return match ($value) { 'prechodovy', 'gradient' => 'gradient', 'duhovy', 'rainbow' => 'rainbow', 'vrstveny', 'layered' => 'layered', default => 'standard', }; } /** @return array{hex:string,alpha:float}|null */ private function parse_color(mixed $raw): ?array { $value = trim((string) $raw); if ($value === '') return null; $hex = sanitize_hex_color($value); if ($hex) return ['hex' => $hex, 'alpha' => 1.0]; if (preg_match('/^#([0-9a-f]{4}|[0-9a-f]{8})$/i', $value, $m)) { $digits = strtolower($m[1]); if (strlen($digits) === 4) { $r = str_repeat($digits[0], 2); $g = str_repeat($digits[1], 2); $b = str_repeat($digits[2], 2); $a = str_repeat($digits[3], 2); } else { [$r,$g,$b,$a] = [substr($digits,0,2), substr($digits,2,2), substr($digits,4,2), substr($digits,6,2)]; } return ['hex' => '#' . $r . $g . $b, 'alpha' => round(hexdec($a) / 255, 4)]; } if (preg_match('/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+)\s*)?\)$/i', $value, $m)) { $r = min(255, max(0, (int) round((float) $m[1]))); $g = min(255, max(0, (int) round((float) $m[2]))); $b = min(255, max(0, (int) round((float) $m[3]))); $a = isset($m[4]) && $m[4] !== '' ? min(1.0, max(0.0, (float) $m[4])) : 1.0; return ['hex' => sprintf('#%02x%02x%02x', $r, $g, $b), 'alpha' => $a]; } return null; } private function serialize_material(Material $material): array { return [ 'source_id' => $material->sourceId, 'slug' => $material->slug, 'name' => $material->name, 'material_type' => $material->materialType, 'appearance_type' => $material->appearanceType, 'manufacturer' => $material->manufacturer, 'color_mode' => $material->colorMode, 'color_mode_type' => $material->colorModeType, 'base_color' => $material->baseColor, 'secondary_colors' => $material->secondaryColors, 'opacity' => $material->opacity, 'transparent' => $material->transparent, 'source_data' => $material->sourceData, ]; } private function hydrate(array $cached): array { $result = []; foreach ($cached as $data) { if (!is_array($data)) continue; $material = new Material( source: $this->providerId, sourceId: absint($data['source_id'] ?? 0), slug: sanitize_key((string) ($data['slug'] ?? '')), name: sanitize_text_field((string) ($data['name'] ?? '')), materialType: isset($data['material_type']) && (string) $data['material_type'] !== '' ? sanitize_text_field((string) $data['material_type']) : null, appearanceType: isset($data['appearance_type']) && (string) $data['appearance_type'] !== '' ? sanitize_key((string) $data['appearance_type']) : 'standard', colorMode: isset($data['color_mode']) ? sanitize_key((string) $data['color_mode']) : null, colorModeType: isset($data['color_mode_type']) ? sanitize_key((string) $data['color_mode_type']) : null, manufacturer: isset($data['manufacturer']) && (string) $data['manufacturer'] !== '' ? sanitize_text_field((string) $data['manufacturer']) : null, baseColor: sanitize_hex_color((string) ($data['base_color'] ?? '')) ?: null, secondaryColors: array_values(array_filter(array_map('sanitize_hex_color', (array) ($data['secondary_colors'] ?? [])))), opacity: min(1, max(0, (float) ($data['opacity'] ?? 1))), transparent: !empty($data['transparent']), sourceData: is_array($data['source_data'] ?? null) ? $data['source_data'] : [] ); $result[$material->identifier()] = $material; } return $result; } private function failure(string $message): array { $status = ['ok' => false, 'message' => $message, 'count' => 0, 'pages' => 0, 'time' => time()]; update_option($this->statusOption, $status, false); return ['success' => false, 'message' => $message, 'count' => 0, 'pages' => 0, 'materials' => []]; } }