Files
timebank-cc-public/app/Console/Commands/BackupPosts.php
Ronald Huynen 2547717edb Initial commit
2026-03-23 21:37:59 +01:00

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,
];
}
}