settings = Plugin::settings(); $this->client = new GitLabClient($this->settings); $this->pages = new PageRepository(); $this->products = new ProductRepository(); $this->versions = new VersionRepository(); } public function syncAll(bool $dryRun = false): \WP_REST_Response { ImportLogger::info($dryRun ? 'Dry run started.' : 'Synchronization started.'); $group = $this->client->getGroup((string) $this->settings['gitlab_group']); if (is_wp_error($group)) { ImportLogger::error('Group lookup failed: ' . $group->get_error_message()); return new \WP_REST_Response(['success' => false, 'message' => $group->get_error_message()], 500); } $projects = $this->client->getProjects((string) ($group['id'] ?? $this->settings['gitlab_group'])); if (is_wp_error($projects)) { ImportLogger::error('Project lookup failed: ' . $projects->get_error_message()); return new \WP_REST_Response(['success' => false, 'message' => $projects->get_error_message()], 500); } $stats = ['projects' => 0, 'branches' => 0, 'pages' => 0]; foreach ($projects as $project) { $stats['projects']++; $result = $this->syncProjectData($project, $dryRun); $stats['branches'] += $result['branches']; $stats['pages'] += $result['pages']; } update_option('kb_markdown_importer_last_sync', current_time('mysql'), false); ImportLogger::info('Synchronization completed.'); return new \WP_REST_Response(['success' => true, 'stats' => $stats]); } public function syncProject(string $projectId, bool $dryRun = false): \WP_REST_Response { if (! $projectId) { return new \WP_REST_Response(['success' => false, 'message' => 'Project ID missing.'], 400); } $project = $this->client->getProject($projectId); if (is_wp_error($project)) { ImportLogger::error('Project lookup failed: ' . $project->get_error_message()); return new \WP_REST_Response(['success' => false, 'message' => $project->get_error_message()], 500); } $result = $this->syncProjectData($project, $dryRun); update_option('kb_markdown_importer_last_sync', current_time('mysql'), false); return new \WP_REST_Response(['success' => true, 'stats' => $result]); } private function syncProjectData(array $project, bool $dryRun): array { $projectId = (string) ($project['id'] ?? ''); $projectPath = (string) ($project['path_with_namespace'] ?? $project['path'] ?? $projectId); if (! $projectId) { return ['branches' => 0, 'pages' => 0]; } ImportLogger::info('Project found: ' . $projectPath); $branches = $this->client->getDocumentationBranches($projectId); if (is_wp_error($branches)) { ImportLogger::error('Branch lookup failed for ' . $projectPath . ': ' . $branches->get_error_message()); return ['branches' => 0, 'pages' => 0]; } $stats = ['branches' => 0, 'pages' => 0]; foreach ($branches as $branch) { $stats['branches']++; $stats['pages'] += $this->syncBranch($project, $branch, $dryRun); } return $stats; } private function syncBranch(array $project, array $branch, bool $dryRun): int { $projectId = (string) ($project['id'] ?? ''); $projectPath = (string) ($project['path_with_namespace'] ?? $project['path'] ?? $projectId); $branchName = (string) ($branch['name'] ?? ''); $commitSha = (string) ($branch['commit']['id'] ?? ''); if (! $branchName) { return 0; } ImportLogger::info('Branch found: ' . $projectPath . '@' . $branchName); $tree = $this->client->getTree($projectId, $branchName); if (is_wp_error($tree)) { ImportLogger::error('Repository tree failed for ' . $projectPath . '@' . $branchName . ': ' . $tree->get_error_message()); return 0; } $paths = array_values(array_map(static fn (array $item): string => (string) ($item['path'] ?? ''), $tree)); if (! in_array('doku.md', $paths, true)) { ImportLogger::warning('doku.md missing for ' . $projectPath . '@' . $branchName . '. Branch skipped.'); return 0; } $metadata = $this->loadMetadata($projectId, $branchName, $paths); $productName = (string) ($metadata['title'] ?? $project['name'] ?? $projectPath); $productSlug = sanitize_title((string) ($metadata['name'] ?? $project['path'] ?? $productName)); $version = (string) ($metadata['version'] ?? ltrim($branchName, 'v')); $versionSlug = sanitize_title($version); $productTermId = $this->products->ensure($productName, $productSlug); $versionTermId = $this->versions->ensure($version); $pagePaths = $this->documentationPages($paths, (array) ($metadata['nav'] ?? [])); if (! in_array('stepbystep.md', $pagePaths, true)) { ImportLogger::warning('stepbystep.md missing for ' . $projectPath . '@' . $branchName . '. It should be present in every new documentation project.'); } if (! $this->hasImagesDirectory($paths)) { ImportLogger::warning('images/ folder missing for ' . $projectPath . '@' . $branchName . '. It should be present in every new documentation project.'); } $navTree = $this->navigationTree($pagePaths, (array) ($metadata['nav'] ?? [])); $imageMap = $this->importImages($projectId, $branchName, $paths, $dryRun); $renderer = new MarkdownRenderer(); $count = 0; foreach ($pagePaths as $sourcePath) { $content = $this->client->getFileRaw($projectId, $sourcePath, $branchName); if (is_wp_error($content)) { ImportLogger::warning('Page unreadable: ' . $sourcePath); continue; } $pageSlug = $this->pageSlugFromPath($sourcePath); $title = $this->extractTitle($content, $sourcePath); $navOrder = $this->navOrder($pagePaths, $sourcePath); $html = $renderer->render($content, [ 'product_slug' => $productSlug, 'version_slug' => $versionSlug, 'images' => $imageMap, 'lightbox' => '1' === $this->settings['image_lightbox'], ]); $saved = $this->pages->save([ 'project_id' => $projectId, 'project_path' => $projectPath, 'branch' => $branchName, 'commit_sha' => $commitSha, 'component' => $productSlug, 'component_title' => $productName, 'version' => $version, 'module' => '', 'page_path' => $sourcePath, 'source_path' => $sourcePath, 'checksum' => Checksum::content($content . $commitSha . $this->rendererVersion()), 'title' => $title, 'html' => $html, 'nav_order' => $navOrder, 'parent_page_path' => '', 'product_slug' => $productSlug, 'version_term_id' => $versionTermId, 'product_term_id' => $productTermId, 'version_slug' => $versionSlug, 'page_slug' => $pageSlug, 'nav_tree' => $navTree, 'renderer_version' => $this->rendererVersion(), ], $dryRun); if ($saved || $dryRun) { $count++; } } return $count; } private function loadMetadata(string $projectId, string $branchName, array $paths): array { if (! in_array('doku.yml', $paths, true) && ! in_array('doku.yaml', $paths, true)) { return []; } $metadataPath = in_array('doku.yml', $paths, true) ? 'doku.yml' : 'doku.yaml'; $content = $this->client->getFileRaw($projectId, $metadataPath, $branchName); if (is_wp_error($content)) { ImportLogger::warning($metadataPath . ' unreadable: ' . $content->get_error_message()); return []; } return $this->parseMetadata($content); } private function parseMetadata(string $content): array { $data = []; $currentNav = null; foreach (preg_split('/\R/', $content) ?: [] as $line) { if (preg_match('/^([a-zA-Z0-9_-]+):\s*"?([^"]*)"?\s*$/', $line, $matches)) { $key = strtolower($matches[1]); if ('nav' === $key) { $data['nav'] = []; $currentNav = null; continue; } $data[$key] = trim($matches[2]); continue; } if (preg_match('/^\s*-\s+title:\s*"?([^"]+)"?\s*$/', $line, $matches)) { $data['nav'] ??= []; $data['nav'][] = ['title' => trim($matches[1]), 'file' => '']; $currentNav = array_key_last($data['nav']); continue; } if (null !== $currentNav && preg_match('/^\s*file:\s*"?([^"]+)"?\s*$/', $line, $matches)) { $data['nav'][$currentNav]['file'] = trim($matches[1]); } } return $data; } private function documentationPages(array $paths, array $nav): array { $pages = array_values(array_filter($paths, static function (string $path): bool { return (bool) preg_match('/^[^\/]+\.md$/i', $path) && ! in_array(strtolower($path), ['readme.md'], true); })); $ordered = []; foreach ($nav as $item) { $file = (string) ($item['file'] ?? ''); if ($file && in_array($file, $pages, true)) { $ordered[] = $file; } } foreach (['doku.md', 'stepbystep.md'] as $required) { if (in_array($required, $pages, true) && ! in_array($required, $ordered, true)) { $ordered[] = $required; } } sort($pages, SORT_NATURAL | SORT_FLAG_CASE); foreach ($pages as $page) { if (! in_array($page, $ordered, true)) { $ordered[] = $page; } } return $ordered; } private function navigationTree(array $pagePaths, array $nav): array { $tree = []; foreach ($nav as $item) { $file = (string) ($item['file'] ?? ''); if (! $file || ! in_array($file, $pagePaths, true)) { continue; } $tree[] = [ 'title' => (string) ($item['title'] ?? $this->titleFromFilename($file)), 'target' => $file, 'children' => [], ]; } foreach ($pagePaths as $path) { if ($this->navContains($tree, $path)) { continue; } $tree[] = [ 'title' => $this->titleFromFilename($path), 'target' => $path, 'children' => [], ]; } return $tree; } private function importImages(string $projectId, string $branchName, array $paths, bool $dryRun): array { $images = []; $allowed = ['png', 'jpg', 'jpeg', 'gif', 'webp']; if ('1' === $this->settings['allow_svg']) { $allowed[] = 'svg'; } foreach ($paths as $path) { if (! preg_match('#^images/.+\.(' . implode('|', $allowed) . ')$#i', $path)) { continue; } $url = $dryRun ? '' : $this->importImage($projectId, $branchName, $path); if ($url) { $images[$path] = $url; $images[ltrim($path, '/')] = $url; $images[basename($path)] = $url; $images[preg_replace('#^images/#', '', $path) ?: basename($path)] = $url; } } return $images; } private function hasImagesDirectory(array $paths): bool { foreach ($paths as $path) { if (str_starts_with($path, 'images/')) { return true; } } return false; } private function importImage(string $projectId, string $branchName, string $path): string { $assetKey = $projectId . ':' . $branchName . ':' . $path; $existing = $this->findAttachmentByAssetKey($assetKey); if ($existing) { return wp_get_attachment_url($existing) ?: ''; } $content = $this->client->getFileRaw($projectId, $path, $branchName); if (is_wp_error($content)) { ImportLogger::warning('Asset unreadable: ' . $path); return ''; } $upload = wp_upload_bits(basename($path), null, $content); if (! empty($upload['error'])) { ImportLogger::warning('Asset upload failed: ' . $path); return ''; } $filetype = wp_check_filetype($upload['file']); $attachmentId = wp_insert_attachment([ 'post_mime_type' => $filetype['type'] ?: 'application/octet-stream', 'post_title' => sanitize_file_name(basename($path)), 'post_status' => 'inherit', ], $upload['file']); if (is_wp_error($attachmentId)) { ImportLogger::warning('Attachment creation failed: ' . $path); return ''; } require_once ABSPATH . 'wp-admin/includes/image.php'; $metadata = wp_generate_attachment_metadata((int) $attachmentId, $upload['file']); wp_update_attachment_metadata((int) $attachmentId, $metadata); update_post_meta((int) $attachmentId, '_kb_markdown_asset_key', $assetKey); update_post_meta((int) $attachmentId, '_kb_markdown_asset_checksum', Checksum::content($content)); ImportLogger::info('Asset imported: ' . $path); return wp_get_attachment_url((int) $attachmentId) ?: ''; } private function findAttachmentByAssetKey(string $assetKey): int { $query = new \WP_Query([ 'post_type' => 'attachment', 'post_status' => 'inherit', 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'meta_key' => '_kb_markdown_asset_key', 'meta_value' => $assetKey, ]); return (int) ($query->posts[0] ?? 0); } private function extractTitle(string $content, string $fallback): string { if (preg_match('/^#\s+(.+)$/m', $content, $matches)) { return trim(wp_strip_all_tags($matches[1])); } return $this->titleFromFilename($fallback); } private function titleFromFilename(string $path): string { $title = preg_replace('/\.md$/i', '', basename($path)) ?: basename($path); if ('doku' === strtolower($title)) { return __('Overview', 'kb-markdown-importer'); } return ucwords(str_replace(['-', '_'], ' ', $title)); } private function pageSlugFromPath(string $path): string { $page = preg_replace('/\.md$/i', '', basename($path)) ?: basename($path); return in_array(strtolower($page), ['doku', 'index'], true) ? '' : sanitize_title($page); } private function navOrder(array $pagePaths, string $sourcePath): int { $index = array_search($sourcePath, $pagePaths, true); return false === $index ? 9999 : ((int) $index + 1); } private function navContains(array $tree, string $target): bool { foreach ($tree as $node) { if ($target === (string) ($node['target'] ?? '')) { return true; } } return false; } private function rendererVersion(): string { return 'markdown-1'; } }