initial COmmit: Add KB Antora Importer plugin files
This commit is contained in:
12
kb-antora-importer/includes/Import/Checksum.php
Normal file
12
kb-antora-importer/includes/Import/Checksum.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbAntoraImporter\Import;
|
||||
|
||||
final class Checksum
|
||||
{
|
||||
public static function content(string $content): string
|
||||
{
|
||||
return hash('sha256', $content);
|
||||
}
|
||||
}
|
||||
14
kb-antora-importer/includes/Import/ImportJob.php
Normal file
14
kb-antora-importer/includes/Import/ImportJob.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbAntoraImporter\Import;
|
||||
|
||||
final class ImportJob
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $projectId,
|
||||
public readonly string $branch,
|
||||
public readonly bool $dryRun = false
|
||||
) {
|
||||
}
|
||||
}
|
||||
61
kb-antora-importer/includes/Import/ImportLogger.php
Normal file
61
kb-antora-importer/includes/Import/ImportLogger.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbAntoraImporter\Import;
|
||||
|
||||
final class ImportLogger
|
||||
{
|
||||
private const OPTION = 'kb_antora_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_antora_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);
|
||||
}
|
||||
}
|
||||
382
kb-antora-importer/includes/Import/ImportManager.php
Normal file
382
kb-antora-importer/includes/Import/ImportManager.php
Normal file
@@ -0,0 +1,382 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KbAntoraImporter\Import;
|
||||
|
||||
use KbAntoraImporter\Antora\AntoraNavParser;
|
||||
use KbAntoraImporter\Antora\AntoraParser;
|
||||
use KbAntoraImporter\Antora\AntoraYamlReader;
|
||||
use KbAntoraImporter\AsciiDoc\AsciiDocRenderer;
|
||||
use KbAntoraImporter\GitLab\GitLabClient;
|
||||
use KbAntoraImporter\Plugin;
|
||||
use KbAntoraImporter\Repository\PageRepository;
|
||||
use KbAntoraImporter\Repository\ProductRepository;
|
||||
use KbAntoraImporter\Repository\VersionRepository;
|
||||
|
||||
final class ImportManager
|
||||
{
|
||||
private GitLabClient $client;
|
||||
private array $settings;
|
||||
private AntoraParser $antora;
|
||||
private AntoraYamlReader $yamlReader;
|
||||
private AntoraNavParser $navParser;
|
||||
private PageRepository $pages;
|
||||
private ProductRepository $products;
|
||||
private VersionRepository $versions;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->settings = Plugin::settings();
|
||||
$this->client = new GitLabClient($this->settings);
|
||||
$this->antora = new AntoraParser();
|
||||
$this->yamlReader = new AntoraYamlReader();
|
||||
$this->navParser = new AntoraNavParser();
|
||||
$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_antora_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_antora_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);
|
||||
|
||||
$antoraYaml = $this->client->getFileRaw($projectId, 'antora.yml', $branchName);
|
||||
if (is_wp_error($antoraYaml)) {
|
||||
ImportLogger::warning('antora.yml missing or unreadable for ' . $projectPath . '@' . $branchName . '. Branch skipped.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$component = $this->yamlReader->parse($antoraYaml);
|
||||
$productName = $component['title'] ?: $component['name'] ?: (string) ($project['name'] ?? $projectPath);
|
||||
$productSlug = sanitize_title($component['name'] ?: ($project['path'] ?? $productName));
|
||||
$version = $component['version'] ?: ltrim($branchName, 'v');
|
||||
$versionSlug = sanitize_title($version);
|
||||
$productTermId = $this->products->ensure($productName, $productSlug);
|
||||
$versionTermId = $this->versions->ensure($version);
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
$navTree = $this->loadNavigation($projectId, $branchName, $component);
|
||||
$imageMap = $this->importImages($projectId, $branchName, $tree, $dryRun);
|
||||
$pagePaths = array_values(array_filter(array_map(static fn (array $item): string => (string) ($item['path'] ?? ''), $tree), static fn (string $path): bool => (bool) preg_match('#^modules/[^/]+/pages/.+\.adoc$#', $path)));
|
||||
|
||||
if (! $this->navHasTargets($navTree)) {
|
||||
$navTree = $this->navTreeFromPages($pagePaths);
|
||||
ImportLogger::warning('Navigation had no linked pages; generated a fallback navigation from imported pages for ' . $projectPath . '@' . $branchName . '.');
|
||||
}
|
||||
|
||||
$navFlat = $this->navParser->flatten($navTree);
|
||||
$count = 0;
|
||||
foreach ($pagePaths as $sourcePath) {
|
||||
$content = $this->client->getFileRaw($projectId, $sourcePath, $branchName);
|
||||
|
||||
if (is_wp_error($content)) {
|
||||
ImportLogger::warning('Page unreadable: ' . $sourcePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
$module = $this->antora->moduleFromPath($sourcePath);
|
||||
$pagePath = preg_replace('#^modules/[^/]+/pages/#', '', $sourcePath) ?: basename($sourcePath);
|
||||
$pageSlug = $this->antora->pageSlugFromPath($sourcePath);
|
||||
$title = $this->extractTitle($content, $pagePath);
|
||||
$navOrder = $this->navOrder($navFlat, basename($sourcePath));
|
||||
$renderer = new AsciiDocRenderer();
|
||||
$html = $renderer->render($content, [
|
||||
'base_slug' => $this->settings['docs_base_slug'],
|
||||
'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' => $component['name'] ?: $productSlug,
|
||||
'component_title' => $productName,
|
||||
'version' => $version,
|
||||
'module' => $module,
|
||||
'page_path' => $pagePath,
|
||||
'source_path' => $sourcePath,
|
||||
'checksum' => Checksum::content($content),
|
||||
'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' => 'antora-shell-2',
|
||||
], $dryRun);
|
||||
|
||||
if ($saved || $dryRun) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function loadNavigation(string $projectId, string $branchName, array $component): array
|
||||
{
|
||||
$navFiles = $component['nav'] ?: ['modules/ROOT/nav.adoc'];
|
||||
$tree = [];
|
||||
|
||||
foreach ($navFiles as $navFile) {
|
||||
$content = $this->client->getFileRaw($projectId, $navFile, $branchName);
|
||||
if (is_wp_error($content)) {
|
||||
ImportLogger::warning('nav.adoc missing or unreadable: ' . $navFile);
|
||||
continue;
|
||||
}
|
||||
|
||||
$tree = array_merge($tree, $this->navParser->parse($content));
|
||||
}
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
private function importImages(string $projectId, string $branchName, array $tree, bool $dryRun): array
|
||||
{
|
||||
$images = [];
|
||||
$allowed = ['png', 'jpg', 'jpeg', 'gif', 'webp'];
|
||||
|
||||
if ('1' === $this->settings['allow_svg']) {
|
||||
$allowed[] = 'svg';
|
||||
}
|
||||
|
||||
foreach ($tree as $item) {
|
||||
$path = (string) ($item['path'] ?? '');
|
||||
if (! preg_match('#^modules/[^/]+/images/.+\.(' . implode('|', $allowed) . ')$#i', $path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$url = $dryRun ? '' : $this->importImage($projectId, $branchName, $path);
|
||||
if ($url) {
|
||||
$images[basename($path)] = $url;
|
||||
$images[preg_replace('#^modules/[^/]+/images/#', '', $path) ?: basename($path)] = $url;
|
||||
}
|
||||
}
|
||||
|
||||
return $images;
|
||||
}
|
||||
|
||||
private function navHasTargets(array $nodes): bool
|
||||
{
|
||||
foreach ($nodes as $node) {
|
||||
if (! empty($node['target'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->navHasTargets((array) ($node['children'] ?? []))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function navTreeFromPages(array $pagePaths): array
|
||||
{
|
||||
usort($pagePaths, static function (string $a, string $b): int {
|
||||
$aBase = basename($a);
|
||||
$bBase = basename($b);
|
||||
|
||||
if ('index.adoc' === $aBase || 'dokumentation.adoc' === $aBase) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if ('index.adoc' === $bBase || 'dokumentation.adoc' === $bBase) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return strnatcasecmp($aBase, $bBase);
|
||||
});
|
||||
|
||||
return array_map(static function (string $path): array {
|
||||
$target = preg_replace('#^modules/[^/]+/pages/#', '', $path) ?: basename($path);
|
||||
$title = preg_replace('/\.adoc$/', '', basename($path)) ?: basename($path);
|
||||
$title = ucwords(str_replace(['-', '_'], ' ', $title));
|
||||
|
||||
if (in_array(strtolower($title), ['index', 'dokumentation'], true)) {
|
||||
$title = __('Overview', 'kb-antora-importer');
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'target' => $target,
|
||||
'children' => [],
|
||||
];
|
||||
}, $pagePaths);
|
||||
}
|
||||
|
||||
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_antora_asset_key', $assetKey);
|
||||
update_post_meta((int) $attachmentId, '_kb_antora_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_antora_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($matches[1]);
|
||||
}
|
||||
|
||||
return ucwords(str_replace(['-', '_', '.adoc'], [' ', ' ', ''], basename($fallback)));
|
||||
}
|
||||
|
||||
private function navOrder(array $navFlat, string $basename): int
|
||||
{
|
||||
foreach ($navFlat as $index => $item) {
|
||||
if ($basename === basename((string) ($item['target'] ?? ''))) {
|
||||
return $index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 9999;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user