295 lines
8.4 KiB
TypeScript
295 lines
8.4 KiB
TypeScript
#!/usr/bin/env tsx
|
||
/**
|
||
* Storage Migration Script
|
||
*
|
||
* Migrates gallery photos from filesystem to Supabase Storage.
|
||
*
|
||
* Usage:
|
||
* tsx scripts/migrate-storage.ts [--dry-run] [--force]
|
||
*
|
||
* Options:
|
||
* --dry-run Show what would be migrated without actually migrating
|
||
* --force Force migration even if files already exist in storage
|
||
*/
|
||
|
||
import { promises as fs } from 'fs';
|
||
import path from 'path';
|
||
import { createSupabaseAdminClient, isSupabaseConfigured } from '../src/lib/supabase';
|
||
import { uploadFile } from '../src/lib/storage';
|
||
import { logger } from '../src/lib/logger';
|
||
|
||
const GALLERY_BUCKET = 'gallery';
|
||
const DATA_FILE = path.join(process.cwd(), "data", "gallery.json");
|
||
const UPLOADS_DIR = path.join(process.cwd(), "public", "uploads", "gallery");
|
||
const THUMBNAILS_DIR = path.join(UPLOADS_DIR, "thumbnails");
|
||
const FULL_IMAGES_DIR = path.join(UPLOADS_DIR, "full");
|
||
|
||
interface GalleryItem {
|
||
id: string;
|
||
guestName: string;
|
||
thumbnailUrl: string;
|
||
fullImageUrl: string;
|
||
caption?: string;
|
||
timestamp: string;
|
||
orientation?: "portrait" | "landscape" | "square";
|
||
}
|
||
|
||
async function loadGalleryItems(): Promise<GalleryItem[]> {
|
||
try {
|
||
const raw = await fs.readFile(DATA_FILE, "utf8");
|
||
const parsed = JSON.parse(raw);
|
||
return Array.isArray(parsed) ? parsed : [];
|
||
} catch (err: any) {
|
||
if (err.code === "ENOENT") {
|
||
return [];
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
async function saveGalleryItems(items: GalleryItem[]): Promise<void> {
|
||
const json = JSON.stringify(items, null, 2);
|
||
await fs.writeFile(DATA_FILE, json, "utf8");
|
||
}
|
||
|
||
function isLocalPath(url: string): boolean {
|
||
return url.startsWith("/uploads/") || url.startsWith("./");
|
||
}
|
||
|
||
function extractFileName(url: string): string {
|
||
return path.basename(url);
|
||
}
|
||
|
||
async function migrateFile(
|
||
localPath: string,
|
||
storagePath: string,
|
||
bucket: string,
|
||
dryRun: boolean
|
||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||
try {
|
||
const fullLocalPath = localPath.startsWith("/")
|
||
? path.join(process.cwd(), "public", localPath)
|
||
: localPath;
|
||
|
||
// Check if file exists
|
||
try {
|
||
await fs.access(fullLocalPath);
|
||
} catch {
|
||
return { success: false, error: 'File not found' };
|
||
}
|
||
|
||
if (dryRun) {
|
||
return { success: true, url: `[DRY RUN] Would upload to ${storagePath}` };
|
||
}
|
||
|
||
// Read file
|
||
const fileBuffer = await fs.readFile(fullLocalPath);
|
||
const fileName = path.basename(fullLocalPath);
|
||
const mimeType = getMimeType(fileName);
|
||
|
||
// Upload to Supabase Storage
|
||
const supabase = createSupabaseAdminClient();
|
||
const { data, error } = await supabase.storage
|
||
.from(bucket)
|
||
.upload(storagePath, fileBuffer, {
|
||
cacheControl: '3600',
|
||
upsert: false,
|
||
contentType: mimeType,
|
||
});
|
||
|
||
if (error) {
|
||
// If file already exists, check if we should skip or overwrite
|
||
if (error.message.includes('already exists')) {
|
||
return { success: false, error: 'File already exists in storage' };
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
// Get public URL
|
||
const { data: { publicUrl } } = supabase.storage
|
||
.from(bucket)
|
||
.getPublicUrl(storagePath);
|
||
|
||
return { success: true, url: publicUrl };
|
||
} catch (error) {
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
};
|
||
}
|
||
}
|
||
|
||
function getMimeType(fileName: string): string {
|
||
const ext = path.extname(fileName).toLowerCase();
|
||
const mimeTypes: Record<string, string> = {
|
||
'.jpg': 'image/jpeg',
|
||
'.jpeg': 'image/jpeg',
|
||
'.png': 'image/png',
|
||
'.webp': 'image/webp',
|
||
};
|
||
return mimeTypes[ext] || 'application/octet-stream';
|
||
}
|
||
|
||
async function migrateGalleryItems(
|
||
items: GalleryItem[],
|
||
dryRun: boolean,
|
||
force: boolean
|
||
): Promise<{ migrated: number; failed: number; skipped: number }> {
|
||
let migrated = 0;
|
||
let failed = 0;
|
||
let skipped = 0;
|
||
|
||
for (const item of items) {
|
||
const thumbnailIsLocal = isLocalPath(item.thumbnailUrl);
|
||
const fullImageIsLocal = isLocalPath(item.fullImageUrl);
|
||
|
||
// Skip if both are already remote URLs
|
||
if (!thumbnailIsLocal && !fullImageIsLocal) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
|
||
console.log(`\nMigrating item ${item.id}...`);
|
||
|
||
let thumbnailUrl = item.thumbnailUrl;
|
||
let fullImageUrl = item.fullImageUrl;
|
||
|
||
// Migrate thumbnail
|
||
if (thumbnailIsLocal) {
|
||
const thumbnailFileName = extractFileName(item.thumbnailUrl);
|
||
const thumbnailStoragePath = `thumbnails/${thumbnailFileName}`;
|
||
|
||
console.log(` Thumbnail: ${item.thumbnailUrl} -> ${thumbnailStoragePath}`);
|
||
const result = await migrateFile(
|
||
item.thumbnailUrl,
|
||
thumbnailStoragePath,
|
||
GALLERY_BUCKET,
|
||
dryRun
|
||
);
|
||
|
||
if (result.success && result.url) {
|
||
thumbnailUrl = result.url;
|
||
console.log(` ✓ Thumbnail migrated: ${result.url}`);
|
||
} else if (result.error?.includes('already exists') && !force) {
|
||
console.log(` ⊘ Thumbnail already in storage, skipping`);
|
||
skipped++;
|
||
continue;
|
||
} else {
|
||
console.log(` ✗ Thumbnail migration failed: ${result.error}`);
|
||
failed++;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Migrate full image
|
||
if (fullImageIsLocal) {
|
||
const fullImageFileName = extractFileName(item.fullImageUrl);
|
||
const fullImageStoragePath = `full/${fullImageFileName}`;
|
||
|
||
console.log(` Full image: ${item.fullImageUrl} -> ${fullImageStoragePath}`);
|
||
const result = await migrateFile(
|
||
item.fullImageUrl,
|
||
fullImageStoragePath,
|
||
GALLERY_BUCKET,
|
||
dryRun
|
||
);
|
||
|
||
if (result.success && result.url) {
|
||
fullImageUrl = result.url;
|
||
console.log(` ✓ Full image migrated: ${result.url}`);
|
||
} else if (result.error?.includes('already exists') && !force) {
|
||
console.log(` ⊘ Full image already in storage, skipping`);
|
||
skipped++;
|
||
continue;
|
||
} else {
|
||
console.log(` ✗ Full image migration failed: ${result.error}`);
|
||
failed++;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Update item with new URLs
|
||
if (!dryRun && (thumbnailUrl !== item.thumbnailUrl || fullImageUrl !== item.fullImageUrl)) {
|
||
item.thumbnailUrl = thumbnailUrl;
|
||
item.fullImageUrl = fullImageUrl;
|
||
migrated++;
|
||
} else if (dryRun) {
|
||
migrated++;
|
||
}
|
||
}
|
||
|
||
return { migrated, failed, skipped };
|
||
}
|
||
|
||
async function main() {
|
||
const args = process.argv.slice(2);
|
||
const dryRun = args.includes('--dry-run');
|
||
const force = args.includes('--force');
|
||
|
||
console.log('=== Gallery Storage Migration ===\n');
|
||
console.log(`Mode: ${dryRun ? 'DRY RUN' : 'LIVE'}`);
|
||
console.log(`Force: ${force ? 'Yes' : 'No'}\n`);
|
||
|
||
// Check Supabase configuration
|
||
if (!isSupabaseConfigured()) {
|
||
console.error('❌ Supabase is not configured!');
|
||
console.error('Please set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY');
|
||
process.exit(1);
|
||
}
|
||
|
||
// Check if bucket exists
|
||
const supabase = createSupabaseAdminClient();
|
||
const { data: buckets, error: bucketsError } = await supabase.storage.listBuckets();
|
||
|
||
if (bucketsError) {
|
||
console.error('❌ Failed to list buckets:', bucketsError.message);
|
||
process.exit(1);
|
||
}
|
||
|
||
const galleryBucket = buckets?.find(b => b.name === GALLERY_BUCKET);
|
||
if (!galleryBucket) {
|
||
console.error(`❌ Bucket "${GALLERY_BUCKET}" does not exist!`);
|
||
console.error('Please create it via Supabase dashboard or run the migration SQL first.');
|
||
process.exit(1);
|
||
}
|
||
|
||
console.log(`✓ Bucket "${GALLERY_BUCKET}" exists\n`);
|
||
|
||
// Load gallery items
|
||
console.log('Loading gallery items...');
|
||
const items = await loadGalleryItems();
|
||
console.log(`Found ${items.length} gallery items\n`);
|
||
|
||
if (items.length === 0) {
|
||
console.log('No items to migrate.');
|
||
return;
|
||
}
|
||
|
||
// Migrate items
|
||
const stats = await migrateGalleryItems(items, dryRun, force);
|
||
|
||
// Save updated items
|
||
if (!dryRun && stats.migrated > 0) {
|
||
console.log('\nSaving updated gallery items...');
|
||
await saveGalleryItems(items);
|
||
console.log('✓ Gallery items saved\n');
|
||
}
|
||
|
||
// Print summary
|
||
console.log('\n=== Migration Summary ===');
|
||
console.log(`Migrated: ${stats.migrated}`);
|
||
console.log(`Failed: ${stats.failed}`);
|
||
console.log(`Skipped: ${stats.skipped}`);
|
||
console.log(`Total: ${items.length}`);
|
||
|
||
if (dryRun) {
|
||
console.log('\n⚠️ This was a DRY RUN. No files were actually migrated.');
|
||
console.log('Run without --dry-run to perform the actual migration.');
|
||
}
|
||
}
|
||
|
||
main().catch((error) => {
|
||
console.error('Migration failed:', error);
|
||
process.exit(1);
|
||
});
|