416 lines
15 KiB
PHP
416 lines
15 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\Meeting;
|
|
use App\Models\Post;
|
|
use App\Models\PostTranslation;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\File;
|
|
use ZipArchive;
|
|
|
|
class BackupPosts extends Command
|
|
{
|
|
/**
|
|
* The name and signature of the console command.
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $signature = 'posts:backup
|
|
{--output= : Output file path (default: backups/posts/posts_backup_YYYYMMDD_HHMMSS.zip)}
|
|
{--post-ids= : Comma-separated list of post IDs to backup (e.g., --post-ids=29,30,405,502)}
|
|
{--exclude-media : Exclude media files from the backup (creates smaller JSON-only backup)}';
|
|
|
|
/**
|
|
* The console command description.
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $description = 'Backup posts, post_translations, meetings, and media to a ZIP archive for restoration on another database';
|
|
|
|
/**
|
|
* The console command help text.
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $help = <<<'HELP'
|
|
Examples:
|
|
<info>php artisan posts:backup</info>
|
|
Backup all posts with media to the default location (backups/posts/posts_YYYYMMDD_HHMMSS.zip)
|
|
|
|
<info>php artisan posts:backup --output=my_backup.zip</info>
|
|
Backup all posts with media to a custom file path
|
|
|
|
<info>php artisan posts:backup --post-ids=29,30,405,502</info>
|
|
Backup only specific posts by their IDs
|
|
|
|
<info>php artisan posts:backup --exclude-media</info>
|
|
Backup posts without media files (JSON-only, smaller file size)
|
|
|
|
<info>php artisan posts:backup --post-ids=29,30 --output=backups/selected_posts.zip</info>
|
|
Backup specific posts to a custom file path
|
|
HELP;
|
|
|
|
/**
|
|
* Execute the console command.
|
|
*/
|
|
public function handle(): int
|
|
{
|
|
$this->info('Starting posts backup...');
|
|
|
|
$excludeMedia = $this->option('exclude-media');
|
|
|
|
// Determine output path
|
|
$outputPath = $this->option('output');
|
|
$extension = $excludeMedia ? 'json' : 'zip';
|
|
|
|
if (!$outputPath) {
|
|
$backupDir = base_path('backups/posts');
|
|
if (!File::isDirectory($backupDir)) {
|
|
File::makeDirectory($backupDir, 0755, true);
|
|
}
|
|
$timestamp = now()->format('Ymd_His');
|
|
$outputPath = "{$backupDir}/posts_backup_{$timestamp}.{$extension}";
|
|
}
|
|
|
|
// Ensure directory exists
|
|
$directory = dirname($outputPath);
|
|
if (!File::isDirectory($directory)) {
|
|
File::makeDirectory($directory, 0755, true);
|
|
}
|
|
|
|
// Build posts query (only non-deleted posts)
|
|
$query = Post::query();
|
|
|
|
// Filter by specific post IDs if provided
|
|
$postIdsOption = $this->option('post-ids');
|
|
if ($postIdsOption) {
|
|
$postIds = array_map('trim', explode(',', $postIdsOption));
|
|
$postIds = array_filter($postIds, fn($id) => is_numeric($id));
|
|
|
|
if (empty($postIds)) {
|
|
$this->error('Invalid post IDs provided. Use comma-separated numeric IDs (e.g., --post-ids=29,30,405)');
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
$query->whereIn('id', $postIds);
|
|
$this->info('Filtering by post IDs: ' . implode(', ', $postIds));
|
|
}
|
|
|
|
$totalPosts = $query->count();
|
|
|
|
if ($totalPosts === 0) {
|
|
$this->warn('No posts found to backup.');
|
|
return Command::SUCCESS;
|
|
}
|
|
|
|
$this->info("Found {$totalPosts} posts to backup");
|
|
|
|
// Build category type lookup (id => type)
|
|
$categoryTypes = Category::pluck('type', 'id')->toArray();
|
|
|
|
// Track counts and media files
|
|
$counts = ['posts' => 0, 'post_translations' => 0, 'meetings' => 0, 'media_files' => 0];
|
|
$mediaFiles = [];
|
|
|
|
// Write posts as JSON incrementally to a temp file to avoid holding everything in memory
|
|
$tempJsonPath = storage_path('app/temp/' . uniqid('backup_json_') . '.json');
|
|
$tempDir = dirname($tempJsonPath);
|
|
if (!File::isDirectory($tempDir)) {
|
|
File::makeDirectory($tempDir, 0755, true);
|
|
}
|
|
|
|
$jsonHandle = fopen($tempJsonPath, 'w');
|
|
fwrite($jsonHandle, '{"meta":"__PLACEHOLDER__","posts":[');
|
|
|
|
$bar = $this->output->createProgressBar($totalPosts);
|
|
$bar->start();
|
|
|
|
$isFirst = true;
|
|
|
|
$query->with(['translations', 'meeting.location', 'media'])
|
|
->chunk(100, function ($posts) use (
|
|
$categoryTypes, $excludeMedia, $jsonHandle,
|
|
&$isFirst, &$counts, &$mediaFiles, $bar
|
|
) {
|
|
foreach ($posts as $post) {
|
|
$categoryType = $categoryTypes[$post->category_id] ?? null;
|
|
|
|
$postData = [
|
|
'category_type' => $categoryType,
|
|
'love_reactant_id' => $post->love_reactant_id,
|
|
'author_id' => $post->author_id,
|
|
'author_model' => $post->author_model,
|
|
'created_at' => $this->formatDate($post->created_at),
|
|
'updated_at' => $this->formatDate($post->updated_at),
|
|
'translations' => [],
|
|
'meeting' => null,
|
|
'media' => null,
|
|
];
|
|
|
|
foreach ($post->translations as $translation) {
|
|
$postData['translations'][] = [
|
|
'locale' => $translation->locale,
|
|
'slug' => $translation->slug,
|
|
'title' => $translation->title,
|
|
'excerpt' => $translation->excerpt,
|
|
'content' => $translation->content,
|
|
'status' => $translation->status,
|
|
'updated_by_user_id' => $translation->updated_by_user_id,
|
|
'from' => $this->formatDate($translation->from),
|
|
'till' => $this->formatDate($translation->till),
|
|
'created_at' => $this->formatDate($translation->created_at),
|
|
'updated_at' => $this->formatDate($translation->updated_at),
|
|
];
|
|
$counts['post_translations']++;
|
|
}
|
|
|
|
if ($post->meeting) {
|
|
$meeting = $post->meeting;
|
|
$postData['meeting'] = [
|
|
'meetingable_type' => $meeting->meetingable_type,
|
|
'meetingable_name' => $meeting->meetingable?->name,
|
|
'venue' => $meeting->venue,
|
|
'address' => $meeting->address,
|
|
'price' => $meeting->price,
|
|
'based_on_quantity' => $meeting->based_on_quantity,
|
|
'transaction_type_id' => $meeting->transaction_type_id,
|
|
'status' => $meeting->status,
|
|
'from' => $this->formatDate($meeting->from),
|
|
'till' => $this->formatDate($meeting->till),
|
|
'created_at' => $this->formatDate($meeting->created_at),
|
|
'updated_at' => $this->formatDate($meeting->updated_at),
|
|
'location' => $this->getLocationNames($meeting->location),
|
|
];
|
|
$counts['meetings']++;
|
|
}
|
|
|
|
if (!$excludeMedia) {
|
|
$media = $post->getFirstMedia('posts');
|
|
if ($media) {
|
|
$originalPath = $media->getPath();
|
|
if (File::exists($originalPath)) {
|
|
$archivePath = "media/{$post->id}/{$media->file_name}";
|
|
$postData['media'] = [
|
|
'name' => $media->name,
|
|
'file_name' => $media->file_name,
|
|
'mime_type' => $media->mime_type,
|
|
'size' => $media->size,
|
|
'collection_name' => $media->collection_name,
|
|
'custom_properties' => $media->custom_properties,
|
|
'archive_path' => $archivePath,
|
|
];
|
|
$mediaFiles[] = [
|
|
'source' => $originalPath,
|
|
'archive_path' => $archivePath,
|
|
];
|
|
$counts['media_files']++;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!$isFirst) {
|
|
fwrite($jsonHandle, ',');
|
|
}
|
|
fwrite($jsonHandle, json_encode($postData, JSON_UNESCAPED_UNICODE));
|
|
$isFirst = false;
|
|
$counts['posts']++;
|
|
|
|
$bar->advance();
|
|
}
|
|
});
|
|
|
|
$bar->finish();
|
|
$this->newLine();
|
|
|
|
// Close JSON array
|
|
fwrite($jsonHandle, ']}');
|
|
fclose($jsonHandle);
|
|
|
|
// Build meta JSON
|
|
$meta = json_encode([
|
|
'version' => '2.0',
|
|
'created_at' => now()->toIso8601String(),
|
|
'source_database' => config('database.connections.mysql.database'),
|
|
'includes_media' => !$excludeMedia,
|
|
'counts' => $counts,
|
|
], JSON_UNESCAPED_UNICODE);
|
|
|
|
// Replace the placeholder in the temp JSON file without reading it all into memory
|
|
$finalJsonPath = $tempJsonPath . '.final';
|
|
$inHandle = fopen($tempJsonPath, 'r');
|
|
$outHandle = fopen($finalJsonPath, 'w');
|
|
|
|
// Read the placeholder prefix, replace it, then stream the rest
|
|
$prefix = fread($inHandle, strlen('{"meta":"__PLACEHOLDER__"'));
|
|
fwrite($outHandle, '{"meta":' . $meta);
|
|
|
|
// Stream the rest of the file in small chunks
|
|
while (!feof($inHandle)) {
|
|
fwrite($outHandle, fread($inHandle, 8192));
|
|
}
|
|
|
|
fclose($inHandle);
|
|
fclose($outHandle);
|
|
@unlink($tempJsonPath);
|
|
rename($finalJsonPath, $tempJsonPath);
|
|
|
|
if ($excludeMedia) {
|
|
// Move temp JSON as the final output
|
|
File::move($tempJsonPath, $outputPath);
|
|
} else {
|
|
// Create ZIP archive with JSON and media files
|
|
$this->info('Creating ZIP archive with media files...');
|
|
|
|
if (!class_exists('ZipArchive')) {
|
|
@unlink($tempJsonPath);
|
|
$this->error('ZipArchive extension is not available. Install php-zip extension or use --exclude-media flag.');
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
$zip = new ZipArchive();
|
|
if ($zip->open($outputPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
|
@unlink($tempJsonPath);
|
|
$this->error("Failed to create ZIP archive: {$outputPath}");
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
// Add backup.json to archive
|
|
$zip->addFile($tempJsonPath, 'backup.json');
|
|
|
|
// Add media files to archive
|
|
$mediaBar = $this->output->createProgressBar(count($mediaFiles));
|
|
$mediaBar->start();
|
|
|
|
foreach ($mediaFiles as $mediaFile) {
|
|
if (File::exists($mediaFile['source'])) {
|
|
$zip->addFile($mediaFile['source'], $mediaFile['archive_path']);
|
|
}
|
|
$mediaBar->advance();
|
|
}
|
|
|
|
$mediaBar->finish();
|
|
$this->newLine();
|
|
|
|
$zip->close();
|
|
@unlink($tempJsonPath);
|
|
}
|
|
|
|
$fileSize = $this->formatBytes(File::size($outputPath));
|
|
|
|
$this->info("Backup completed successfully!");
|
|
$this->table(
|
|
['Metric', 'Value'],
|
|
[
|
|
['Posts', $counts['posts']],
|
|
['Translations', $counts['post_translations']],
|
|
['Meetings', $counts['meetings']],
|
|
['Media Files', $counts['media_files']],
|
|
['File Size', $fileSize],
|
|
['Output File', $outputPath],
|
|
]
|
|
);
|
|
|
|
return Command::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* Format bytes to human readable format.
|
|
*/
|
|
private function formatBytes(int $bytes): string
|
|
{
|
|
$units = ['B', 'KB', 'MB', 'GB'];
|
|
$i = 0;
|
|
while ($bytes >= 1024 && $i < count($units) - 1) {
|
|
$bytes /= 1024;
|
|
$i++;
|
|
}
|
|
return round($bytes, 2) . ' ' . $units[$i];
|
|
}
|
|
|
|
/**
|
|
* Safely format a date value to ISO8601 string.
|
|
*/
|
|
private function formatDate($value): ?string
|
|
{
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
|
|
if ($value instanceof \Carbon\Carbon || $value instanceof \DateTime) {
|
|
return $value->format('c');
|
|
}
|
|
|
|
if (is_string($value)) {
|
|
return $value;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get location names in the app's base locale for backup.
|
|
* Names are used for lookup on restore instead of IDs.
|
|
*/
|
|
private function getLocationNames($location): ?array
|
|
{
|
|
if (!$location) {
|
|
return null;
|
|
}
|
|
|
|
$baseLocale = config('app.locale');
|
|
|
|
// Get country name
|
|
$countryName = null;
|
|
if ($location->country_id) {
|
|
$countryLocale = CountryLocale::withoutGlobalScopes()
|
|
->where('country_id', $location->country_id)
|
|
->where('locale', $baseLocale)
|
|
->first();
|
|
$countryName = $countryLocale?->name;
|
|
}
|
|
|
|
// Get division name
|
|
$divisionName = null;
|
|
if ($location->division_id) {
|
|
$divisionLocale = DivisionLocale::withoutGlobalScopes()
|
|
->where('division_id', $location->division_id)
|
|
->where('locale', $baseLocale)
|
|
->first();
|
|
$divisionName = $divisionLocale?->name;
|
|
}
|
|
|
|
// Get city name
|
|
$cityName = null;
|
|
if ($location->city_id) {
|
|
$cityLocale = CityLocale::withoutGlobalScopes()
|
|
->where('city_id', $location->city_id)
|
|
->where('locale', $baseLocale)
|
|
->first();
|
|
$cityName = $cityLocale?->name;
|
|
}
|
|
|
|
// Get district name
|
|
$districtName = null;
|
|
if ($location->district_id) {
|
|
$districtLocale = DistrictLocale::withoutGlobalScopes()
|
|
->where('district_id', $location->district_id)
|
|
->where('locale', $baseLocale)
|
|
->first();
|
|
$districtName = $districtLocale?->name;
|
|
}
|
|
|
|
return [
|
|
'country_name' => $countryName,
|
|
'division_name' => $divisionName,
|
|
'city_name' => $cityName,
|
|
'district_name' => $districtName,
|
|
];
|
|
}
|
|
}
|