diff --git a/AI_API.md b/AI_API.md index 1f09335..d5e9295 100644 --- a/AI_API.md +++ b/AI_API.md @@ -37,8 +37,9 @@ SocialAccount (login for a social network) Post (one piece of content, belongs to a Site, optionally a SocialAccount) └─ type: same vocabulary as SocialAccount.network, plus "blog" └─ target: nullable, network-specific destination (e.g. a subreddit for type=reddit) - └─ image_url: nullable URL. Required to publish type=blog (WordPress) — - used as the featured image. Optional for every other type. + └─ image_url: nullable URL, optional for every type. If set on a + type=blog post, WordPressPublisher uploads it and uses it as the + featured image — but it's fine to publish a blog post without one. └─ status: draft -> scheduled -> publishing -> published | failed └─ published_url: set once it's actually live somewhere └─ publish_error: set to the failure reason when status=failed (or when a @@ -157,9 +158,9 @@ must exist if given. `type` required, one of `twitter`, `instagram`, `facebook`, `reddit`, `tiktok`, `linkedin`, `youtube`, `blog`, `other`. `body` required. `title` nullable. `target` nullable — a network-specific destination, e.g. the subreddit name for `type=reddit`. `image_url` nullable -URL — not enforced at save time (you can draft a `type=blog` post without one), -but `WordPressPublisher` will reject publishing it until it's set. `status` -defaults to `draft` if omitted. `scheduled_at` nullable datetime. +URL, optional for every type — if set on a `type=blog` post it's uploaded to +WordPress as the featured image, but publishing works fine without one. +`status` defaults to `draft` if omitted. `scheduled_at` nullable datetime. Note: `type` and the linked Social Account's `network` are **not** cross-validated — you can link a `type=blog` post to a Twitter account, or @@ -217,18 +218,20 @@ php artisan posts:publish {post_id} What it does: 1. Loads the Post and its Site. 2. Validates: `post.type` must be `blog`, `post.title` must be set, - `post.image_url` must be set, `site.cms_type` must be `wordpress`, and - the site must have `wp_username`/`wp_app_password` set. Any failure here - exits 1 with a descriptive message and **does not touch** `Post.status`. -3. Downloads `post.image_url`, then POSTs it to + `site.cms_type` must be `wordpress`, and the site must have + `wp_username`/`wp_app_password` set. Any failure here exits 1 with a + descriptive message and **does not touch** `Post.status`. +3. If `post.image_url` is set: downloads it, then POSTs it to `{site.url}/wp-json/wp/v2/media` (HTTP Basic Auth with the site's WordPress Application Password) to upload it as media and get back a - media id. + media id. Skipped entirely if there's no image — that's fine, the post + just won't have a featured image. 4. POSTs to `{site.url}/wp-json/wp/v2/posts` with `status: publish` (goes - live immediately, not a WP draft) and `featured_media` set to that id. + live immediately, not a WP draft), and `featured_media` set to the + uploaded media id if there was one. 5. On success: sets `Post.status = published`, `Post.published_url` to the new post's permalink, prints `{"success": true, "url": "..."}`, exits 0. -6. On failure at either HTTP step (bad credentials, unreachable site, +6. On failure at any HTTP step (bad credentials, unreachable site, unreachable/rejected image, WordPress error): sets `Post.status = failed`, prints `{"success": false, "error": "..."}`, exits 1. diff --git a/README.md b/README.md index 2ad53e7..1b64abe 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,10 @@ Built with Laravel 13, Livewire, and Tailwind CSS. (e.g. Reddit's browser automation) don't block the page. - Built-in publishers (`app/Support/Publishing/`), one per network, resolved by `Post.type`: - - **WordPress**: posts directly to a site's WordPress REST API, uploading - `Post.image_url` as the featured image first — required to publish a - WordPress post, optional for every other post type. Also available - synchronously from the CLI: `php artisan posts:publish {post_id}`. + - **WordPress**: posts directly to a site's WordPress REST API. If + `Post.image_url` is set (optional, for every post type), it's uploaded + and used as the featured image. Also available synchronously from the + CLI: `php artisan posts:publish {post_id}`. - **Reddit**: drives a standalone Playwright sidecar at `sidecars/reddit-post/` that logs in and creates a post. - Anything else: no publisher yet — publish however fits, then report diff --git a/app/Support/Publishing/WordPressPublisher.php b/app/Support/Publishing/WordPressPublisher.php index 4f919a7..c8542b6 100644 --- a/app/Support/Publishing/WordPressPublisher.php +++ b/app/Support/Publishing/WordPressPublisher.php @@ -20,10 +20,6 @@ class WordPressPublisher implements Publisher return PublishResult::rejected("Post {$post->id} has no title, required to publish to WordPress."); } - if (! $post->image_url) { - return PublishResult::rejected("Post {$post->id} has no image, required to publish to WordPress."); - } - $site = $post->site; if ($site->cms_type !== 'wordpress') { @@ -34,20 +30,25 @@ class WordPressPublisher implements Publisher return PublishResult::rejected("Site '{$site->name}' is missing WordPress credentials."); } - $mediaId = $this->uploadFeaturedImage($post, $site); + $payload = [ + 'title' => $post->title, + 'content' => $post->body, + 'status' => 'publish', + ]; - if ($mediaId instanceof PublishResult) { - return $mediaId; + if ($post->image_url) { + $mediaId = $this->uploadFeaturedImage($post, $site); + + if ($mediaId instanceof PublishResult) { + return $mediaId; + } + + $payload['featured_media'] = $mediaId; } try { $response = Http::withBasicAuth($site->wp_username, $site->wp_app_password) - ->post(rtrim($site->url, '/').'/wp-json/wp/v2/posts', [ - 'title' => $post->title, - 'content' => $post->body, - 'status' => 'publish', - 'featured_media' => $mediaId, - ]); + ->post(rtrim($site->url, '/').'/wp-json/wp/v2/posts', $payload); } catch (ConnectionException $e) { return PublishResult::failed("could not connect to site: {$e->getMessage()}"); } diff --git a/resources/views/livewire/posts/index.blade.php b/resources/views/livewire/posts/index.blade.php index 9c13f56..6adfa75 100644 --- a/resources/views/livewire/posts/index.blade.php +++ b/resources/views/livewire/posts/index.blade.php @@ -27,8 +27,6 @@ @if ($post->image_url) - @elseif ($post->type === 'blog') - missing @else — @endif @@ -118,7 +116,7 @@
@error('imageUrl') {{ $message }} @enderror diff --git a/tests/Feature/PublishPostCommandTest.php b/tests/Feature/PublishPostCommandTest.php index 75a4dce..2212b30 100644 --- a/tests/Feature/PublishPostCommandTest.php +++ b/tests/Feature/PublishPostCommandTest.php @@ -29,20 +29,9 @@ test('it fails when the post has no title', function () { ->assertExitCode(1); }); -test('it fails when the post has no image', function () { - $site = Site::factory()->wordpress()->create(); - $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'image_url' => null]); - - $this->artisan('posts:publish', ['post' => $post->id]) - ->expectsOutputToContain('no image') - ->assertExitCode(1); - - expect($post->fresh()->status)->toBe($post->status); -}); - test('it fails when the site is not configured as WordPress', function () { $site = Site::factory()->create(['cms_type' => 'custom']); - $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'image_url' => 'https://example.com/image.jpg']); + $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi']); $this->artisan('posts:publish', ['post' => $post->id]) ->expectsOutputToContain('not configured as WordPress') @@ -51,13 +40,47 @@ test('it fails when the site is not configured as WordPress', function () { test('it fails when the WordPress site is missing credentials', function () { $site = Site::factory()->create(['cms_type' => 'wordpress', 'wp_username' => null, 'wp_app_password' => null]); - $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'image_url' => 'https://example.com/image.jpg']); + $post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi']); $this->artisan('posts:publish', ['post' => $post->id]) ->expectsOutputToContain('missing WordPress credentials') ->assertExitCode(1); }); +test('it publishes successfully without an image', function () { + Http::fake([ + '*/wp-json/wp/v2/posts' => Http::response([ + 'id' => 42, + 'link' => 'https://wp.example.com/2026/07/hello-world/', + ], 201), + ]); + + $site = Site::factory()->wordpress()->create(['url' => 'https://wp.example.com']); + $post = Post::factory()->create([ + 'site_id' => $site->id, + 'type' => 'blog', + 'title' => 'Hello World', + 'body' => 'Post content', + 'image_url' => null, + 'status' => 'draft', + ]); + + $this->artisan('posts:publish', ['post' => $post->id]) + ->expectsOutputToContain('"success":true') + ->assertExitCode(0); + + expect($post->fresh()) + ->status->toBe('published') + ->published_url->toBe('https://wp.example.com/2026/07/hello-world/'); + + Http::assertNotSent(fn ($request) => $request->url() === 'https://wp.example.com/wp-json/wp/v2/media'); + + Http::assertSent(function ($request) { + return $request->url() === 'https://wp.example.com/wp-json/wp/v2/posts' + && ! array_key_exists('featured_media', $request->data()); + }); +}); + test('it publishes successfully, uploading the image as the featured media', function () { Http::fake([ 'https://images.example.com/*' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),