From 97c1540adde6899b8cee4ab769d1f00e0babde82 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 9 Mar 2026 17:28:06 +0000 Subject: [PATCH] feat: extract content providers from app/Plug/Content Devto, Hashnode, Medium, Wordpress providers with Core\Plug\Content namespace alignment. Co-Authored-By: Claude Opus 4.6 --- composer.json | 17 +++ src/Devto/Auth.php | 84 +++++++++++++ src/Devto/Delete.php | 46 +++++++ src/Devto/Post.php | 112 ++++++++++++++++++ src/Devto/Read.php | 163 +++++++++++++++++++++++++ src/Hashnode/Auth.php | 91 ++++++++++++++ src/Hashnode/Delete.php | 56 +++++++++ src/Hashnode/Post.php | 198 +++++++++++++++++++++++++++++++ src/Hashnode/Publications.php | 128 ++++++++++++++++++++ src/Hashnode/Read.php | 217 ++++++++++++++++++++++++++++++++++ src/Medium/Auth.php | 123 +++++++++++++++++++ src/Medium/Post.php | 126 ++++++++++++++++++++ src/Medium/Publications.php | 86 ++++++++++++++ src/Medium/Read.php | 59 +++++++++ src/Wordpress/Auth.php | 126 ++++++++++++++++++++ src/Wordpress/Delete.php | 94 +++++++++++++++ src/Wordpress/Media.php | 127 ++++++++++++++++++++ src/Wordpress/Post.php | 174 +++++++++++++++++++++++++++ src/Wordpress/Read.php | 209 ++++++++++++++++++++++++++++++++ 19 files changed, 2236 insertions(+) create mode 100644 composer.json create mode 100644 src/Devto/Auth.php create mode 100644 src/Devto/Delete.php create mode 100644 src/Devto/Post.php create mode 100644 src/Devto/Read.php create mode 100644 src/Hashnode/Auth.php create mode 100644 src/Hashnode/Delete.php create mode 100644 src/Hashnode/Post.php create mode 100644 src/Hashnode/Publications.php create mode 100644 src/Hashnode/Read.php create mode 100644 src/Medium/Auth.php create mode 100644 src/Medium/Post.php create mode 100644 src/Medium/Publications.php create mode 100644 src/Medium/Read.php create mode 100644 src/Wordpress/Auth.php create mode 100644 src/Wordpress/Delete.php create mode 100644 src/Wordpress/Media.php create mode 100644 src/Wordpress/Post.php create mode 100644 src/Wordpress/Read.php diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b6379a7 --- /dev/null +++ b/composer.json @@ -0,0 +1,17 @@ +{ + "name": "core/php-plug-content", + "description": "Content platform integrations for the Plug framework", + "type": "library", + "license": "EUPL-1.2", + "require": { + "php": "^8.2", + "core/php": "^1.0" + }, + "autoload": { + "psr-4": { + "Core\\Plug\\Content\\": "src/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/src/Devto/Auth.php b/src/Devto/Auth.php new file mode 100644 index 0000000..32f3157 --- /dev/null +++ b/src/Devto/Auth.php @@ -0,0 +1,84 @@ +apiKey = $apiKey; + } + + public static function identifier(): string + { + return 'devto'; + } + + public static function name(): string + { + return 'DEV Community'; + } + + /** + * API key generation URL. + */ + public function getAuthUrl(): string + { + return 'https://dev.to/settings/extensions'; + } + + /** + * Validate API key. + * + * @param array $params ['api_key' => string] + */ + public function requestAccessToken(array $params): array + { + $apiKey = $params['api_key'] ?? $this->apiKey; + + if (! $apiKey) { + return ['error' => 'API key is required']; + } + + // Validate by fetching user info + $response = $this->http() + ->withHeaders(['api-key' => $apiKey]) + ->get(self::API_URL.'/users/me'); + + if (! $response->successful()) { + return ['error' => 'Invalid API key']; + } + + $user = $response->json(); + + return [ + 'api_key' => $apiKey, + 'user_id' => $user['id'], + 'username' => $user['username'], + ]; + } + + public function getAccount(): Response + { + return $this->error('Use Read::me() with API key'); + } +} diff --git a/src/Devto/Delete.php b/src/Devto/Delete.php new file mode 100644 index 0000000..8530623 --- /dev/null +++ b/src/Devto/Delete.php @@ -0,0 +1,46 @@ +http() + ->withHeaders(['api-key' => $this->accessToken()]) + ->put(self::API_URL."/articles/{$id}", [ + 'article' => [ + 'published' => false, + ], + ]); + + return $this->fromHttp($response, fn () => [ + 'unpublished' => true, + 'note' => 'Article unpublished (Dev.to API does not support deletion)', + ]); + } +} diff --git a/src/Devto/Post.php b/src/Devto/Post.php new file mode 100644 index 0000000..18ff8ec --- /dev/null +++ b/src/Devto/Post.php @@ -0,0 +1,112 @@ +error('Title is required'); + } + + $articleData = [ + 'article' => array_filter([ + 'title' => $title, + 'body_markdown' => $text, + 'published' => $params['published'] ?? false, + 'tags' => isset($params['tags']) ? array_slice((array) $params['tags'], 0, 4) : null, // Max 4 tags + 'series' => $params['series'] ?? null, + 'canonical_url' => $params['canonical_url'] ?? null, + 'main_image' => $params['main_image'] ?? ($media->first()['url'] ?? null), + 'description' => $params['description'] ?? null, + 'organization_id' => $params['organization_id'] ?? null, + ]), + ]; + + $response = $this->http() + ->withHeaders(['api-key' => $this->accessToken()]) + ->post(self::API_URL.'/articles', $articleData); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'title' => $data['title'], + 'url' => $data['url'], + 'slug' => $data['slug'], + 'published' => $data['published'] ?? false, + 'published_at' => $data['published_at'] ?? null, + ]); + } + + /** + * Update an existing article. + */ + public function update(int $articleId, array $params): Response + { + $articleData = [ + 'article' => array_filter([ + 'title' => $params['title'] ?? null, + 'body_markdown' => $params['body_markdown'] ?? $params['content'] ?? null, + 'published' => $params['published'] ?? null, + 'tags' => isset($params['tags']) ? array_slice((array) $params['tags'], 0, 4) : null, + 'series' => $params['series'] ?? null, + 'canonical_url' => $params['canonical_url'] ?? null, + 'main_image' => $params['main_image'] ?? null, + ]), + ]; + + $response = $this->http() + ->withHeaders(['api-key' => $this->accessToken()]) + ->put(self::API_URL."/articles/{$articleId}", $articleData); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'title' => $data['title'], + 'url' => $data['url'], + 'published' => $data['published'] ?? false, + ]); + } + + /** + * Get external URL to an article. + */ + public static function externalPostUrl(string $username, string $slug): string + { + return "https://dev.to/{$username}/{$slug}"; + } + + /** + * Get external URL to a profile. + */ + public static function externalAccountUrl(string $username): string + { + return "https://dev.to/{$username}"; + } +} diff --git a/src/Devto/Read.php b/src/Devto/Read.php new file mode 100644 index 0000000..5b6fe7f --- /dev/null +++ b/src/Devto/Read.php @@ -0,0 +1,163 @@ +http() + ->withHeaders(['api-key' => $this->accessToken()]) + ->get(self::API_URL."/articles/{$id}"); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'title' => $data['title'], + 'description' => $data['description'], + 'body_markdown' => $data['body_markdown'] ?? null, + 'body_html' => $data['body_html'] ?? null, + 'url' => $data['url'], + 'slug' => $data['slug'], + 'tags' => $data['tags'] ?? [], + 'published' => $data['published'] ?? false, + 'published_at' => $data['published_at'] ?? null, + 'reading_time' => $data['reading_time_minutes'] ?? null, + 'reactions_count' => $data['public_reactions_count'] ?? 0, + 'comments_count' => $data['comments_count'] ?? 0, + ]); + } + + /** + * Get the authenticated user's profile. + */ + public function me(): Response + { + $response = $this->http() + ->withHeaders(['api-key' => $this->accessToken()]) + ->get(self::API_URL.'/users/me'); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'username' => $data['username'], + 'name' => $data['name'], + 'image' => $data['profile_image'], + 'bio' => $data['summary'] ?? null, + 'location' => $data['location'] ?? null, + 'website_url' => $data['website_url'] ?? null, + 'twitter_username' => $data['twitter_username'] ?? null, + 'github_username' => $data['github_username'] ?? null, + 'joined_at' => $data['joined_at'] ?? null, + ]); + } + + /** + * List user's articles. + */ + public function list(array $params = []): Response + { + $queryParams = [ + 'page' => $params['page'] ?? 1, + 'per_page' => $params['per_page'] ?? 30, + ]; + + // Can filter by username, tag, state, etc. + if (isset($params['username'])) { + $queryParams['username'] = $params['username']; + } + + if (isset($params['tag'])) { + $queryParams['tag'] = $params['tag']; + } + + if (isset($params['state'])) { + $queryParams['state'] = $params['state']; // fresh, rising, all + } + + $response = $this->http() + ->withHeaders(['api-key' => $this->accessToken()]) + ->get(self::API_URL.'/articles', $queryParams); + + return $this->fromHttp($response, fn ($data) => [ + 'articles' => array_map(fn ($article) => [ + 'id' => $article['id'], + 'title' => $article['title'], + 'description' => $article['description'], + 'url' => $article['url'], + 'tags' => $article['tag_list'] ?? [], + 'published_at' => $article['published_at'] ?? null, + 'reading_time' => $article['reading_time_minutes'] ?? null, + 'reactions_count' => $article['public_reactions_count'] ?? 0, + 'comments_count' => $article['comments_count'] ?? 0, + ], $data), + ]); + } + + /** + * List user's published articles. + */ + public function myArticles(array $params = []): Response + { + $queryParams = [ + 'page' => $params['page'] ?? 1, + 'per_page' => $params['per_page'] ?? 30, + ]; + + $response = $this->http() + ->withHeaders(['api-key' => $this->accessToken()]) + ->get(self::API_URL.'/articles/me', $queryParams); + + return $this->fromHttp($response, fn ($data) => [ + 'articles' => array_map(fn ($article) => [ + 'id' => $article['id'], + 'title' => $article['title'], + 'url' => $article['url'], + 'published' => $article['published'] ?? false, + 'published_at' => $article['published_at'] ?? null, + 'page_views_count' => $article['page_views_count'] ?? 0, + ], $data), + ]); + } + + /** + * List user's unpublished (draft) articles. + */ + public function myDrafts(array $params = []): Response + { + $queryParams = [ + 'page' => $params['page'] ?? 1, + 'per_page' => $params['per_page'] ?? 30, + ]; + + $response = $this->http() + ->withHeaders(['api-key' => $this->accessToken()]) + ->get(self::API_URL.'/articles/me/unpublished', $queryParams); + + return $this->fromHttp($response, fn ($data) => [ + 'articles' => array_map(fn ($article) => [ + 'id' => $article['id'], + 'title' => $article['title'], + 'body_markdown' => $article['body_markdown'] ?? null, + ], $data), + ]); + } +} diff --git a/src/Hashnode/Auth.php b/src/Hashnode/Auth.php new file mode 100644 index 0000000..0076b57 --- /dev/null +++ b/src/Hashnode/Auth.php @@ -0,0 +1,91 @@ +accessToken = $accessToken; + } + + public static function identifier(): string + { + return 'hashnode'; + } + + public static function name(): string + { + return 'Hashnode'; + } + + /** + * PAT generation URL. + */ + public function getAuthUrl(): string + { + return 'https://hashnode.com/settings/developer'; + } + + /** + * Validate access token. + * + * @param array $params ['access_token' => string] + */ + public function requestAccessToken(array $params): array + { + $token = $params['access_token'] ?? $this->accessToken; + + if (! $token) { + return ['error' => 'Access token is required']; + } + + // Validate by fetching user info + $response = $this->http() + ->withHeaders(['Authorization' => $token]) + ->post(self::API_URL, [ + 'query' => 'query { me { id username name } }', + ]); + + if (! $response->successful()) { + return ['error' => 'Invalid access token']; + } + + $data = $response->json('data.me'); + + if (! $data) { + return ['error' => 'Invalid access token']; + } + + return [ + 'access_token' => $token, + 'user_id' => $data['id'], + 'username' => $data['username'], + 'name' => $data['name'], + ]; + } + + public function getAccount(): Response + { + return $this->error('Use Read::me() with access token'); + } +} diff --git a/src/Hashnode/Delete.php b/src/Hashnode/Delete.php new file mode 100644 index 0000000..e31bebd --- /dev/null +++ b/src/Hashnode/Delete.php @@ -0,0 +1,56 @@ +http() + ->withHeaders(['Authorization' => $this->accessToken()]) + ->post(self::API_URL, [ + 'query' => $query, + 'variables' => [ + 'input' => ['id' => $id], + ], + ]); + + return $this->fromHttp($response, function ($data) { + if (isset($data['errors'])) { + return ['error' => $data['errors'][0]['message'] ?? 'Deletion failed']; + } + + return ['deleted' => true]; + }); + } +} diff --git a/src/Hashnode/Post.php b/src/Hashnode/Post.php new file mode 100644 index 0000000..c645b58 --- /dev/null +++ b/src/Hashnode/Post.php @@ -0,0 +1,198 @@ +publicationId = $publicationId; + + return $this; + } + + /** + * Publish an article. + * + * @param string $text Markdown content + * @param Collection $media Cover image (first item used) + * @param array $params title (required), subtitle, slug, tags, canonicalUrl, publishedAt + */ + public function publish(string $text, Collection $media, array $params = []): Response + { + $title = $params['title'] ?? null; + $publicationId = $params['publication_id'] ?? $this->publicationId; + + if (! $title) { + return $this->error('Title is required'); + } + + if (! $publicationId) { + return $this->error('Publication ID is required'); + } + + $input = [ + 'title' => $title, + 'contentMarkdown' => $text, + 'publicationId' => $publicationId, + ]; + + // Optional fields + if (isset($params['subtitle'])) { + $input['subtitle'] = $params['subtitle']; + } + + if (isset($params['slug'])) { + $input['slug'] = $params['slug']; + } + + if (isset($params['canonicalUrl'])) { + $input['originalArticleURL'] = $params['canonicalUrl']; + } + + if ($media->isNotEmpty()) { + $input['coverImageOptions'] = [ + 'coverImageURL' => $media->first()['url'] ?? null, + ]; + } + + if (isset($params['tags'])) { + $input['tags'] = array_map(fn ($tag) => [ + 'slug' => is_array($tag) ? $tag['slug'] : $tag, + 'name' => is_array($tag) ? ($tag['name'] ?? $tag['slug']) : $tag, + ], (array) $params['tags']); + } + + if (isset($params['publishedAt'])) { + $input['publishedAt'] = $params['publishedAt']; + } + + // Determine if draft or published + $mutation = isset($params['draft']) && $params['draft'] + ? 'createDraft' + : 'publishPost'; + + $query = <<<'GRAPHQL' + mutation PublishPost($input: PublishPostInput!) { + publishPost(input: $input) { + post { + id + title + slug + url + publication { + id + } + } + } + } + GRAPHQL; + + $response = $this->http() + ->withHeaders(['Authorization' => $this->accessToken()]) + ->post(self::API_URL, [ + 'query' => $query, + 'variables' => ['input' => $input], + ]); + + return $this->fromHttp($response, function ($data) { + $post = $data['data']['publishPost']['post'] ?? null; + + if (! $post) { + $errors = $data['errors'] ?? []; + + return ['error' => $errors[0]['message'] ?? 'Failed to publish']; + } + + return [ + 'id' => $post['id'], + 'title' => $post['title'], + 'slug' => $post['slug'], + 'url' => $post['url'], + ]; + }); + } + + /** + * Update an existing post. + */ + public function update(string $postId, array $params): Response + { + $input = array_filter([ + 'id' => $postId, + 'title' => $params['title'] ?? null, + 'contentMarkdown' => $params['content'] ?? $params['body'] ?? null, + 'subtitle' => $params['subtitle'] ?? null, + 'slug' => $params['slug'] ?? null, + ]); + + $query = <<<'GRAPHQL' + mutation UpdatePost($input: UpdatePostInput!) { + updatePost(input: $input) { + post { + id + title + slug + url + } + } + } + GRAPHQL; + + $response = $this->http() + ->withHeaders(['Authorization' => $this->accessToken()]) + ->post(self::API_URL, [ + 'query' => $query, + 'variables' => ['input' => $input], + ]); + + return $this->fromHttp($response, function ($data) { + $post = $data['data']['updatePost']['post'] ?? null; + + return $post ? [ + 'id' => $post['id'], + 'title' => $post['title'], + 'url' => $post['url'], + ] : ['error' => 'Failed to update']; + }); + } + + /** + * Get external URL to an article. + */ + public static function externalPostUrl(string $publicationHost, string $slug): string + { + return "https://{$publicationHost}/{$slug}"; + } + + /** + * Get external URL to a profile. + */ + public static function externalAccountUrl(string $username): string + { + return "https://hashnode.com/@{$username}"; + } +} diff --git a/src/Hashnode/Publications.php b/src/Hashnode/Publications.php new file mode 100644 index 0000000..653e485 --- /dev/null +++ b/src/Hashnode/Publications.php @@ -0,0 +1,128 @@ +http() + ->withHeaders(['Authorization' => $this->accessToken()]) + ->post(self::API_URL, ['query' => $query]); + + return $this->fromHttp($response, function ($data) { + $publications = $data['data']['me']['publications']['edges'] ?? []; + + return [ + 'publications' => array_map(fn ($edge) => [ + 'id' => $edge['node']['id'], + 'title' => $edge['node']['title'], + 'display_title' => $edge['node']['displayTitle'] ?? $edge['node']['title'], + 'url' => $edge['node']['url'], + 'about' => $edge['node']['about']['markdown'] ?? null, + 'favicon' => $edge['node']['favicon'] ?? null, + 'is_team' => $edge['node']['isTeam'] ?? false, + 'posts_count' => $edge['node']['postsCount'] ?? 0, + ], $publications), + ]; + }); + } + + /** + * Get publication by ID. + */ + public function get(string $publicationId): Response + { + $query = <<<'GRAPHQL' + query GetPublication($id: ObjectId!) { + publication(id: $id) { + id + title + displayTitle + url + about { + markdown + } + favicon + isTeam + postsCount + author { + id + username + name + } + } + } + GRAPHQL; + + $response = $this->http() + ->withHeaders(['Authorization' => $this->accessToken()]) + ->post(self::API_URL, [ + 'query' => $query, + 'variables' => ['id' => $publicationId], + ]); + + return $this->fromHttp($response, function ($data) { + $pub = $data['data']['publication'] ?? null; + + if (! $pub) { + return ['error' => 'Publication not found']; + } + + return [ + 'id' => $pub['id'], + 'title' => $pub['title'], + 'display_title' => $pub['displayTitle'] ?? $pub['title'], + 'url' => $pub['url'], + 'about' => $pub['about']['markdown'] ?? null, + 'favicon' => $pub['favicon'] ?? null, + 'is_team' => $pub['isTeam'] ?? false, + 'posts_count' => $pub['postsCount'] ?? 0, + 'author' => $pub['author'] ?? null, + ]; + }); + } +} diff --git a/src/Hashnode/Read.php b/src/Hashnode/Read.php new file mode 100644 index 0000000..734f4ac --- /dev/null +++ b/src/Hashnode/Read.php @@ -0,0 +1,217 @@ +http() + ->withHeaders(['Authorization' => $this->accessToken()]) + ->post(self::API_URL, [ + 'query' => $query, + 'variables' => ['id' => $id], + ]); + + return $this->fromHttp($response, function ($data) { + $post = $data['data']['post'] ?? null; + + if (! $post) { + return ['error' => 'Post not found']; + } + + return [ + 'id' => $post['id'], + 'title' => $post['title'], + 'slug' => $post['slug'], + 'url' => $post['url'], + 'content_markdown' => $post['content']['markdown'] ?? null, + 'content_html' => $post['content']['html'] ?? null, + 'brief' => $post['brief'] ?? null, + 'cover_image' => $post['coverImage']['url'] ?? null, + 'tags' => $post['tags'] ?? [], + 'published_at' => $post['publishedAt'] ?? null, + 'views' => $post['views'] ?? 0, + 'reactions' => $post['reactionCount'] ?? 0, + 'responses' => $post['responseCount'] ?? 0, + ]; + }); + } + + /** + * Get the authenticated user's profile. + */ + public function me(): Response + { + $query = <<<'GRAPHQL' + query { + me { + id + username + name + bio { + markdown + } + profilePicture + publications(first: 10) { + edges { + node { + id + title + url + } + } + } + followersCount + followingsCount + } + } + GRAPHQL; + + $response = $this->http() + ->withHeaders(['Authorization' => $this->accessToken()]) + ->post(self::API_URL, ['query' => $query]); + + return $this->fromHttp($response, function ($data) { + $user = $data['data']['me'] ?? null; + + if (! $user) { + return ['error' => 'Not authenticated']; + } + + return [ + 'id' => $user['id'], + 'username' => $user['username'], + 'name' => $user['name'], + 'image' => $user['profilePicture'] ?? null, + 'bio' => $user['bio']['markdown'] ?? null, + 'followers_count' => $user['followersCount'] ?? 0, + 'following_count' => $user['followingsCount'] ?? 0, + 'publications' => array_map(fn ($edge) => [ + 'id' => $edge['node']['id'], + 'title' => $edge['node']['title'], + 'url' => $edge['node']['url'], + ], $user['publications']['edges'] ?? []), + ]; + }); + } + + /** + * List posts from a publication. + */ + public function list(array $params = []): Response + { + $publicationId = $params['publication_id'] ?? null; + + if (! $publicationId) { + return $this->error('publication_id is required'); + } + + $query = <<<'GRAPHQL' + query GetPublicationPosts($publicationId: ObjectId!, $first: Int!, $after: String) { + publication(id: $publicationId) { + posts(first: $first, after: $after) { + edges { + node { + id + title + slug + url + brief + publishedAt + views + reactionCount + } + cursor + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + GRAPHQL; + + $response = $this->http() + ->withHeaders(['Authorization' => $this->accessToken()]) + ->post(self::API_URL, [ + 'query' => $query, + 'variables' => [ + 'publicationId' => $publicationId, + 'first' => $params['limit'] ?? 20, + 'after' => $params['cursor'] ?? null, + ], + ]); + + return $this->fromHttp($response, function ($data) { + $posts = $data['data']['publication']['posts'] ?? null; + + if (! $posts) { + return ['error' => 'Publication not found']; + } + + return [ + 'posts' => array_map(fn ($edge) => [ + 'id' => $edge['node']['id'], + 'title' => $edge['node']['title'], + 'slug' => $edge['node']['slug'], + 'url' => $edge['node']['url'], + 'brief' => $edge['node']['brief'], + 'published_at' => $edge['node']['publishedAt'], + 'views' => $edge['node']['views'] ?? 0, + 'reactions' => $edge['node']['reactionCount'] ?? 0, + ], $posts['edges'] ?? []), + 'has_next_page' => $posts['pageInfo']['hasNextPage'] ?? false, + 'cursor' => $posts['pageInfo']['endCursor'] ?? null, + ]; + }); + } +} diff --git a/src/Medium/Auth.php b/src/Medium/Auth.php new file mode 100644 index 0000000..a016571 --- /dev/null +++ b/src/Medium/Auth.php @@ -0,0 +1,123 @@ +clientId = $clientId; + $this->clientSecret = $clientSecret; + $this->redirectUri = $redirectUri; + } + + public static function identifier(): string + { + return 'medium'; + } + + public static function name(): string + { + return 'Medium'; + } + + public function getAuthUrl(): string + { + $state = bin2hex(random_bytes(16)); + + $params = http_build_query([ + 'client_id' => $this->clientId, + 'scope' => 'basicProfile,publishPost,listPublications', + 'state' => $state, + 'response_type' => 'code', + 'redirect_uri' => $this->redirectUri, + ]); + + return self::AUTH_URL.'?'.$params; + } + + public function requestAccessToken(array $params): array + { + $code = $params['code'] ?? null; + + if (! $code) { + return ['error' => 'Authorization code is required']; + } + + $response = $this->http()->post(self::TOKEN_URL, [ + 'code' => $code, + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'grant_type' => 'authorization_code', + 'redirect_uri' => $this->redirectUri, + ]); + + if (! $response->successful()) { + return [ + 'error' => $response->json('error') ?? 'Token exchange failed', + ]; + } + + $data = $response->json(); + + return [ + 'access_token' => $data['access_token'], + 'token_type' => $data['token_type'] ?? 'Bearer', + 'refresh_token' => $data['refresh_token'] ?? null, + 'scope' => $data['scope'] ?? [], + 'expires_at' => $data['expires_at'] ?? null, + ]; + } + + /** + * Refresh access token. + */ + public function refreshToken(string $refreshToken): array + { + $response = $this->http()->post(self::TOKEN_URL, [ + 'refresh_token' => $refreshToken, + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'grant_type' => 'refresh_token', + ]); + + if (! $response->successful()) { + return [ + 'error' => $response->json('error') ?? 'Refresh failed', + ]; + } + + return $response->json(); + } + + public function getAccount(): Response + { + return $this->error('Use Read::me() with access token'); + } +} diff --git a/src/Medium/Post.php b/src/Medium/Post.php new file mode 100644 index 0000000..f72567c --- /dev/null +++ b/src/Medium/Post.php @@ -0,0 +1,126 @@ +authorId = $authorId; + + return $this; + } + + /** + * Set publication ID for posting to a publication. + */ + public function forPublication(string $publicationId): self + { + $this->publicationId = $publicationId; + + return $this; + } + + /** + * Publish an article. + * + * @param string $text The HTML or Markdown content + * @param Collection $media Not used - Medium handles media within content + * @param array $params title (required), contentFormat (html/markdown), tags, canonicalUrl, publishStatus + */ + public function publish(string $text, Collection $media, array $params = []): Response + { + $title = $params['title'] ?? null; + + if (! $title) { + return $this->error('Title is required'); + } + + $postData = [ + 'title' => $title, + 'contentFormat' => $params['contentFormat'] ?? 'html', // html or markdown + 'content' => $text, + 'publishStatus' => $params['publishStatus'] ?? 'public', // public, draft, unlisted + ]; + + // Optional parameters + if (isset($params['tags'])) { + $postData['tags'] = array_slice((array) $params['tags'], 0, 5); // Max 5 tags + } + + if (isset($params['canonicalUrl'])) { + $postData['canonicalUrl'] = $params['canonicalUrl']; + } + + if (isset($params['license'])) { + $postData['license'] = $params['license']; // all-rights-reserved, cc-40-by, etc. + } + + if (isset($params['notifyFollowers'])) { + $postData['notifyFollowers'] = $params['notifyFollowers']; + } + + // Determine endpoint + $endpoint = $this->publicationId + ? self::API_URL."/publications/{$this->publicationId}/posts" + : self::API_URL."/users/{$this->authorId}/posts"; + + if (! $this->publicationId && ! $this->authorId) { + return $this->error('Author ID or Publication ID required'); + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->post($endpoint, $postData); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['data']['id'], + 'title' => $data['data']['title'], + 'url' => $data['data']['url'], + 'publishStatus' => $data['data']['publishStatus'], + 'publishedAt' => $data['data']['publishedAt'] ?? null, + 'authorId' => $data['data']['authorId'], + ]); + } + + /** + * Get external URL to an article. + */ + public static function externalPostUrl(string $url): string + { + return $url; + } + + /** + * Get external URL to a profile. + */ + public static function externalAccountUrl(string $username): string + { + return "https://medium.com/@{$username}"; + } +} diff --git a/src/Medium/Publications.php b/src/Medium/Publications.php new file mode 100644 index 0000000..76318c2 --- /dev/null +++ b/src/Medium/Publications.php @@ -0,0 +1,86 @@ +userId = $userId; + + return $this; + } + + /** + * List publications the user can write to. + */ + public function listEntities(): Response + { + if (! $this->userId) { + // Get user ID first + $meResponse = $this->http() + ->withToken($this->accessToken()) + ->get(self::API_URL.'/me'); + + if (! $meResponse->successful()) { + return $this->fromHttp($meResponse); + } + + $this->userId = $meResponse->json('data.id'); + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->get(self::API_URL."/users/{$this->userId}/publications"); + + return $this->fromHttp($response, fn ($data) => [ + 'publications' => array_map(fn ($pub) => [ + 'id' => $pub['id'], + 'name' => $pub['name'], + 'description' => $pub['description'] ?? null, + 'url' => $pub['url'], + 'imageUrl' => $pub['imageUrl'] ?? null, + ], $data['data'] ?? []), + ]); + } + + /** + * Get contributors to a publication. + */ + public function contributors(string $publicationId): Response + { + $response = $this->http() + ->withToken($this->accessToken()) + ->get(self::API_URL."/publications/{$publicationId}/contributors"); + + return $this->fromHttp($response, fn ($data) => [ + 'contributors' => array_map(fn ($contributor) => [ + 'userId' => $contributor['userId'], + 'publicationId' => $contributor['publicationId'], + 'role' => $contributor['role'], + ], $data['data'] ?? []), + ]); + } +} diff --git a/src/Medium/Read.php b/src/Medium/Read.php new file mode 100644 index 0000000..91860f9 --- /dev/null +++ b/src/Medium/Read.php @@ -0,0 +1,59 @@ +error('Medium API does not support retrieving individual posts'); + } + + /** + * Get the authenticated user's profile. + */ + public function me(): Response + { + $response = $this->http() + ->withToken($this->accessToken()) + ->get(self::API_URL.'/me'); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['data']['id'], + 'username' => $data['data']['username'], + 'name' => $data['data']['name'], + 'image' => $data['data']['imageUrl'] ?? null, + 'url' => $data['data']['url'], + ]); + } + + /** + * List not supported for Medium (no feed API). + */ + public function list(array $params = []): Response + { + return $this->error('Medium API does not support listing posts'); + } +} diff --git a/src/Wordpress/Auth.php b/src/Wordpress/Auth.php new file mode 100644 index 0000000..ca94abf --- /dev/null +++ b/src/Wordpress/Auth.php @@ -0,0 +1,126 @@ +siteUrl = rtrim($siteUrl, '/'); + $this->username = $username; + $this->applicationPassword = $applicationPassword; + } + + public static function identifier(): string + { + return 'wordpress'; + } + + public static function name(): string + { + return 'WordPress'; + } + + /** + * Set site URL. + */ + public function forSite(string $siteUrl): self + { + $this->siteUrl = rtrim($siteUrl, '/'); + + return $this; + } + + /** + * Application passwords setup URL. + */ + public function getAuthUrl(): string + { + return $this->siteUrl.'/wp-admin/profile.php#application-passwords'; + } + + /** + * Validate credentials. + * + * @param array $params ['site_url' => string, 'username' => string, 'application_password' => string] + */ + public function requestAccessToken(array $params): array + { + $siteUrl = rtrim($params['site_url'] ?? $this->siteUrl, '/'); + $username = $params['username'] ?? $this->username; + $password = $params['application_password'] ?? $this->applicationPassword; + + if (! $siteUrl) { + return ['error' => 'Site URL is required']; + } + + if (! $username || ! $password) { + return ['error' => 'Username and application password are required']; + } + + // Validate by fetching user info + $response = $this->http() + ->withBasicAuth($username, $password) + ->get($siteUrl.'/wp-json/wp/v2/users/me'); + + if (! $response->successful()) { + return ['error' => 'Invalid credentials or site configuration']; + } + + $user = $response->json(); + + return [ + 'site_url' => $siteUrl, + 'username' => $username, + 'application_password' => $password, + 'user_id' => $user['id'], + 'user_slug' => $user['slug'], + 'user_name' => $user['name'], + ]; + } + + public function getAccount(): Response + { + return $this->error('Use Read::me() with credentials'); + } + + /** + * Check if site has REST API enabled. + */ + public function checkSite(string $siteUrl): Response + { + $siteUrl = rtrim($siteUrl, '/'); + + $response = $this->http()->get($siteUrl.'/wp-json'); + + return $this->fromHttp($response, fn ($data) => [ + 'name' => $data['name'] ?? null, + 'description' => $data['description'] ?? null, + 'url' => $data['url'] ?? $siteUrl, + 'home' => $data['home'] ?? null, + 'namespaces' => $data['namespaces'] ?? [], + 'has_wp_v2' => in_array('wp/v2', $data['namespaces'] ?? []), + ]); + } +} diff --git a/src/Wordpress/Delete.php b/src/Wordpress/Delete.php new file mode 100644 index 0000000..c71b1a4 --- /dev/null +++ b/src/Wordpress/Delete.php @@ -0,0 +1,94 @@ +siteUrl = rtrim($siteUrl, '/'); + + return $this; + } + + /** + * Set basic auth credentials. + */ + public function withCredentials(string $username, string $password): self + { + $this->username = $username; + $this->applicationPassword = $password; + + return $this; + } + + /** + * Delete a post. + * + * @param string $id Post ID + */ + public function delete(string $id): Response + { + if (! $this->siteUrl) { + return $this->error('Site URL is required'); + } + + // force=true permanently deletes, otherwise moves to trash + $response = $this->http() + ->withBasicAuth($this->username, $this->applicationPassword) + ->delete($this->siteUrl."/wp-json/wp/v2/posts/{$id}", [ + 'force' => false, // Move to trash + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'deleted' => true, + 'id' => $data['id'], + 'status' => $data['status'], // Should be 'trash' + ]); + } + + /** + * Permanently delete a post. + */ + public function forceDelete(int $postId): Response + { + if (! $this->siteUrl) { + return $this->error('Site URL is required'); + } + + $response = $this->http() + ->withBasicAuth($this->username, $this->applicationPassword) + ->delete($this->siteUrl."/wp-json/wp/v2/posts/{$postId}", [ + 'force' => true, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'deleted' => $data['deleted'] ?? true, + 'previous' => $data['previous'] ?? null, + ]); + } +} diff --git a/src/Wordpress/Media.php b/src/Wordpress/Media.php new file mode 100644 index 0000000..7db2c22 --- /dev/null +++ b/src/Wordpress/Media.php @@ -0,0 +1,127 @@ +siteUrl = rtrim($siteUrl, '/'); + + return $this; + } + + /** + * Set basic auth credentials. + */ + public function withCredentials(string $username, string $password): self + { + $this->username = $username; + $this->applicationPassword = $password; + + return $this; + } + + public function upload(array $item): Response + { + if (! $this->siteUrl) { + return $this->error('Site URL is required'); + } + + $path = $item['path'] ?? null; + + if (! $path || ! file_exists($path)) { + return $this->error('File not found'); + } + + $filename = $item['name'] ?? basename($path); + $mimeType = mime_content_type($path); + + $response = $this->http() + ->withBasicAuth($this->username, $this->applicationPassword) + ->withHeaders([ + 'Content-Disposition' => "attachment; filename=\"{$filename}\"", + 'Content-Type' => $mimeType, + ]) + ->withBody(file_get_contents($path), $mimeType) + ->post($this->siteUrl.'/wp-json/wp/v2/media'); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'title' => $data['title']['rendered'] ?? $filename, + 'url' => $data['source_url'] ?? $data['guid']['rendered'], + 'link' => $data['link'], + 'mime_type' => $data['mime_type'], + 'media_details' => $data['media_details'] ?? null, + ]); + } + + /** + * Update media item metadata. + */ + public function update(int $mediaId, array $params): Response + { + if (! $this->siteUrl) { + return $this->error('Site URL is required'); + } + + $response = $this->http() + ->withBasicAuth($this->username, $this->applicationPassword) + ->post($this->siteUrl."/wp-json/wp/v2/media/{$mediaId}", array_filter([ + 'title' => $params['title'] ?? null, + 'caption' => $params['caption'] ?? null, + 'alt_text' => $params['alt_text'] ?? $params['alt'] ?? null, + 'description' => $params['description'] ?? null, + ])); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'title' => $data['title']['rendered'] ?? null, + 'url' => $data['source_url'] ?? null, + ]); + } + + /** + * Delete a media item. + */ + public function delete(int $mediaId, bool $force = false): Response + { + if (! $this->siteUrl) { + return $this->error('Site URL is required'); + } + + $response = $this->http() + ->withBasicAuth($this->username, $this->applicationPassword) + ->delete($this->siteUrl."/wp-json/wp/v2/media/{$mediaId}", [ + 'force' => $force, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'deleted' => $data['deleted'] ?? true, + ]); + } +} diff --git a/src/Wordpress/Post.php b/src/Wordpress/Post.php new file mode 100644 index 0000000..78fc375 --- /dev/null +++ b/src/Wordpress/Post.php @@ -0,0 +1,174 @@ +siteUrl = rtrim($siteUrl, '/'); + + return $this; + } + + /** + * Set basic auth credentials. + */ + public function withCredentials(string $username, string $password): self + { + $this->username = $username; + $this->applicationPassword = $password; + + return $this; + } + + /** + * Publish a post. + * + * @param string $text HTML content + * @param Collection $media Featured image (first item used) + * @param array $params title, status, excerpt, categories, tags, format, featured_media, slug + */ + public function publish(string $text, Collection $media, array $params = []): Response + { + if (! $this->siteUrl) { + return $this->error('Site URL is required'); + } + + $title = $params['title'] ?? null; + + if (! $title) { + return $this->error('Title is required'); + } + + $postData = [ + 'title' => $title, + 'content' => $text, + 'status' => $params['status'] ?? 'publish', // publish, draft, pending, private + ]; + + // Optional parameters + if (isset($params['excerpt'])) { + $postData['excerpt'] = $params['excerpt']; + } + + if (isset($params['slug'])) { + $postData['slug'] = $params['slug']; + } + + if (isset($params['categories'])) { + $postData['categories'] = (array) $params['categories']; + } + + if (isset($params['tags'])) { + $postData['tags'] = (array) $params['tags']; + } + + if (isset($params['format'])) { + $postData['format'] = $params['format']; // standard, aside, gallery, link, etc. + } + + if (isset($params['featured_media'])) { + $postData['featured_media'] = $params['featured_media']; + } + + // Handle media upload for featured image + if ($media->isNotEmpty() && ! isset($params['featured_media'])) { + $mediaUploader = (new Media) + ->forSite($this->siteUrl) + ->withCredentials($this->username, $this->applicationPassword); + + $uploadResult = $mediaUploader->upload($media->first()); + + if ($uploadResult->isOk()) { + $postData['featured_media'] = $uploadResult->get('id'); + } + } + + $response = $this->http() + ->withBasicAuth($this->username, $this->applicationPassword) + ->post($this->siteUrl.'/wp-json/wp/v2/posts', $postData); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'title' => $data['title']['rendered'] ?? $title, + 'slug' => $data['slug'], + 'url' => $data['link'], + 'status' => $data['status'], + 'guid' => $data['guid']['rendered'] ?? null, + ]); + } + + /** + * Update an existing post. + */ + public function update(int $postId, array $params): Response + { + if (! $this->siteUrl) { + return $this->error('Site URL is required'); + } + + $postData = array_filter([ + 'title' => $params['title'] ?? null, + 'content' => $params['content'] ?? null, + 'excerpt' => $params['excerpt'] ?? null, + 'status' => $params['status'] ?? null, + 'slug' => $params['slug'] ?? null, + 'categories' => $params['categories'] ?? null, + 'tags' => $params['tags'] ?? null, + 'featured_media' => $params['featured_media'] ?? null, + ]); + + $response = $this->http() + ->withBasicAuth($this->username, $this->applicationPassword) + ->post($this->siteUrl."/wp-json/wp/v2/posts/{$postId}", $postData); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'title' => $data['title']['rendered'] ?? null, + 'url' => $data['link'], + 'status' => $data['status'], + ]); + } + + /** + * Get external URL to a post. + */ + public static function externalPostUrl(string $siteUrl, string $slug): string + { + return rtrim($siteUrl, '/').'/'.$slug; + } + + /** + * Get external URL to site. + */ + public static function externalAccountUrl(string $siteUrl): string + { + return $siteUrl; + } +} diff --git a/src/Wordpress/Read.php b/src/Wordpress/Read.php new file mode 100644 index 0000000..3c0b776 --- /dev/null +++ b/src/Wordpress/Read.php @@ -0,0 +1,209 @@ +siteUrl = rtrim($siteUrl, '/'); + + return $this; + } + + /** + * Set basic auth credentials. + */ + public function withCredentials(string $username, string $password): self + { + $this->username = $username; + $this->applicationPassword = $password; + + return $this; + } + + /** + * Get a post by ID. + */ + public function get(string $id): Response + { + if (! $this->siteUrl) { + return $this->error('Site URL is required'); + } + + $response = $this->http() + ->withBasicAuth($this->username, $this->applicationPassword) + ->get($this->siteUrl."/wp-json/wp/v2/posts/{$id}"); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'title' => $data['title']['rendered'] ?? '', + 'content' => $data['content']['rendered'] ?? '', + 'excerpt' => $data['excerpt']['rendered'] ?? '', + 'slug' => $data['slug'], + 'url' => $data['link'], + 'status' => $data['status'], + 'date' => $data['date'], + 'modified' => $data['modified'], + 'author' => $data['author'], + 'categories' => $data['categories'] ?? [], + 'tags' => $data['tags'] ?? [], + 'featured_media' => $data['featured_media'] ?? null, + ]); + } + + /** + * Get the authenticated user's profile. + */ + public function me(): Response + { + if (! $this->siteUrl) { + return $this->error('Site URL is required'); + } + + $response = $this->http() + ->withBasicAuth($this->username, $this->applicationPassword) + ->get($this->siteUrl.'/wp-json/wp/v2/users/me'); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'username' => $data['slug'], + 'name' => $data['name'], + 'image' => $data['avatar_urls']['96'] ?? $data['avatar_urls']['48'] ?? null, + 'description' => $data['description'] ?? null, + 'url' => $data['url'] ?? null, + 'link' => $data['link'] ?? null, + 'roles' => $data['roles'] ?? [], + ]); + } + + /** + * List posts. + */ + public function list(array $params = []): Response + { + if (! $this->siteUrl) { + return $this->error('Site URL is required'); + } + + $queryParams = [ + 'per_page' => $params['per_page'] ?? 10, + 'page' => $params['page'] ?? 1, + 'status' => $params['status'] ?? 'publish', + 'orderby' => $params['orderby'] ?? 'date', + 'order' => $params['order'] ?? 'desc', + ]; + + if (isset($params['author'])) { + $queryParams['author'] = $params['author']; + } + + if (isset($params['categories'])) { + $queryParams['categories'] = is_array($params['categories']) + ? implode(',', $params['categories']) + : $params['categories']; + } + + if (isset($params['tags'])) { + $queryParams['tags'] = is_array($params['tags']) + ? implode(',', $params['tags']) + : $params['tags']; + } + + if (isset($params['search'])) { + $queryParams['search'] = $params['search']; + } + + $response = $this->http() + ->withBasicAuth($this->username, $this->applicationPassword) + ->get($this->siteUrl.'/wp-json/wp/v2/posts', $queryParams); + + return $this->fromHttp($response, fn ($data) => [ + 'posts' => array_map(fn ($post) => [ + 'id' => $post['id'], + 'title' => $post['title']['rendered'] ?? '', + 'excerpt' => strip_tags($post['excerpt']['rendered'] ?? ''), + 'slug' => $post['slug'], + 'url' => $post['link'], + 'status' => $post['status'], + 'date' => $post['date'], + ], $data), + 'total' => (int) ($response->header('X-WP-Total') ?? count($data)), + 'total_pages' => (int) ($response->header('X-WP-TotalPages') ?? 1), + ]); + } + + /** + * Get categories. + */ + public function categories(array $params = []): Response + { + if (! $this->siteUrl) { + return $this->error('Site URL is required'); + } + + $response = $this->http() + ->withBasicAuth($this->username, $this->applicationPassword) + ->get($this->siteUrl.'/wp-json/wp/v2/categories', [ + 'per_page' => $params['per_page'] ?? 100, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'categories' => array_map(fn ($cat) => [ + 'id' => $cat['id'], + 'name' => $cat['name'], + 'slug' => $cat['slug'], + 'count' => $cat['count'], + ], $data), + ]); + } + + /** + * Get tags. + */ + public function tags(array $params = []): Response + { + if (! $this->siteUrl) { + return $this->error('Site URL is required'); + } + + $response = $this->http() + ->withBasicAuth($this->username, $this->applicationPassword) + ->get($this->siteUrl.'/wp-json/wp/v2/tags', [ + 'per_page' => $params['per_page'] ?? 100, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'tags' => array_map(fn ($tag) => [ + 'id' => $tag['id'], + 'name' => $tag['name'], + 'slug' => $tag['slug'], + 'count' => $tag['count'], + ], $data), + ]); + } +}