added support for legacy tickets

This commit is contained in:
Mark Gregory
2026-09-28 11:56:10 +01:00
parent c4a7061e87
commit 77d2a5e998
@@ -0,0 +1,569 @@
<?php
declare(strict_types=1);
namespace Modules\Documentation\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Modules\Documentation\Models\DocumentationCategory;
use Modules\Documentation\Models\DocumentationDocument;
use SplFileInfo;
use Symfony\Component\Finder\Finder;
use Throwable;
final class ImportDocumentationCommand extends Command
{
/**
* The console command signature.
*/
protected $signature = 'helpdesk:import-documentation
{path? : Directory containing Markdown files; defaults to the bundled Sentinel documentation}
{--category=Sentinel Documentation : Documentation category name}
{--fresh : Remove previously imported documents in this category before importing}';
/**
* The console command description.
*/
protected $description = 'Import Markdown documentation into the Helpdesk Documentation module.';
/**
* Execute the console command.
*/
public function handle(): int
{
$path = $this->resolvePath();
$categoryName = trim((string) $this->option('category'));
if ($categoryName === '') {
$this->components->error('The documentation category name cannot be empty.');
return self::FAILURE;
}
if (! is_dir($path)) {
$this->components->error("Documentation directory does not exist: {$path}");
return self::FAILURE;
}
if (! is_readable($path)) {
$this->components->error("Documentation directory is not readable: {$path}");
return self::FAILURE;
}
$files = $this->markdownFiles($path);
if ($files === []) {
$this->components->warn("No Markdown files were found in: {$path}");
return self::SUCCESS;
}
try {
$category = DB::transaction(function () use ($categoryName): DocumentationCategory {
$category = DocumentationCategory::query()->firstOrCreate(
['slug' => Str::slug($categoryName)],
[
'name' => $categoryName,
'description' => 'Imported documentation.',
'sort_order' => 0,
'is_active' => true,
],
);
if ($category->name !== $categoryName) {
$category->name = $categoryName;
$category->save();
}
if ($this->option('fresh')) {
/*
* Force-delete because DocumentationDocument uses SoftDeletes.
* Otherwise the unique slug constraint can prevent the same
* documentation set being imported again.
*/
$category->documents()
->withTrashed()
->get()
->each(static function (DocumentationDocument $document): void {
$document->forceDelete();
});
}
return $category;
});
$progress = $this->output->createProgressBar(count($files));
$progress->start();
$imported = 0;
foreach ($files as $file) {
$this->importFile($category, $file, $path);
++$imported;
$progress->advance();
}
$progress->finish();
$this->newLine(2);
$this->components->info(
sprintf(
'Imported %d documentation %s into "%s".',
$imported,
$imported === 1 ? 'page' : 'pages',
$category->name,
),
);
return self::SUCCESS;
} catch (Throwable $exception) {
$this->newLine();
$this->components->error(
'Documentation import failed: ' . $exception->getMessage(),
);
if ($this->output->isVerbose()) {
$this->newLine();
$this->line($exception->getTraceAsString());
}
return self::FAILURE;
}
}
/**
* Resolve the requested documentation source directory.
*/
private function resolvePath(): string
{
$argument = $this->argument('path');
if (is_string($argument) && trim($argument) !== '') {
$path = trim($argument);
if (! str_starts_with($path, DIRECTORY_SEPARATOR)) {
$path = base_path($path);
}
return rtrim($path, DIRECTORY_SEPARATOR);
}
return module_path('Documentation', 'ImportData/docs');
}
/**
* Find Markdown files recursively.
*
* @return list<SplFileInfo>
*/
private function markdownFiles(string $path): array
{
$finder = Finder::create()
->files()
->in($path)
->name('*.md')
->name('*.markdown')
->sortByName();
/** @var list<SplFileInfo> $files */
$files = iterator_to_array($finder, false);
return $files;
}
/**
* Import one Markdown document.
*/
private function importFile(
DocumentationCategory $category,
SplFileInfo $file,
string $rootPath,
): void {
$realPath = $file->getRealPath();
if ($realPath === false || ! is_readable($realPath)) {
throw new \RuntimeException(
sprintf('Unable to read documentation file: %s', $file->getPathname()),
);
}
$body = file_get_contents($realPath);
if ($body === false) {
throw new \RuntimeException(
sprintf('Unable to read documentation file: %s', $realPath),
);
}
$body = $this->stripBom($body);
$relativePath = ltrim(
str_replace(
DIRECTORY_SEPARATOR,
'/',
substr($realPath, strlen(rtrim($rootPath, DIRECTORY_SEPARATOR))),
),
'/',
);
$title = $this->extractTitle($body, $file);
$summary = $this->extractSummary($body);
$slug = $this->makeSlug($relativePath, $title);
DocumentationDocument::withTrashed()->updateOrCreate(
['slug' => $slug],
[
'documentation_category_id' => $category->id,
'title' => $title,
'summary' => $summary,
'body' => $body,
'status' => DocumentationDocument::STATUS_PUBLISHED,
'is_featured' => strtolower($file->getBasename('.' . $file->getExtension())) === 'index',
'version' => '1.0.0',
'reference' => Str::limit($relativePath, 100, ''),
'published_at' => now(),
'deleted_at' => null,
],
);
}
/**
* Extract the first Markdown H1 heading.
*/
private function extractTitle(string $body, SplFileInfo $file): string
{
if (preg_match('/^\s*#\s+(.+?)\s*$/m', $body, $matches) === 1) {
$title = trim($matches[1]);
$title = preg_replace('/\s+#+\s*$/', '', $title) ?? $title;
$title = trim($title);
if ($title !== '') {
return Str::limit($title, 220, '');
}
}
$filename = pathinfo($file->getFilename(), PATHINFO_FILENAME);
return Str::limit(
Str::headline(str_replace(['-', '_'], ' ', $filename)),
220,
'',
);
}
/**
* Extract a useful summary from the first normal Markdown paragraph.
*/
private function extractSummary(string $body): ?string
{
$lines = preg_split('/\R/', $body);
if (! is_array($lines)) {
return null;
}
$paragraph = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === '') {
if ($paragraph !== []) {
break;
}
continue;
}
if (
str_starts_with($line, '#')
|| str_starts_with($line, '```')
|| str_starts_with($line, '~~~')
|| str_starts_with($line, '>')
|| preg_match('/^[-*+]\s+/', $line) === 1
|| preg_match('/^\d+\.\s+/', $line) === 1
) {
if ($paragraph !== []) {
break;
}
continue;
}
$paragraph[] = $line;
}
if ($paragraph === []) {
return null;
}
$summary = implode(' ', $paragraph);
$summary = preg_replace('/!$begin:math:display$\(\[\^$end:math:display$]*)\]$begin:math:text$\[\^\)\]\+$end:math:text$/', '$1', $summary) ?? $summary;
$summary = preg_replace('/$begin:math:display$\(\[\^$end:math:display$]+)\]$begin:math:text$\[\^\)\]\+$end:math:text$/', '$1', $summary) ?? $summary;
$summary = str_replace(['**', '__', '`', '*', '_'], '', $summary);
$summary = trim(preg_replace('/\s+/', ' ', $summary) ?? $summary);
return $summary === ''
? null
: Str::limit($summary, 500, '');
}
/**
* Generate a stable globally unique slug from the source path.
*/
private function makeSlug(string $relativePath, string $title): string
{
$withoutExtension = preg_replace(
'/\.(md|markdown)$/i',
'',
$relativePath,
) ?? $relativePath;
$slug = Str::slug(str_replace('/', '-', $withoutExtension));
if ($slug === '') {
$slug = Str::slug($title);
}
if ($slug === '') {
$slug = 'document-' . substr(sha1($relativePath), 0, 12);
}
return Str::limit($slug, 240, '');
}
/**
* Remove a UTF-8 byte-order mark.
*/
private function stripBom(string $contents): string
{
if (str_starts_with($contents, "\xEF\xBB\xBF")) {
return substr($contents, 3);
}
return $contents;
}
}