397 lines
13 KiB
PHP
Executable File
397 lines
13 KiB
PHP
Executable File
<?php
|
|
|
|
namespace Modules\Documentation\Http\Controllers;
|
|
|
|
use Illuminate\Contracts\View\View;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Routing\Controller;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Modules\Documentation\Http\Requests\StoreDocumentationDocumentRequest;
|
|
use Modules\Documentation\Http\Requests\UpdateDocumentationDocumentRequest;
|
|
use Modules\Documentation\Models\DocumentationAttachment;
|
|
use Modules\Documentation\Models\DocumentationCategory;
|
|
use Modules\Documentation\Models\DocumentationDocument;
|
|
|
|
class DocumentationController extends Controller
|
|
{
|
|
/**
|
|
* Display the documentation dashboard and document list.
|
|
*/
|
|
public function index(Request $request): View
|
|
{
|
|
$query = DocumentationDocument::query()->with(['category', 'attachments'])->latest();
|
|
|
|
if ($request->filled('status')) {
|
|
$query->where('status', $request->string('status')->toString());
|
|
}
|
|
|
|
if ($request->filled('category')) {
|
|
$query->where('documentation_category_id', $request->integer('category'));
|
|
}
|
|
|
|
if ($request->filled('search')) {
|
|
$search = '%'.$request->string('search')->trim()->toString().'%';
|
|
$query->where(function ($builder) use ($search): void {
|
|
$builder->where('title', 'like', $search)
|
|
->orWhere('summary', 'like', $search)
|
|
->orWhere('reference', 'like', $search)
|
|
->orWhere('body', 'like', $search);
|
|
});
|
|
}
|
|
|
|
return view('documentation::documents.index', [
|
|
'documents' => $query->paginate(15)->withQueryString(),
|
|
'categories' => DocumentationCategory::query()->ordered()->get(),
|
|
'featuredDocuments' => DocumentationDocument::query()->with('category')->featured()->latest()->limit(4)->get(),
|
|
'stats' => [
|
|
'total' => DocumentationDocument::query()->count(),
|
|
'published' => DocumentationDocument::query()->published()->count(),
|
|
'drafts' => DocumentationDocument::query()->where('status', DocumentationDocument::STATUS_DRAFT)->count(),
|
|
'categories' => DocumentationCategory::query()->count(),
|
|
'archived' => DocumentationDocument::onlyTrashed()->count(),
|
|
],
|
|
'statuses' => config('documentation.statuses', []),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Display soft-deleted documents.
|
|
*/
|
|
public function archived(Request $request): View
|
|
{
|
|
$query = DocumentationDocument::onlyTrashed()
|
|
->with(['category', 'attachments'])
|
|
->latest('deleted_at');
|
|
|
|
if ($request->filled('category')) {
|
|
$query->where('documentation_category_id', $request->integer('category'));
|
|
}
|
|
|
|
if ($request->filled('search')) {
|
|
$search = '%'.$request->string('search')->trim()->toString().'%';
|
|
|
|
$query->where(function ($builder) use ($search): void {
|
|
$builder->where('title', 'like', $search)
|
|
->orWhere('summary', 'like', $search)
|
|
->orWhere('reference', 'like', $search)
|
|
->orWhere('body', 'like', $search);
|
|
});
|
|
}
|
|
|
|
return view('documentation::documents.archived', [
|
|
'documents' => $query->paginate(15)->withQueryString(),
|
|
'categories' => DocumentationCategory::query()->ordered()->get(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Show the create document form.
|
|
*/
|
|
public function create(): View
|
|
{
|
|
return view('documentation::documents.create', [
|
|
'document' => new DocumentationDocument(['status' => DocumentationDocument::STATUS_DRAFT, 'version' => '1.0.0']),
|
|
'categories' => DocumentationCategory::query()->active()->ordered()->get(),
|
|
'statuses' => config('documentation.statuses', []),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Store a newly created document.
|
|
*/
|
|
public function store(StoreDocumentationDocumentRequest $request): RedirectResponse
|
|
{
|
|
$data = $request->validated();
|
|
$data['is_featured'] = $request->boolean('is_featured');
|
|
$data['created_by'] = $request->user()?->id;
|
|
$data['updated_by'] = $request->user()?->id;
|
|
|
|
if ($request->hasFile('attachment')) {
|
|
if (blank($data['body'] ?? null)) {
|
|
$data['body'] = $this->extractMarkdownBody($request) ?: ($data['body'] ?? null);
|
|
}
|
|
|
|
$data = array_merge($data, $this->storeAttachment($request));
|
|
}
|
|
|
|
unset($data['attachment'], $data['attachments']);
|
|
|
|
$document = DocumentationDocument::query()->create($data);
|
|
$this->storeAttachments($request, $document);
|
|
|
|
return redirect()->route('documentation.documents.show', $document)->with('status', 'Document created successfully.');
|
|
}
|
|
|
|
/**
|
|
* Display a document.
|
|
*/
|
|
public function show(DocumentationDocument $document): View
|
|
{
|
|
return view('documentation::documents.show', [
|
|
'document' => $document->load(['category', 'attachments']),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Show the edit document form.
|
|
*/
|
|
public function edit(DocumentationDocument $document): View
|
|
{
|
|
return view('documentation::documents.edit', [
|
|
'document' => $document->load('attachments'),
|
|
'categories' => DocumentationCategory::query()->active()->ordered()->get(),
|
|
'statuses' => config('documentation.statuses', []),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Update an existing document.
|
|
*/
|
|
public function update(UpdateDocumentationDocumentRequest $request, DocumentationDocument $document): RedirectResponse
|
|
{
|
|
$data = $request->validated();
|
|
$data['is_featured'] = $request->boolean('is_featured');
|
|
$data['updated_by'] = $request->user()?->id;
|
|
|
|
if ($request->boolean('remove_attachment')) {
|
|
$this->deleteAttachment($document);
|
|
$data = array_merge($data, $this->emptyAttachmentData());
|
|
}
|
|
|
|
if ($request->hasFile('attachment')) {
|
|
$this->deleteAttachment($document);
|
|
|
|
if (blank($data['body'] ?? null)) {
|
|
$data['body'] = $this->extractMarkdownBody($request) ?: ($data['body'] ?? null);
|
|
}
|
|
|
|
$data = array_merge($data, $this->storeAttachment($request));
|
|
}
|
|
|
|
unset($data['attachment'], $data['attachments'], $data['remove_attachment']);
|
|
$document->update($data);
|
|
$this->storeAttachments($request, $document);
|
|
|
|
return redirect()->route('documentation.documents.show', $document)->with('status', 'Document updated successfully.');
|
|
}
|
|
|
|
/**
|
|
* Archive a document using Laravel soft deletes.
|
|
*
|
|
* Stored files and attachment records are deliberately retained. They are
|
|
* only removed when an archived document is permanently deleted.
|
|
*/
|
|
public function destroy(Request $request, DocumentationDocument $document): RedirectResponse
|
|
{
|
|
$document->forceFill([
|
|
'status' => DocumentationDocument::STATUS_ARCHIVED,
|
|
'updated_by' => $request->user()?->id,
|
|
])->save();
|
|
|
|
$document->delete();
|
|
|
|
return redirect()
|
|
->route('documentation.documents.index')
|
|
->with('status', 'Document archived successfully.');
|
|
}
|
|
|
|
/**
|
|
* Restore a previously archived document.
|
|
*/
|
|
public function restore(Request $request, int $document): RedirectResponse
|
|
{
|
|
$document = DocumentationDocument::onlyTrashed()->findOrFail($document);
|
|
|
|
$document->restore();
|
|
|
|
// Restored documents return to Draft so they cannot unexpectedly
|
|
// become publicly/operationally published again.
|
|
$document->forceFill([
|
|
'status' => DocumentationDocument::STATUS_DRAFT,
|
|
'updated_by' => $request->user()?->id,
|
|
])->save();
|
|
|
|
return redirect()
|
|
->route('documentation.documents.archived')
|
|
->with('status', 'Document restored as a draft.');
|
|
}
|
|
|
|
/**
|
|
* Permanently delete an archived document and all stored files.
|
|
*/
|
|
public function forceDestroy(int $document): RedirectResponse
|
|
{
|
|
$document = DocumentationDocument::onlyTrashed()
|
|
->with('attachments')
|
|
->findOrFail($document);
|
|
|
|
$this->deleteAttachment($document);
|
|
|
|
foreach ($document->attachments as $attachment) {
|
|
Storage::disk($attachment->disk)->delete($attachment->path);
|
|
$attachment->delete();
|
|
}
|
|
|
|
$document->forceDelete();
|
|
|
|
return redirect()
|
|
->route('documentation.documents.archived')
|
|
->with('status', 'Document permanently deleted.');
|
|
}
|
|
|
|
/**
|
|
* Download the attached document file.
|
|
*/
|
|
public function download(DocumentationDocument $document)
|
|
{
|
|
abort_if(blank($document->attachment_path), 404);
|
|
|
|
return Storage::disk($document->attachment_disk ?: config('documentation.storage_disk', 'public'))
|
|
->download($document->attachment_path, $document->attachment_name);
|
|
}
|
|
|
|
/**
|
|
* Download one of the document's additional attachments.
|
|
*/
|
|
public function downloadAttachment(DocumentationDocument $document, DocumentationAttachment $attachment)
|
|
{
|
|
abort_if($attachment->documentation_document_id !== $document->id, 404);
|
|
|
|
return Storage::disk($attachment->disk)->download($attachment->path, $attachment->original_name);
|
|
}
|
|
|
|
/**
|
|
* Delete one of the document's additional attachments.
|
|
*/
|
|
public function destroyAttachment(DocumentationDocument $document, DocumentationAttachment $attachment): RedirectResponse
|
|
{
|
|
abort_if($attachment->documentation_document_id !== $document->id, 404);
|
|
|
|
Storage::disk($attachment->disk)->delete($attachment->path);
|
|
$attachment->delete();
|
|
|
|
return redirect()->route('documentation.documents.edit', $document)->with('status', 'Attachment removed successfully.');
|
|
}
|
|
|
|
/**
|
|
* Extract body content from an uploaded Markdown file when requested.
|
|
*/
|
|
private function extractMarkdownBody(Request $request): ?string
|
|
{
|
|
if (! config('documentation.import_markdown_attachments', true)) {
|
|
return null;
|
|
}
|
|
|
|
$file = $request->file('attachment');
|
|
|
|
if ($file === null || ! $this->isMarkdownUpload($request)) {
|
|
return null;
|
|
}
|
|
|
|
if ($file->getSize() > (int) config('documentation.max_markdown_import_bytes', 1048576)) {
|
|
return null;
|
|
}
|
|
|
|
$content = file_get_contents($file->getRealPath());
|
|
|
|
if ($content === false) {
|
|
return null;
|
|
}
|
|
|
|
return trim($content);
|
|
}
|
|
|
|
/**
|
|
* Determine whether the uploaded attachment is a Markdown file.
|
|
*/
|
|
private function isMarkdownUpload(Request $request): bool
|
|
{
|
|
$file = $request->file('attachment');
|
|
|
|
if ($file === null) {
|
|
return false;
|
|
}
|
|
|
|
$extension = strtolower($file->getClientOriginalExtension());
|
|
$mime = strtolower((string) $file->getMimeType());
|
|
|
|
return $extension === 'md' || in_array($mime, ['text/markdown', 'text/x-markdown'], true);
|
|
}
|
|
|
|
/**
|
|
* Store an uploaded attachment and return persisted metadata.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function storeAttachment(Request $request): array
|
|
{
|
|
$file = $request->file('attachment');
|
|
$disk = config('documentation.storage_disk', 'public');
|
|
$path = $file->store(config('documentation.upload_path', 'documentation'), $disk);
|
|
|
|
return [
|
|
'attachment_disk' => $disk,
|
|
'attachment_path' => $path,
|
|
'attachment_name' => $file->getClientOriginalName(),
|
|
'attachment_mime' => $file->getMimeType(),
|
|
'attachment_size' => $file->getSize(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Store all additional uploaded attachments for a document.
|
|
*/
|
|
private function storeAttachments(Request $request, DocumentationDocument $document): void
|
|
{
|
|
if (! $request->hasFile('attachments')) {
|
|
return;
|
|
}
|
|
|
|
$disk = config('documentation.storage_disk', 'public');
|
|
|
|
foreach ((array) $request->file('attachments') as $file) {
|
|
if ($file === null || ! $file->isValid()) {
|
|
continue;
|
|
}
|
|
|
|
$path = $file->store(config('documentation.upload_path', 'documentation'), $disk);
|
|
|
|
$document->attachments()->create([
|
|
'disk' => $disk,
|
|
'path' => $path,
|
|
'original_name' => $file->getClientOriginalName(),
|
|
'mime_type' => $file->getMimeType(),
|
|
'size' => $file->getSize(),
|
|
'uploaded_by' => $request->user()?->id,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a stored attachment if it exists.
|
|
*/
|
|
private function deleteAttachment(DocumentationDocument $document): void
|
|
{
|
|
if (filled($document->attachment_path)) {
|
|
Storage::disk($document->attachment_disk ?: config('documentation.storage_disk', 'public'))->delete($document->attachment_path);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Empty attachment metadata.
|
|
*
|
|
* @return array<string, null>
|
|
*/
|
|
private function emptyAttachmentData(): array
|
|
{
|
|
return [
|
|
'attachment_disk' => null,
|
|
'attachment_path' => null,
|
|
'attachment_name' => null,
|
|
'attachment_mime' => null,
|
|
'attachment_size' => null,
|
|
];
|
|
}
|
|
}
|