settings = $settings ?: Plugin::settings(); $this->baseUrl = self::normalizeBaseUrl((string) ($this->settings['olm_base_url'] ?? '')); } public static function items(): array { $items = get_option(self::OPTION_ITEMS, []); return is_array($items) ? $items : []; } public static function lastSync(): string { return (string) get_option(self::OPTION_LAST_SYNC, ''); } public static function normalizeBaseUrl(string $url): string { $url = trim($url); if ('' === $url) { return ''; } if (! preg_match('#^https?://#i', $url)) { $url = 'https://' . $url; } return rtrim($url, '/'); } public function sync(): \WP_REST_Response { ImportLogger::info('OLM changelog synchronization started.'); $token = $this->login(); if (is_wp_error($token)) { ImportLogger::error('OLM changelog login failed: ' . $token->get_error_message()); return new \WP_REST_Response(['success' => false, 'message' => $token->get_error_message()], 500); } $this->headers = [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $token, ]; $downloadIds = $this->productDownloadIds(); if (is_wp_error($downloadIds)) { ImportLogger::error('OLM product lookup failed: ' . $downloadIds->get_error_message()); return new \WP_REST_Response(['success' => false, 'message' => $downloadIds->get_error_message()], 500); } $items = []; foreach ($downloadIds as $downloadId) { $versions = $this->downloadFieldVersions($downloadId); if (is_wp_error($versions)) { ImportLogger::warning('OLM download field lookup failed for ' . $downloadId . ': ' . $versions->get_error_message()); continue; } foreach ($versions as $version) { $item = $this->normalizeVersion($version); if (null !== $item) { $items[] = $item; } } } usort($items, static fn (array $a, array $b): int => ($b['_timestamp'] ?? 0) <=> ($a['_timestamp'] ?? 0)); $items = array_filter($items, fn (array $item): bool => $this->isInDateWindow($item)); $items = array_map(static function (array $item): array { unset($item['_timestamp']); return $item; }, array_values($items)); update_option(self::OPTION_ITEMS, $items, false); update_option(self::OPTION_LAST_SYNC, current_time('mysql'), false); ImportLogger::info('OLM changelog synchronization completed. Entries: ' . count($items)); return new \WP_REST_Response([ 'success' => true, 'stats' => [ 'downloads' => count($downloadIds), 'updates' => count($items), ], ]); } private function login(): string|\WP_Error { if ('' === $this->baseUrl) { return new \WP_Error('kb_olm_missing_base_url', __('OLM base URL missing.', 'kb-markdown-importer')); } $username = trim((string) ($this->settings['olm_username'] ?? '')); $password = (string) ($this->settings['olm_password'] ?? ''); if ('' === $username || '' === $password) { return new \WP_Error('kb_olm_missing_credentials', __('OLM credentials missing.', 'kb-markdown-importer')); } $response = wp_remote_post($this->baseUrl . '/login', [ 'timeout' => 12, 'redirection' => 3, 'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], 'body' => wp_json_encode([ 'username' => $username, 'password' => $password, ]), ]); if (is_wp_error($response)) { return $response; } $status = (int) wp_remote_retrieve_response_code($response); $body = (string) wp_remote_retrieve_body($response); $data = json_decode($body, true); if ($status < 200 || $status >= 300 || ! is_array($data) || empty($data['bearerToken'])) { return new \WP_Error( 'kb_olm_login_failed', __('OLM login failed.', 'kb-markdown-importer'), [ 'status' => $status, 'response_excerpt' => substr(wp_strip_all_tags($body), 0, 1000), ] ); } return (string) $data['bearerToken']; } private function productDownloadIds(): array|\WP_Error { $ids = []; $page = 1; while (true) { $data = $this->getJson($this->baseUrl . '/api/rest/v1/product?page=' . $page . '&size=1'); if (is_wp_error($data)) { return $data; } $products = is_array($data['content'] ?? null) ? $data['content'] : []; if (! $products) { break; } foreach ($products as $product) { if (! is_array($product)) { continue; } foreach ((array) ($product['downloads'] ?? []) as $download) { $id = is_array($download) ? (string) ($download['id'] ?? '') : ''; if ('' !== $id) { $ids[$id] = $id; } } } $page++; } return array_values($ids); } private function downloadFieldVersions(string $downloadId): array|\WP_Error { $versions = []; $page = 1; while (true) { $data = $this->getJson($this->baseUrl . '/api/rest/v1/download/field/' . rawurlencode($downloadId) . '?page=' . $page . '&size=1'); if (is_wp_error($data)) { return $data; } $content = is_array($data['content'] ?? null) ? $data['content'] : []; if (! $content) { break; } foreach ($content as $version) { if (is_array($version)) { $versions[] = $version; } } $page++; } return $versions; } private function getJson(string $url): array|\WP_Error { $response = wp_remote_get($url, [ 'timeout' => 12, 'redirection' => 3, 'headers' => $this->headers, 'user-agent' => 'KB Markdown Importer/' . KB_MARKDOWN_IMPORTER_VERSION, ]); if (is_wp_error($response)) { return $response; } $status = (int) wp_remote_retrieve_response_code($response); $body = (string) wp_remote_retrieve_body($response); $data = json_decode($body, true); if ($status < 200 || $status >= 300 || ! is_array($data)) { return new \WP_Error( 'kb_olm_request_failed', __('OLM request failed.', 'kb-markdown-importer'), [ 'status' => $status, 'url' => $url, 'response_excerpt' => substr(wp_strip_all_tags($body), 0, 1000), ] ); } return $data; } private function normalizeVersion(array $version): ?array { $productVersion = is_array($version['productVersion'] ?? null) ? $version['productVersion'] : []; $product = is_array($productVersion['product'] ?? null) ? $productVersion['product'] : []; $downloadField = is_array($version['downloadField'] ?? null) ? $version['downloadField'] : []; $productNo = strtolower((string) ($product['productNo'] ?? '')); $publishedAt = (string) ($version['publishedAt'] ?? ''); if (true !== ($version['qa'] ?? false) || true !== ($product['published'] ?? false) || '' === $publishedAt) { return null; } if (in_array($productNo, $this->ignoredOlmNumbers(), true)) { return null; } $timestamp = $this->dateTimestamp($publishedAt); $productName = $this->cleanText((string) ($product['name'] ?? '')); $downloadName = $this->cleanText((string) ($downloadField['name'] ?? '')); if (true !== ($downloadField['starfaceModule'] ?? false) && '' !== $downloadName) { $productName = trim($productName . ' - ' . $downloadName); } $changelogLines = $this->changelogLines((string) ($version['changelog'] ?? '')); return [ 'product' => $productName, 'version' => $this->moduleVersion($productVersion, $version), 'date' => $this->formatDate($publishedAt), 'month_label' => $timestamp > 0 ? wp_date('F Y', $timestamp) : '', 'changelog' => implode(' ', $changelogLines), 'changelog_lines' => $changelogLines, 'starface_min' => $this->starfaceVersion(is_array($productVersion['minStarfaceVersion'] ?? null) ? $productVersion['minStarfaceVersion'] : []), 'starface_max' => $this->starfaceVersion(is_array($productVersion['maxStarfaceVersion'] ?? null) ? $productVersion['maxStarfaceVersion'] : []), 'link' => $this->validUrl((string) ($product['productPageURI'] ?? '')), 'download_link' => '' !== $productNo ? 'https://get.o-byte.com?olm=' . rawurlencode($productNo) : '', 'product_no' => $productNo, 'published_at' => $timestamp > 0 ? wp_date('Y-m-d', $timestamp) : '', '_timestamp' => $timestamp, ]; } private function isInDateWindow(array $item): bool { $timestamp = (int) ($item['_timestamp'] ?? 0); if ($timestamp <= 0) { return false; } $months = max(1, min(24, (int) ($this->settings['product_updates_olm_months'] ?? 4))); $timezone = wp_timezone(); $date = (new \DateTimeImmutable('@' . $timestamp))->setTimezone($timezone); $now = new \DateTimeImmutable('now', $timezone); $start = $now->modify('first day of this month')->modify('-' . $months . ' months')->setTime(0, 0, 0); return $date >= $start && $date <= $now; } private function ignoredOlmNumbers(): array { $value = strtolower((string) ($this->settings['product_updates_olm_ignore_numbers'] ?? '')); return array_values(array_filter(array_map('trim', explode(',', $value)), static fn (string $item): bool => '' !== $item)); } private function moduleVersion(array $productVersion, array $downloadVersion): string { return implode('.', [ (string) ($productVersion['major'] ?? ''), (string) ($productVersion['minor'] ?? ''), (string) ($downloadVersion['bugfixVersion'] ?? ''), ]); } private function starfaceVersion(array $version): string { return implode('.', [ (string) ($version['major'] ?? ''), (string) ($version['minor'] ?? ''), (string) ($version['build'] ?? ''), (string) ($version['revision'] ?? ''), ]); } private function changelogLines(string $value): array { $value = str_replace(["\r\n", "\r"], "\n", $value); $lines = []; foreach (explode("\n", $value) as $line) { $line = $this->cleanText($line); if ('' !== $line) { $lines[] = $line; } } return $lines; } private function cleanText(string $value): string { $value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, get_bloginfo('charset')); $value = preg_replace('//i', ' ', $value) ?? $value; $value = wp_strip_all_tags($value); $value = preg_replace('/\s+/', ' ', $value) ?? $value; return trim($value); } private function validUrl(string $value): string { $value = trim($value); if ('' === $value || in_array($value, ['.', '-'], true)) { return ''; } return filter_var($value, FILTER_VALIDATE_URL) ? $value : ''; } private function dateTimestamp(string $date): int { if ('' === $date) { return 0; } $timestamp = strtotime($date); return $timestamp ?: 0; } private function formatDate(string $date): string { $timestamp = $this->dateTimestamp($date); return $timestamp > 0 ? wp_date((string) get_option('date_format'), $timestamp) : $date; } }