diff --git a/app/Console/Commands/MigrateMedia.php b/app/Console/Commands/MigrateMedia.php new file mode 100644 index 00000000..987da9af --- /dev/null +++ b/app/Console/Commands/MigrateMedia.php @@ -0,0 +1,92 @@ +hasTable('media_note')) { + $this->error('The table "media_note" does not exist.'); + + exit(1); + } + + // Load all media already saved in `media_endpoint` table + $this->line('Updating existing local media'); + $mediaEndpointMedia = Media::all(); + // Save relationship in new `media_note` table based on `media_endpoint.note_id` + $this->withProgressBar($mediaEndpointMedia, function (Media $mediaEndpointMediaItem) { + $note = Note::find($mediaEndpointMediaItem->note_id); + if ($note) { + $note->media()->attach($mediaEndpointMediaItem->id); + } + }); + + // Load all media records from `media` table + $this->line(''); + $this->line('Migrating old media from S3'); + $oldMedia = DB::table('media')->get(); + foreach ($oldMedia as $oldMediaItem) { + // We only want to process the S3 media + if ($oldMediaItem->disk !== 's3') { + $this->warn('Original media item never stored in S3'); + + continue; + } + + // Check media exists in S3 + if (! Storage::disk('s3')->exists($oldMediaItem->id.DIRECTORY_SEPARATOR.$oldMediaItem->file_name)) { + $this->warn('Original media item not found in S3'); + + continue; + } + // We want to just copy the file, check it does not already exist locally + if (Storage::disk('public')->exists('media'.DIRECTORY_SEPARATOR.$oldMediaItem->file_name)) { + $this->warn('File already exists locally with filename of original media item'); + + continue; + } + // Copy the file + Storage::disk('public')->writeStream('media'.DIRECTORY_SEPARATOR.$oldMediaItem->file_name, Storage::disk('s3')->readStream($oldMediaItem->id.DIRECTORY_SEPARATOR.$oldMediaItem->file_name)); + + // Save relationship based on `media.model_id` + // I have already checked they are all notes + $note = $oldMediaItem->model_id; + $newMediaItem = Media::create([ + 'path' => 'media'.DIRECTORY_SEPARATOR.$oldMediaItem->file_name, + 'type' => 'image', + ]); + $note->media()->attach($newMediaItem->id); + + $this->info('Media item migrated from S3'); + } + + $this->line(''); + $this->line('Migration finished'); + } +} diff --git a/app/Models/Media.php b/app/Models/Media.php index 3d923bed..b25e889a 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -7,7 +7,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Support\Str; class Media extends Model @@ -20,9 +20,11 @@ class Media extends Model /** @var array */ protected $fillable = ['token', 'path', 'type', 'image_widths']; - public function note(): BelongsTo + public function notes(): BelongsToMany { - return $this->belongsTo(Note::class); + return $this->belongsToMany(Note::class) + ->withPivot('alt_text', 'order') + ->withTimestamps(); } protected function url(): Attribute diff --git a/app/Models/Note.php b/app/Models/Note.php index 74533443..91f4b8a1 100644 --- a/app/Models/Note.php +++ b/app/Models/Note.php @@ -14,7 +14,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; -use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Facades\Cache; @@ -92,9 +91,12 @@ class Note extends Model return $this->belongsTo(Place::class); } - public function media(): HasMany + public function media(): BelongsToMany { - return $this->hasMany(Media::class); + return $this->BelongsToMany(Media::class) + ->withPivot('alt_text', 'order') + ->withTimestamps() + ->orderBy('order'); } /** diff --git a/app/Services/NoteService.php b/app/Services/NoteService.php index c80a450c..096f7f25 100644 --- a/app/Services/NoteService.php +++ b/app/Services/NoteService.php @@ -51,8 +51,11 @@ class NoteService // $note->instagram_url = $this->getInstagramUrl($request); - foreach ($this->getMedia($data) as $media) { - $note->media()->save($media); + foreach ($this->getMedia($data) as $index => $media) { + $note->media()->attach($media['value']->id, [ + 'alt_text' => $media['alt_text'], + 'order' => $index, + ]); } $note->save(); @@ -195,16 +198,37 @@ class NoteService if (isset($photos)) { foreach ((array) $photos as $photo) { + // $photo can be a string of the URL opf the photo + // or it can be an object with a `value` and `alt` + $photoUrl = null; + $photoAlt = null; + if (is_string($photo)) { + $photoUrl = $photo; + } elseif (is_array($photo)) { + $photoUrl = $photo['value']; + $photoAlt = $photo['alt']; + } + + if (empty($photoUrl)) { + continue; + } + // check the media was uploaded to my endpoint, and use path - if (Str::startsWith($photo, config('filesystems.disks.public.url'))) { - $path = substr($photo, strlen(config('filesystems.disks.public.url'))); - $media[] = Media::where('path', ltrim($path, '/'))->firstOrFail(); + if (Str::startsWith($photoUrl, config('filesystems.disks.public.url'))) { + $path = substr($photoUrl, strlen(config('filesystems.disks.public.url'))); + $media[] = [ + 'value' => Media::where('path', ltrim($path, '/'))->firstOrFail(), + 'alt' => $photoAlt, + ]; } else { - $newMedia = Media::firstOrNew(['path' => $photo]); + $newMedia = Media::firstOrNew(['path' => $photoUrl]); // currently assuming this is a photo from Swarm or OwnYourGram $newMedia->type = 'image'; $newMedia->save(); - $media[] = $newMedia; + $media[] = [ + 'value' => $newMedia, + 'alt' => $photoAlt, + ]; } } } diff --git a/database/migrations/2025_12_31_110617_create_media_note_pivot_table.php b/database/migrations/2025_12_31_110617_create_media_note_pivot_table.php new file mode 100644 index 00000000..4017aefb --- /dev/null +++ b/database/migrations/2025_12_31_110617_create_media_note_pivot_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('note_id')->constrained()->onDelete('cascade'); + $table->foreignId('media_id')->constrained('media_endpoint')->onDelete('cascade'); + $table->text('alt_text')->nullable(); + $table->integer('order')->default(0); + $table->timestamps(); + + $table->unique(['note_id', 'media_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('media_note'); + } +}; diff --git a/database/seeders/NotesTableSeeder.php b/database/seeders/NotesTableSeeder.php index 630cfda6..ea00c7d5 100644 --- a/database/seeders/NotesTableSeeder.php +++ b/database/seeders/NotesTableSeeder.php @@ -84,7 +84,9 @@ class NotesTableSeeder extends Seeder // copy aaron’s profile pic in place $spl = new SplFileInfo(public_path() . '/assets/profile-images/aaronparecki.com'); if ($spl->isDir() === false) { - mkdir(public_path() . '/assets/profile-images/aaronparecki.com', 0755); + if (! mkdir($concurrentDirectory = public_path() . '/assets/profile-images/aaronparecki.com', 0755) && ! is_dir($concurrentDirectory)) { + throw new \RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory)); + } copy(base_path() . '/tests/aaron.png', public_path() . '/assets/profile-images/aaronparecki.com/image'); } @@ -171,7 +173,9 @@ class NotesTableSeeder extends Seeder $noteWithOnlyImage->setCreatedAt($now); $noteWithOnlyImage->setUpdatedAt($now); $noteWithOnlyImage->save(); - $noteWithOnlyImage->media()->save($media); + $noteWithOnlyImage->media()->attach($media->id, [ + 'alt_text' => 'Test alt text', + ]); DB::table('notes') ->where('id', $noteWithOnlyImage->id) ->update(['updated_at' => $now->toDateTimeString()]); diff --git a/resources/views/templates/note.blade.php b/resources/views/templates/note.blade.php index 68c9d2f6..e8418184 100644 --- a/resources/views/templates/note.blade.php +++ b/resources/views/templates/note.blade.php @@ -12,7 +12,7 @@ @foreach($note->media as $media) @if($media->type === 'image') - image_widths !== null) srcset="{{ $media->url }} {{ $media->image_widths }}w, {{ $media->mediumurl }} 1000w, {{ $media->smallurl }} 500w" sizes="80vh"@endif> + {{ $media->pivot->alt_text }}image_widths !== null) srcset="{{ $media->url }} {{ $media->image_widths }}w, {{ $media->mediumurl }} 1000w, {{ $media->smallurl }} 500w" sizes="min(80ch,80vw)" @else src="{{ $media->url }}" @endif> @endif @if($media->type === 'audio') diff --git a/tests/Unit/MediaTest.php b/tests/Unit/MediaTest.php index 642d797f..675f761d 100644 --- a/tests/Unit/MediaTest.php +++ b/tests/Unit/MediaTest.php @@ -15,11 +15,18 @@ class MediaTest extends TestCase use RefreshDatabase; #[Test] - public function get_the_note_that_media_instance_belongs_to(): void + public function media_can_belong_to_multiple_notes(): void { - $media = Media::factory()->for(Note::factory())->create(); + $media = Media::factory()->create(); + $note1 = Note::factory()->create(); + $note2 = Note::factory()->create(); - $this->assertInstanceOf(Note::class, $media->note); + $note1->media()->attach($media->id, ['alt_text' => 'Alt text for note 1']); + $note2->media()->attach($media->id, ['alt_text' => 'Alt text for note 2']); + + $this->assertCount(2, $media->notes); + $this->assertTrue($media->notes->contains($note1)); + $this->assertTrue($media->notes->contains($note2)); } #[Test] diff --git a/tests/Unit/NotesTest.php b/tests/Unit/NotesTest.php index e71f513a..330d93fe 100644 --- a/tests/Unit/NotesTest.php +++ b/tests/Unit/NotesTest.php @@ -434,4 +434,21 @@ class NotesTest extends TestCase $this->assertSame($expected, $note->note); } + + #[Test] + public function note_can_have_media_with_alt_text(): void + { + $note = Note::factory()->create(); + $media = Media::factory()->create(); + + $note->media()->attach($media->id, [ + 'alt_text' => 'Test alt text', + 'order' => 0, + ]); + + $note->refresh(); + + $this->assertCount(1, $note->media); + $this->assertEquals('Test alt text', $note->media->first()->pivot->alt_text); + } }