Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render
This commit is contained in:
290
app/Console/Commands/Enhance/CleanupEnhanceJobsCommand.php
Normal file
290
app/Console/Commands/Enhance/CleanupEnhanceJobsCommand.php
Normal file
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Enhance;
|
||||
|
||||
use App\Models\EnhanceJob;
|
||||
use App\Services\Enhance\EnhanceStorageService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
|
||||
final class CleanupEnhanceJobsCommand extends Command
|
||||
{
|
||||
protected $signature = 'enhance:cleanup
|
||||
{--dry-run : Preview cleanup actions only}
|
||||
{--force : Delete files and update records}
|
||||
{--only= : Restrict cleanup to expired, deleted, failed, or orphaned}
|
||||
{--days= : Override retention days for failed or deleted cleanup}';
|
||||
|
||||
protected $description = 'Safely clean expired, deleted, failed, and orphaned Enhance files.';
|
||||
|
||||
public function __construct(
|
||||
private readonly EnhanceStorageService $storage,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$target = strtolower(trim((string) $this->option('only')));
|
||||
$validTargets = ['', 'expired', 'deleted', 'failed', 'orphaned'];
|
||||
|
||||
if (! in_array($target, $validTargets, true)) {
|
||||
$this->error('The --only option must be one of: expired, deleted, failed, orphaned.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ((bool) $this->option('dry-run') && (bool) $this->option('force')) {
|
||||
$this->error('Use either --dry-run or --force, not both.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$dryRun = (bool) $this->option('dry-run') || ! (bool) $this->option('force');
|
||||
$daysOverride = $this->option('days');
|
||||
$selectedTarget = $target !== '' ? $target : 'all';
|
||||
|
||||
Log::info('enhance.cleanup.started', [
|
||||
'dry_run' => $dryRun,
|
||||
'target' => $selectedTarget,
|
||||
'days_override' => $daysOverride,
|
||||
]);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('Running in dry-run mode. No files will be deleted.');
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
|
||||
if ($target === '' || $target === 'expired') {
|
||||
$expired = $this->cleanupExpiredCompletedJobs($dryRun);
|
||||
$rows[] = ['expired', $expired['jobs'], $expired['files'], $dryRun ? 'dry-run' : 'cleaned'];
|
||||
}
|
||||
|
||||
if ($target === '' || $target === 'failed') {
|
||||
$failed = $this->cleanupFailedJobs($dryRun, $daysOverride);
|
||||
$rows[] = ['failed', $failed['jobs'], $failed['files'], $dryRun ? 'dry-run' : 'cleaned'];
|
||||
}
|
||||
|
||||
if ($target === '' || $target === 'deleted') {
|
||||
$deleted = $this->cleanupSoftDeletedJobs($dryRun, $daysOverride);
|
||||
$rows[] = ['deleted', $deleted['jobs'], $deleted['files'], $dryRun ? 'dry-run' : 'cleaned'];
|
||||
}
|
||||
|
||||
if ($target === 'orphaned') {
|
||||
$orphaned = $this->scanOrphanedFiles($dryRun);
|
||||
$rows[] = ['orphaned', $orphaned['files'], $orphaned['deleted'], $dryRun ? 'dry-run' : 'deleted'];
|
||||
|
||||
if ($orphaned['unsupported']) {
|
||||
$this->warn('Orphaned file scan was skipped because the configured disk does not support safe listing.');
|
||||
}
|
||||
|
||||
foreach ($orphaned['sample'] as $path) {
|
||||
$this->line(' - ' . $path);
|
||||
}
|
||||
}
|
||||
|
||||
if ($rows !== []) {
|
||||
$this->table(['Target', 'Jobs/Files', 'Files deleted', 'Mode'], $rows);
|
||||
}
|
||||
|
||||
Log::info('enhance.cleanup.completed', [
|
||||
'dry_run' => $dryRun,
|
||||
'target' => $selectedTarget,
|
||||
'rows' => $rows,
|
||||
]);
|
||||
|
||||
$this->info('Enhance cleanup finished.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function cleanupExpiredCompletedJobs(bool $dryRun): array
|
||||
{
|
||||
$query = EnhanceJob::query()
|
||||
->where('status', EnhanceJob::STATUS_COMPLETED)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now());
|
||||
|
||||
return $this->cleanupJobs($query, $dryRun, 'expired', fn (): array => [
|
||||
'status' => EnhanceJob::STATUS_EXPIRED,
|
||||
]);
|
||||
}
|
||||
|
||||
private function cleanupFailedJobs(bool $dryRun, mixed $daysOverride): array
|
||||
{
|
||||
$days = $this->resolveDays($daysOverride, (int) config('enhance.lifecycle.failed_expires_after_days', 7));
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$query = EnhanceJob::query()
|
||||
->where('status', EnhanceJob::STATUS_FAILED)
|
||||
->where(function (Builder $builder) use ($cutoff): void {
|
||||
$builder
|
||||
->where('finished_at', '<=', $cutoff)
|
||||
->orWhere(function (Builder $fallback) use ($cutoff): void {
|
||||
$fallback->whereNull('finished_at')->where('created_at', '<=', $cutoff);
|
||||
});
|
||||
});
|
||||
|
||||
return $this->cleanupJobs($query, $dryRun, 'failed-expired');
|
||||
}
|
||||
|
||||
private function cleanupSoftDeletedJobs(bool $dryRun, mixed $daysOverride): array
|
||||
{
|
||||
$days = $this->resolveDays($daysOverride, (int) config('enhance.lifecycle.deleted_file_grace_days', 1));
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$query = EnhanceJob::withTrashed()
|
||||
->whereNotNull('deleted_at')
|
||||
->where('deleted_at', '<=', $cutoff);
|
||||
|
||||
return $this->cleanupJobs($query, $dryRun, 'deleted-grace');
|
||||
}
|
||||
|
||||
private function cleanupJobs(Builder $query, bool $dryRun, string $reason, ?callable $attributes = null): array
|
||||
{
|
||||
$result = ['jobs' => 0, 'files' => 0];
|
||||
$chunkSize = max(1, (int) config('enhance.lifecycle.cleanup_chunk_size', 100));
|
||||
|
||||
$query->chunkById($chunkSize, function ($jobs) use (&$result, $dryRun, $reason, $attributes): void {
|
||||
foreach ($jobs as $job) {
|
||||
$result['jobs']++;
|
||||
$result['files'] += count($this->enhanceJobPaths($job));
|
||||
|
||||
if ($dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$deleteResult = $this->storage->deleteFilesForJob($job);
|
||||
$metadata = is_array($job->metadata) ? $job->metadata : [];
|
||||
|
||||
$job->forceFill(array_merge(
|
||||
$this->cleanupAttributesForJob($job),
|
||||
$attributes ? $attributes($job) : [],
|
||||
[
|
||||
'metadata' => array_merge($metadata, [
|
||||
'cleanup' => [
|
||||
'files_removed_at' => now()->toIso8601String(),
|
||||
'reason' => $reason,
|
||||
'deleted' => $deleteResult['deleted'],
|
||||
],
|
||||
]),
|
||||
],
|
||||
))->save();
|
||||
}
|
||||
});
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function scanOrphanedFiles(bool $dryRun): array
|
||||
{
|
||||
$disk = $this->storage->diskName();
|
||||
$knownPaths = array_fill_keys(array_map(
|
||||
static fn (string $path): string => ltrim($path, '/'),
|
||||
$this->storage->listKnownJobPaths(),
|
||||
), true);
|
||||
$sample = [];
|
||||
$result = [
|
||||
'files' => 0,
|
||||
'deleted' => 0,
|
||||
'unsupported' => false,
|
||||
'sample' => [],
|
||||
];
|
||||
|
||||
foreach ($this->enhancePrefixes() as $prefix) {
|
||||
try {
|
||||
$files = Storage::disk($disk)->allFiles($prefix);
|
||||
} catch (Throwable) {
|
||||
$result['unsupported'] = true;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
$normalized = ltrim($file, '/');
|
||||
|
||||
if (isset($knownPaths[$normalized])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result['files']++;
|
||||
|
||||
if (count($sample) < 20) {
|
||||
$sample[] = $normalized;
|
||||
}
|
||||
|
||||
if (! $dryRun && $this->storage->safeDelete($disk, $normalized)) {
|
||||
$result['deleted']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result['sample'] = $sample;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function cleanupAttributesForJob(EnhanceJob $job): array
|
||||
{
|
||||
$attributes = [];
|
||||
|
||||
if ($this->storage->isEnhancePath($job->source_path)) {
|
||||
$attributes['source_disk'] = null;
|
||||
$attributes['source_path'] = null;
|
||||
$attributes['source_hash'] = null;
|
||||
}
|
||||
|
||||
if ($this->storage->isEnhancePath($job->output_path)) {
|
||||
$attributes['output_disk'] = null;
|
||||
$attributes['output_path'] = null;
|
||||
$attributes['output_hash'] = null;
|
||||
$attributes['output_width'] = null;
|
||||
$attributes['output_height'] = null;
|
||||
$attributes['output_filesize'] = null;
|
||||
$attributes['output_mime'] = null;
|
||||
}
|
||||
|
||||
if ($this->storage->isEnhancePath($job->preview_path)) {
|
||||
$attributes['preview_disk'] = null;
|
||||
$attributes['preview_path'] = null;
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
private function enhanceJobPaths(EnhanceJob $job): array
|
||||
{
|
||||
return array_values(array_filter([
|
||||
$this->storage->isEnhancePath($job->source_path) ? trim((string) $job->source_path) : null,
|
||||
$this->storage->isEnhancePath($job->output_path) ? trim((string) $job->output_path) : null,
|
||||
$this->storage->isEnhancePath($job->preview_path) ? trim((string) $job->preview_path) : null,
|
||||
]));
|
||||
}
|
||||
|
||||
private function enhancePrefixes(): array
|
||||
{
|
||||
return array_values(array_filter(array_unique(array_map(
|
||||
static fn (string $prefix): string => trim($prefix, '/'),
|
||||
[
|
||||
(string) config('enhance.source_prefix', 'enhance/sources'),
|
||||
(string) config('enhance.output_prefix', 'enhance/outputs'),
|
||||
(string) config('enhance.preview_prefix', 'enhance/previews'),
|
||||
],
|
||||
))));
|
||||
}
|
||||
|
||||
private function resolveDays(mixed $daysOverride, int $default): int
|
||||
{
|
||||
if ($daysOverride === null || $daysOverride === '') {
|
||||
return max(0, $default);
|
||||
}
|
||||
|
||||
return max(0, (int) $daysOverride);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user