This commit is contained in:
Sven Steinert
2026-05-13 11:57:52 +02:00
parent 6abf6f9c3d
commit f4511b9213
76 changed files with 4494 additions and 1940 deletions

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace KbMarkdownImporter\Import;
final class Checksum
{
public static function content(string $content): string
{
return hash('sha256', $content);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace KbMarkdownImporter\Import;
final class ImportJob
{
public function __construct(
public readonly string $projectId,
public readonly string $branch,
public readonly bool $dryRun = false
) {
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace KbMarkdownImporter\Import;
final class ImportLogger
{
private const OPTION = 'kb_markdown_importer_logs';
private const LIMIT = 300;
public static function info(string $message): void
{
self::log('INFO', $message);
}
public static function warning(string $message): void
{
self::log('WARNING', $message);
}
public static function error(string $message): void
{
update_option('kb_markdown_importer_last_error', self::sanitize($message), false);
self::log('ERROR', $message);
}
public static function debug(string $message): void
{
self::log('DEBUG', $message);
}
public static function recent(int $limit = 50): array
{
$logs = array_reverse((array) get_option(self::OPTION, []));
return array_slice($logs, 0, $limit);
}
private static function log(string $level, string $message): void
{
$logs = (array) get_option(self::OPTION, []);
$logs[] = [
'time' => current_time('mysql'),
'level' => $level,
'message' => self::sanitize($message),
];
if (count($logs) > self::LIMIT) {
$logs = array_slice($logs, -self::LIMIT);
}
update_option(self::OPTION, $logs, false);
}
private static function sanitize(string $message): string
{
$message = preg_replace('/([?&](?:private_)?token=)[^&\s]+/i', '$1[redacted]', $message) ?? $message;
$message = preg_replace('/(PRIVATE-TOKEN|Authorization):\s*\S+/i', '$1: [redacted]', $message) ?? $message;
return sanitize_text_field($message);
}
}

View File

@@ -0,0 +1,456 @@
<?php
declare(strict_types=1);
namespace KbMarkdownImporter\Import;
use KbMarkdownImporter\GitLab\GitLabClient;
use KbMarkdownImporter\Markdown\MarkdownRenderer;
use KbMarkdownImporter\Plugin;
use KbMarkdownImporter\Repository\PageRepository;
use KbMarkdownImporter\Repository\ProductRepository;
use KbMarkdownImporter\Repository\VersionRepository;
final class ImportManager
{
private GitLabClient $client;
private array $settings;
private PageRepository $pages;
private ProductRepository $products;
private VersionRepository $versions;
public function __construct()
{
$this->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';
}
}