added support for legacy tickets
This commit is contained in:
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user