649 lines
27 KiB
PHP
649 lines
27 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\Category;
|
|
use App\Models\Locations\CityLocale;
|
|
use App\Models\Locations\CountryLocale;
|
|
use App\Models\Locations\DistrictLocale;
|
|
use App\Models\Locations\DivisionLocale;
|
|
use App\Models\Locations\Location;
|
|
use App\Models\Meeting;
|
|
use App\Models\Post;
|
|
use App\Models\PostTranslation;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\File;
|
|
use ZipArchive;
|
|
|
|
class RestorePosts extends Command
|
|
{
|
|
/**
|
|
* The name and signature of the console command.
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $signature = 'posts:restore
|
|
{file : Path to the backup file (ZIP archive or JSON file)}
|
|
{--profile-id= : Profile ID to assign as post owner (overrides active session)}
|
|
{--profile-type= : Profile type (User, Organization, Bank, Admin) to assign as post owner}
|
|
{--dry-run : Show what would be imported without making changes}
|
|
{--skip-existing : Skip posts with duplicate slugs instead of failing}
|
|
{--skip-media : Skip media restoration even if backup contains media files}
|
|
{--select : Interactively select which posts to restore}';
|
|
|
|
/**
|
|
* The console command description.
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $description = 'Restore posts, post_translations, meetings, and media from a backup file (ZIP or JSON)';
|
|
|
|
/**
|
|
* Execute the console command.
|
|
*/
|
|
public function handle(): int
|
|
{
|
|
$filePath = $this->argument('file');
|
|
$dryRun = $this->option('dry-run');
|
|
$skipExisting = $this->option('skip-existing');
|
|
$skipMedia = $this->option('skip-media');
|
|
|
|
// Validate file exists
|
|
if (!File::exists($filePath)) {
|
|
$this->error("Backup file not found: {$filePath}");
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
// Determine file type and extract if necessary
|
|
$isZip = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)) === 'zip';
|
|
$extractDir = null;
|
|
$backupData = null;
|
|
|
|
if ($isZip) {
|
|
// Extract ZIP archive
|
|
if (!class_exists('ZipArchive')) {
|
|
$this->error('ZipArchive extension is not available. Install php-zip extension.');
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
$extractDir = storage_path('app/temp/restore_' . uniqid());
|
|
File::makeDirectory($extractDir, 0755, true);
|
|
|
|
$zip = new ZipArchive();
|
|
if ($zip->open($filePath) !== true) {
|
|
$this->error("Failed to open ZIP archive: {$filePath}");
|
|
File::deleteDirectory($extractDir);
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
$zip->extractTo($extractDir);
|
|
$zip->close();
|
|
|
|
$this->info("Extracted ZIP archive to temporary directory");
|
|
|
|
// Read backup.json from extracted directory
|
|
$jsonPath = "{$extractDir}/backup.json";
|
|
if (!File::exists($jsonPath)) {
|
|
$this->error("Invalid ZIP archive: missing backup.json");
|
|
File::deleteDirectory($extractDir);
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
$json = File::get($jsonPath);
|
|
} else {
|
|
// Read JSON file directly
|
|
$json = File::get($filePath);
|
|
}
|
|
|
|
$backupData = json_decode($json, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
$this->error("Invalid JSON file: " . json_last_error_msg());
|
|
if ($extractDir) {
|
|
File::deleteDirectory($extractDir);
|
|
}
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
// Validate backup format
|
|
if (!isset($backupData['meta']) || !isset($backupData['posts'])) {
|
|
$this->error("Invalid backup file format. Missing 'meta' or 'posts' keys.");
|
|
if ($extractDir) {
|
|
File::deleteDirectory($extractDir);
|
|
}
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
$includesMedia = $backupData['meta']['includes_media'] ?? false;
|
|
$mediaCount = $backupData['meta']['counts']['media_files'] ?? 0;
|
|
|
|
$this->info("Backup file info:");
|
|
$this->table(
|
|
['Property', 'Value'],
|
|
[
|
|
['Version', $backupData['meta']['version'] ?? 'unknown'],
|
|
['Created', $backupData['meta']['created_at'] ?? 'unknown'],
|
|
['Source Database', $backupData['meta']['source_database'] ?? 'unknown'],
|
|
['Posts', $backupData['meta']['counts']['posts'] ?? count($backupData['posts'])],
|
|
['Translations', $backupData['meta']['counts']['post_translations'] ?? 'unknown'],
|
|
['Meetings', $backupData['meta']['counts']['meetings'] ?? 'unknown'],
|
|
['Media Files', $mediaCount],
|
|
['Includes Media', $includesMedia ? 'Yes' : 'No'],
|
|
]
|
|
);
|
|
|
|
if ($includesMedia && $skipMedia) {
|
|
$this->warn("Media restoration will be skipped (--skip-media flag)");
|
|
}
|
|
|
|
// Handle post selection with --select flag
|
|
$postsToRestore = $backupData['posts'];
|
|
|
|
if ($this->option('select')) {
|
|
$baseLocale = config('app.locale');
|
|
|
|
// Build numbered list for display
|
|
$tableRows = [];
|
|
foreach ($backupData['posts'] as $index => $post) {
|
|
$title = null;
|
|
$locales = [];
|
|
foreach ($post['translations'] ?? [] as $translation) {
|
|
$locales[] = $translation['locale'];
|
|
if ($translation['locale'] === $baseLocale) {
|
|
$title = $translation['title'];
|
|
}
|
|
}
|
|
if ($title === null && !empty($post['translations'])) {
|
|
$title = $post['translations'][0]['title'];
|
|
}
|
|
|
|
$indicators = [];
|
|
if (!empty($post['meeting'])) $indicators[] = 'meeting';
|
|
if (!empty($post['media'])) $indicators[] = 'media';
|
|
$indicatorStr = $indicators ? ' [' . implode(', ', $indicators) . ']' : '';
|
|
|
|
$tableRows[] = [
|
|
$index + 1,
|
|
($title ?? 'Untitled') . $indicatorStr,
|
|
implode(', ', $locales),
|
|
];
|
|
}
|
|
|
|
$this->newLine();
|
|
$this->info('Available posts:');
|
|
$this->table(['#', 'Title', 'Locales'], $tableRows);
|
|
|
|
$this->info("Enter post numbers to restore (comma-separated, ranges with dash, or 'all').");
|
|
$this->info("Examples: 1,3,5 or 1-10 or 1-5,8,12-15 or all");
|
|
$input = $this->ask('Selection');
|
|
|
|
if (strtolower(trim($input)) !== 'all') {
|
|
$selectedIndices = $this->parseSelection($input, count($backupData['posts']));
|
|
|
|
if (empty($selectedIndices)) {
|
|
$this->error('No valid posts selected.');
|
|
if ($extractDir) {
|
|
File::deleteDirectory($extractDir);
|
|
}
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
$postsToRestore = [];
|
|
foreach ($selectedIndices as $idx) {
|
|
$postsToRestore[] = $backupData['posts'][$idx];
|
|
}
|
|
|
|
$this->info("Selected " . count($postsToRestore) . " of " . count($backupData['posts']) . " posts.");
|
|
}
|
|
}
|
|
|
|
// Determine profile for post ownership
|
|
$profileId = $this->option('profile-id');
|
|
$profileType = $this->option('profile-type');
|
|
|
|
if ($profileId && $profileType) {
|
|
// Use provided profile
|
|
$profileType = $this->resolveProfileType($profileType);
|
|
if (!$profileType) {
|
|
$this->error("Invalid profile type. Use: User, Organization, Bank, or Admin");
|
|
return Command::FAILURE;
|
|
}
|
|
} else {
|
|
// Try to get from session (won't work in CLI, but check anyway)
|
|
$profileId = session('activeProfileId');
|
|
$profileType = session('activeProfileType');
|
|
|
|
if (!$profileId || !$profileType) {
|
|
$this->error("No active profile in session. Please provide --profile-id and --profile-type options.");
|
|
$this->info("Example: php artisan posts:restore backup.json --profile-id=1 --profile-type=User");
|
|
return Command::FAILURE;
|
|
}
|
|
}
|
|
|
|
// Validate profile exists
|
|
if (!class_exists($profileType)) {
|
|
$this->error("Profile type class not found: {$profileType}");
|
|
if ($extractDir) {
|
|
File::deleteDirectory($extractDir);
|
|
}
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
$profile = $profileType::find($profileId);
|
|
if (!$profile) {
|
|
$this->error("Profile not found: {$profileType} with ID {$profileId}");
|
|
if ($extractDir) {
|
|
File::deleteDirectory($extractDir);
|
|
}
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
$this->info("Posts will be assigned to: {$profile->name} ({$profileType} #{$profileId})");
|
|
|
|
// Build category type => id lookup for the target database
|
|
$categoryLookup = Category::pluck('id', 'type')->toArray();
|
|
|
|
if ($dryRun) {
|
|
$this->warn("DRY RUN MODE - No changes will be made");
|
|
}
|
|
|
|
if (!$dryRun && !$this->confirm("Do you want to proceed with the restore?")) {
|
|
$this->info("Restore cancelled.");
|
|
if ($extractDir) {
|
|
File::deleteDirectory($extractDir);
|
|
}
|
|
return Command::SUCCESS;
|
|
}
|
|
|
|
$stats = [
|
|
'posts_created' => 0,
|
|
'posts_skipped' => 0,
|
|
'posts_overwritten' => 0,
|
|
'translations_created' => 0,
|
|
'meetings_created' => 0,
|
|
'media_restored' => 0,
|
|
'media_skipped' => 0,
|
|
'category_not_found' => 0,
|
|
];
|
|
|
|
// Track "all" choices for duplicate handling
|
|
$skipAll = null;
|
|
|
|
$bar = $this->output->createProgressBar(count($postsToRestore));
|
|
$bar->start();
|
|
|
|
DB::beginTransaction();
|
|
|
|
try {
|
|
foreach ($postsToRestore as $postData) {
|
|
// Look up category_id by category_type
|
|
$categoryId = null;
|
|
if (!empty($postData['category_type'])) {
|
|
$categoryId = $categoryLookup[$postData['category_type']] ?? null;
|
|
if ($categoryId === null) {
|
|
$this->newLine();
|
|
$this->warn("Category type not found: {$postData['category_type']}");
|
|
$stats['category_not_found']++;
|
|
}
|
|
}
|
|
|
|
// Check for existing slugs
|
|
if (!empty($postData['translations'])) {
|
|
$existingSlugs = PostTranslation::withTrashed()
|
|
->whereIn('slug', array_column($postData['translations'], 'slug'))
|
|
->pluck('slug')
|
|
->toArray();
|
|
|
|
if (!empty($existingSlugs)) {
|
|
$this->newLine();
|
|
$this->warn("Duplicate slug(s) found: " . implode(', ', $existingSlugs));
|
|
|
|
// Determine action based on flags or prompt
|
|
$action = $skipAll ?? null;
|
|
|
|
if ($action === null && !$skipExisting) {
|
|
$action = $this->choice(
|
|
'What would you like to do?',
|
|
[
|
|
'skip' => 'Skip this post',
|
|
'overwrite' => 'Overwrite existing post(s)',
|
|
'skip_all' => 'Skip all duplicates',
|
|
'overwrite_all' => 'Overwrite all duplicates',
|
|
],
|
|
'skip'
|
|
);
|
|
|
|
if ($action === 'skip_all') {
|
|
$skipAll = 'skip';
|
|
$action = 'skip';
|
|
} elseif ($action === 'overwrite_all') {
|
|
$skipAll = 'overwrite';
|
|
$action = 'overwrite';
|
|
}
|
|
} elseif ($skipExisting) {
|
|
$action = 'skip';
|
|
}
|
|
|
|
if ($action === 'skip') {
|
|
$stats['posts_skipped']++;
|
|
$bar->advance();
|
|
continue;
|
|
} elseif ($action === 'overwrite') {
|
|
// Delete existing translations and their posts if they become empty
|
|
$existingTranslations = PostTranslation::withTrashed()
|
|
->whereIn('slug', $existingSlugs)
|
|
->get();
|
|
|
|
foreach ($existingTranslations as $existingTranslation) {
|
|
$postId = $existingTranslation->post_id;
|
|
$existingTranslation->forceDelete();
|
|
|
|
// Check if post has no more translations and delete it too
|
|
$remainingTranslations = PostTranslation::withTrashed()
|
|
->where('post_id', $postId)
|
|
->count();
|
|
|
|
if ($remainingTranslations === 0) {
|
|
$existingPost = Post::withTrashed()->find($postId);
|
|
if ($existingPost) {
|
|
// Delete related meetings first
|
|
Meeting::withTrashed()->where('post_id', $postId)->forceDelete();
|
|
$existingPost->forceDelete();
|
|
}
|
|
}
|
|
}
|
|
$stats['posts_overwritten']++;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!$dryRun) {
|
|
// Create post with profile ownership
|
|
$post = new Post();
|
|
$post->postable_id = $profileId;
|
|
$post->postable_type = $profileType;
|
|
$post->category_id = $categoryId;
|
|
// Don't set love_reactant_id - let PostObserver register it as reactant
|
|
$post->author_id = $postData['author_id'];
|
|
$post->author_model = $postData['author_model'];
|
|
$post->created_at = $postData['created_at'] ? new \DateTime($postData['created_at']) : now();
|
|
$post->updated_at = $postData['updated_at'] ? new \DateTime($postData['updated_at']) : now();
|
|
$post->save();
|
|
|
|
// Ensure post is registered as reactant (in case observer didn't fire)
|
|
if (!$post->isRegisteredAsLoveReactant()) {
|
|
$post->registerAsLoveReactant();
|
|
}
|
|
|
|
// Create translations
|
|
foreach ($postData['translations'] as $translationData) {
|
|
$translation = new PostTranslation();
|
|
$translation->post_id = $post->id;
|
|
$translation->locale = $translationData['locale'];
|
|
$translation->slug = $translationData['slug'];
|
|
$translation->title = $translationData['title'];
|
|
$translation->excerpt = $translationData['excerpt'];
|
|
$translation->content = $translationData['content'];
|
|
$translation->status = $translationData['status'];
|
|
$translation->updated_by_user_id = $translationData['updated_by_user_id'];
|
|
$translation->from = $translationData['from'] ? new \DateTime($translationData['from']) : null;
|
|
$translation->till = $translationData['till'] ? new \DateTime($translationData['till']) : null;
|
|
$translation->created_at = $translationData['created_at'] ? new \DateTime($translationData['created_at']) : now();
|
|
$translation->updated_at = $translationData['updated_at'] ? new \DateTime($translationData['updated_at']) : now();
|
|
$translation->save();
|
|
|
|
$stats['translations_created']++;
|
|
}
|
|
|
|
// Create meeting (hasOne relationship)
|
|
if (!empty($postData['meeting'])) {
|
|
$meetingData = $postData['meeting'];
|
|
|
|
// Look up meetingable by name and type
|
|
$meetingableId = null;
|
|
$meetingableType = null;
|
|
// Whitelist of allowed meetingable types to prevent arbitrary class instantiation
|
|
$allowedMeetingableTypes = [
|
|
\App\Models\User::class,
|
|
\App\Models\Organization::class,
|
|
\App\Models\Bank::class,
|
|
];
|
|
if (!empty($meetingData['meetingable_type']) && !empty($meetingData['meetingable_name'])) {
|
|
$meetingableType = $meetingData['meetingable_type'];
|
|
if (in_array($meetingableType, $allowedMeetingableTypes, true)) {
|
|
$meetingable = $meetingableType::where('name', $meetingData['meetingable_name'])->first();
|
|
if ($meetingable) {
|
|
$meetingableId = $meetingable->id;
|
|
}
|
|
}
|
|
}
|
|
|
|
$meeting = new Meeting();
|
|
$meeting->post_id = $post->id;
|
|
$meeting->meetingable_id = $meetingableId;
|
|
$meeting->meetingable_type = $meetingableId ? $meetingableType : null;
|
|
$meeting->venue = $meetingData['venue'];
|
|
$meeting->address = $meetingData['address'];
|
|
$meeting->price = $meetingData['price'];
|
|
$meeting->based_on_quantity = $meetingData['based_on_quantity'];
|
|
$meeting->transaction_type_id = $meetingData['transaction_type_id'];
|
|
$meeting->status = $meetingData['status'];
|
|
$meeting->from = $meetingData['from'] ? new \DateTime($meetingData['from']) : null;
|
|
$meeting->till = $meetingData['till'] ? new \DateTime($meetingData['till']) : null;
|
|
$meeting->created_at = $meetingData['created_at'] ? new \DateTime($meetingData['created_at']) : now();
|
|
$meeting->updated_at = $meetingData['updated_at'] ? new \DateTime($meetingData['updated_at']) : now();
|
|
$meeting->save();
|
|
|
|
// Create location if location data exists
|
|
if (!empty($meetingData['location'])) {
|
|
$locationIds = $this->lookupLocationIds($meetingData['location']);
|
|
if ($locationIds['country_id'] || $locationIds['division_id'] || $locationIds['city_id'] || $locationIds['district_id']) {
|
|
$location = new Location();
|
|
$location->locatable_id = $meeting->id;
|
|
$location->locatable_type = Meeting::class;
|
|
$location->country_id = $locationIds['country_id'];
|
|
$location->division_id = $locationIds['division_id'];
|
|
$location->city_id = $locationIds['city_id'];
|
|
$location->district_id = $locationIds['district_id'];
|
|
$location->save();
|
|
}
|
|
}
|
|
|
|
$stats['meetings_created']++;
|
|
}
|
|
|
|
// Restore media if available and not skipped
|
|
if (!$skipMedia && $extractDir && !empty($postData['media'])) {
|
|
$mediaData = $postData['media'];
|
|
$mediaPath = "{$extractDir}/{$mediaData['archive_path']}";
|
|
|
|
if (File::exists($mediaPath)) {
|
|
try {
|
|
$media = $post->addMedia($mediaPath)
|
|
->usingName($mediaData['name'])
|
|
->usingFileName($mediaData['file_name'])
|
|
->withCustomProperties($mediaData['custom_properties'] ?? [])
|
|
->toMediaCollection('posts');
|
|
|
|
// Dispatch conversion job to queue
|
|
$conversionCollection = \Spatie\MediaLibrary\Conversions\ConversionCollection::createForMedia($media);
|
|
if ($conversionCollection->isNotEmpty()) {
|
|
dispatch(new \Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob($conversionCollection, $media, false))
|
|
->onQueue('low');
|
|
}
|
|
|
|
$stats['media_restored']++;
|
|
} catch (\Exception $e) {
|
|
$this->newLine();
|
|
$this->warn("Failed to restore media for post {$post->id}: " . $e->getMessage());
|
|
$stats['media_skipped']++;
|
|
}
|
|
} else {
|
|
$this->newLine();
|
|
$this->warn("Media file not found in archive: {$mediaData['archive_path']}");
|
|
$stats['media_skipped']++;
|
|
}
|
|
}
|
|
} else {
|
|
// Dry run - just count
|
|
$stats['translations_created'] += count($postData['translations']);
|
|
$stats['meetings_created'] += !empty($postData['meeting']) ? 1 : 0;
|
|
if (!empty($postData['media'])) {
|
|
$stats['media_restored']++;
|
|
}
|
|
}
|
|
|
|
$stats['posts_created']++;
|
|
$bar->advance();
|
|
}
|
|
|
|
if (!$dryRun) {
|
|
DB::commit();
|
|
}
|
|
|
|
} catch (\Exception $e) {
|
|
DB::rollBack();
|
|
$this->newLine();
|
|
$this->error("Restore failed: " . $e->getMessage());
|
|
if ($extractDir) {
|
|
File::deleteDirectory($extractDir);
|
|
}
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
// Clean up extracted files
|
|
if ($extractDir) {
|
|
File::deleteDirectory($extractDir);
|
|
$this->info("Cleaned up temporary files");
|
|
}
|
|
|
|
$bar->finish();
|
|
$this->newLine(2);
|
|
|
|
$this->info($dryRun ? "Dry run completed!" : "Restore completed successfully!");
|
|
$this->table(
|
|
['Metric', 'Value'],
|
|
[
|
|
['Posts Created', $stats['posts_created']],
|
|
['Posts Skipped', $stats['posts_skipped']],
|
|
['Posts Overwritten', $stats['posts_overwritten']],
|
|
['Translations Created', $stats['translations_created']],
|
|
['Meetings Created', $stats['meetings_created']],
|
|
['Media Restored', $stats['media_restored']],
|
|
['Media Skipped', $stats['media_skipped']],
|
|
['Categories Not Found', $stats['category_not_found']],
|
|
]
|
|
);
|
|
|
|
if ($stats['category_not_found'] > 0) {
|
|
$this->warn("Some posts were created without a category. You may need to assign categories manually.");
|
|
}
|
|
|
|
return Command::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* Resolve profile type string to full class name.
|
|
*/
|
|
private function resolveProfileType(string $type): ?string
|
|
{
|
|
$typeMap = [
|
|
'user' => \App\Models\User::class,
|
|
'organization' => \App\Models\Organization::class,
|
|
'bank' => \App\Models\Bank::class,
|
|
'admin' => \App\Models\Admin::class,
|
|
];
|
|
|
|
$normalized = strtolower(trim($type));
|
|
|
|
// Handle full class names - only allow known model classes
|
|
if (str_contains($type, '\\')) {
|
|
return in_array($type, $typeMap, true) ? $type : null;
|
|
}
|
|
|
|
return $typeMap[$normalized] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Look up location IDs by names in the app's base locale.
|
|
* Returns null for any location component that cannot be found.
|
|
*/
|
|
private function lookupLocationIds(array $locationData): array
|
|
{
|
|
$baseLocale = config('app.locale');
|
|
|
|
$result = [
|
|
'country_id' => null,
|
|
'division_id' => null,
|
|
'city_id' => null,
|
|
'district_id' => null,
|
|
];
|
|
|
|
// Look up country by name
|
|
if (!empty($locationData['country_name'])) {
|
|
$countryLocale = CountryLocale::withoutGlobalScopes()
|
|
->where('name', $locationData['country_name'])
|
|
->where('locale', $baseLocale)
|
|
->first();
|
|
$result['country_id'] = $countryLocale?->country_id;
|
|
}
|
|
|
|
// Look up division by name
|
|
if (!empty($locationData['division_name'])) {
|
|
$divisionLocale = DivisionLocale::withoutGlobalScopes()
|
|
->where('name', $locationData['division_name'])
|
|
->where('locale', $baseLocale)
|
|
->first();
|
|
$result['division_id'] = $divisionLocale?->division_id;
|
|
}
|
|
|
|
// Look up city by name
|
|
if (!empty($locationData['city_name'])) {
|
|
$cityLocale = CityLocale::withoutGlobalScopes()
|
|
->where('name', $locationData['city_name'])
|
|
->where('locale', $baseLocale)
|
|
->first();
|
|
$result['city_id'] = $cityLocale?->city_id;
|
|
}
|
|
|
|
// Look up district by name
|
|
if (!empty($locationData['district_name'])) {
|
|
$districtLocale = DistrictLocale::withoutGlobalScopes()
|
|
->where('name', $locationData['district_name'])
|
|
->where('locale', $baseLocale)
|
|
->first();
|
|
$result['district_id'] = $districtLocale?->district_id;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Parse a user selection string like "1,3,5-10" into an array of 0-based indices.
|
|
*/
|
|
private function parseSelection(string $input, int $total): array
|
|
{
|
|
$indices = [];
|
|
$parts = preg_split('/\s*,\s*/', trim($input));
|
|
|
|
foreach ($parts as $part) {
|
|
$part = trim($part);
|
|
if (preg_match('/^(\d+)-(\d+)$/', $part, $matches)) {
|
|
$start = max(1, (int) $matches[1]);
|
|
$end = min($total, (int) $matches[2]);
|
|
for ($i = $start; $i <= $end; $i++) {
|
|
$indices[] = $i - 1;
|
|
}
|
|
} elseif (preg_match('/^\d+$/', $part)) {
|
|
$num = (int) $part;
|
|
if ($num >= 1 && $num <= $total) {
|
|
$indices[] = $num - 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($indices));
|
|
}
|
|
}
|