diff --git a/includes/Admin.php b/includes/Admin.php index e76e1c0..4685ddc 100644 --- a/includes/Admin.php +++ b/includes/Admin.php @@ -104,6 +104,43 @@ public function register_settings(): void { ], ); + register_setting( + 'autoblue', + 'autoblue_publish_documents_enabled', + [ + 'type' => 'boolean', + 'description' => __( 'Whether to publish posts as standard.site documents.', 'autoblue' ), + 'show_in_rest' => true, + 'default' => false, + 'sanitize_callback' => 'rest_sanitize_boolean', + ], + ); + + register_setting( + 'autoblue', + 'autoblue_publication_overrides', + [ + 'type' => 'object', + 'description' => __( 'Per-field overrides for the standard.site publication record.', 'autoblue' ), + 'show_in_rest' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'name' => [ 'type' => [ 'string', 'null' ] ], + 'description' => [ 'type' => [ 'string', 'null' ] ], + 'icon_id' => [ 'type' => [ 'integer', 'null' ] ], + 'theme_background' => [ 'type' => [ 'string', 'null' ] ], + 'theme_foreground' => [ 'type' => [ 'string', 'null' ] ], + 'theme_accent' => [ 'type' => [ 'string', 'null' ] ], + 'theme_accent_foreground' => [ 'type' => [ 'string', 'null' ] ], + ], + 'additionalProperties' => false, + ], + ], + 'default' => [], + ], + ); + register_setting( 'autoblue', 'autoblue_log_level', diff --git a/includes/Assets.php b/includes/Assets.php index 403b307..ad91a1f 100644 --- a/includes/Assets.php +++ b/includes/Assets.php @@ -78,6 +78,7 @@ public function register_admin_assets( string $hook_suffix ): void { return; } + wp_enqueue_media(); wp_enqueue_style( 'wp-components' ); wp_enqueue_style( 'dataviews' ); $this->enqueue_script( @@ -108,6 +109,12 @@ private function get_initial_state(): array { ], ]; + $publication_record = get_option( 'autoblue_publication_record', [] ); + $overrides = get_option( 'autoblue_publication_overrides', [] ); + $override_icon_id = is_array( $overrides ) && ! empty( $overrides['icon_id'] ) ? (int) $overrides['icon_id'] : 0; + $override_icon_url = $override_icon_id > 0 ? (string) wp_get_attachment_image_url( $override_icon_id, 'thumbnail' ) : ''; + $site_icon_url = (string) get_site_icon_url( 128 ); + return [ 'accounts' => [ 'items' => $connections, @@ -115,6 +122,17 @@ private function get_initial_state(): array { 'settings' => [ 'enabled' => get_option( 'autoblue_enabled', false ), 'availablePostTypes' => Utils::get_available_post_types(), + 'standardSite' => [ + 'isRootInstall' => Utils::is_root_install(), + 'publication' => [ + 'url' => untrailingslashit( home_url( '/' ) ), + 'siteName' => get_bloginfo( 'name' ), + 'siteDesc' => get_bloginfo( 'description' ), + 'siteIconUrl' => $site_icon_url, + 'iconOverrideUrl' => $override_icon_url, + 'publishedUri' => isset( $publication_record['uri'] ) ? (string) $publication_record['uri'] : null, + ], + ], ], 'logs' => [ 'items' => $logs['data'], diff --git a/includes/Bluesky.php b/includes/Bluesky.php index 198f752..9f4308f 100644 --- a/includes/Bluesky.php +++ b/includes/Bluesky.php @@ -180,7 +180,17 @@ public function share_to_bluesky( int $post_id ) { $body['record']['embed']['external']['thumb'] = $image_blob; } - $response = $this->api_client->create_record( $body, $connection['access_jwt'], $connection['did'] ); + if ( $this->should_publish_document_for( $post_id ) ) { + $response = $this->create_post_with_document( + $post_id, + $post, + $connection, + $body['record'], + is_array( $image_blob ) ? $image_blob : null + ); + } else { + $response = $this->api_client->create_record( $body, $connection['access_jwt'], $connection['did'] ); + } if ( is_wp_error( $response ) ) { $this->log->error( @@ -219,4 +229,104 @@ public function share_to_bluesky( int $post_id ) { return $share; } + + /** + * Whether this post should also produce a standard.site document record. + */ + private function should_publish_document_for( int $post_id ): bool { + if ( ! Utils::is_standard_site_enabled() ) { + return false; + } + + return (bool) get_post_meta( $post_id, 'autoblue_publish_document', true ); + } + + /** + * Create the Bluesky post and standard.site document in one atomic write. + * + * The initial document cannot include bskyPostRef because the Bluesky post + * CID is only known after the write. Once the atomic create succeeds, we + * update the document with the document-side strongRef. The Bluesky post does + * not point back at the document, avoiding an unstable CID cycle. + * + * @param array $connection + * @param array $bsky_record + * @param array|null $image_blob + * @return array|\WP_Error + */ + private function create_post_with_document( int $post_id, \WP_Post $post, array $connection, array $bsky_record, ?array $image_blob ) { + $publication = new Standard\Publication( $this->api_client, $this->log ); + $publication_uri = $publication->ensure_exists_for_connection( $connection ); + if ( is_wp_error( $publication_uri ) ) { + return $publication_uri; + } + + $document = new Standard\Document( $this->api_client, $this->log, $publication ); + $prepared = $document->prepare_record( $post_id, (string) $publication_uri, null, $image_blob ); + if ( is_wp_error( $prepared ) ) { + return $prepared; + } + + $result = $this->api_client->apply_writes( + [ + [ + '$type' => 'com.atproto.repo.applyWrites#create', + 'collection' => 'app.bsky.feed.post', + 'rkey' => $this->generate_rkey(), + 'value' => $bsky_record, + ], + [ + '$type' => 'com.atproto.repo.applyWrites#create', + 'collection' => 'site.standard.document', + 'rkey' => $prepared['rkey'], + 'value' => $prepared['record'], + ], + ], + $connection['access_jwt'], + $connection['did'] + ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $bsky_result = $result['results'][0] ?? []; + $doc_result = $result['results'][1] ?? []; + if ( empty( $bsky_result['uri'] ) || empty( $bsky_result['cid'] ) || empty( $doc_result['uri'] ) || empty( $doc_result['cid'] ) ) { + return new \WP_Error( 'autoblue_invalid_apply_writes_response', __( 'The PDS did not return complete Bluesky and document record refs.', 'autoblue' ) ); + } + + $document->store_meta( $post_id, $doc_result, $prepared['rkey'] ); + + $update = $document->update_bsky_ref( + $post_id, + [ + 'uri' => (string) $bsky_result['uri'], + 'cid' => (string) $bsky_result['cid'], + ], + $image_blob, + $connection + ); + + if ( is_wp_error( $update ) ) { + $this->log->error( + __( 'Failed to add bskyPostRef to standard.site document for post `{post_title}` with ID `{post_id}`: {message}', 'autoblue' ), + [ + 'post_id' => $post_id, + 'post_title' => $post->post_title, + 'message' => $update->get_error_message(), + ] + ); + } + + return [ + 'uri' => (string) $bsky_result['uri'], + 'cid' => (string) $bsky_result['cid'], + 'applyWrites' => $result, + ]; + } + + private function generate_rkey(): string { + return 'ab' . strtolower( str_replace( '-', '', wp_generate_uuid4() ) ); + } } diff --git a/includes/Bluesky/API.php b/includes/Bluesky/API.php index fe5ed1d..7aee307 100644 --- a/includes/Bluesky/API.php +++ b/includes/Bluesky/API.php @@ -253,6 +253,108 @@ public function create_record( array $record, string $access_token, string $did ); } + /** + * List records in a collection via com.atproto.repo.listRecords. + * + * @return array|\WP_Error + */ + public function list_records( string $repo, string $collection, string $access_token, ?string $cursor = null, int $limit = 100 ) { + if ( ! $repo || ! $collection || ! $access_token ) { + return new \WP_Error( 'autoblue_invalid_list_records_args', __( 'Repo, collection, and access token are required.', 'autoblue' ) ); + } + + $pds_endpoint = $this->resolve_pds_endpoint( $repo ); + + if ( is_wp_error( $pds_endpoint ) ) { + return $pds_endpoint; + } + + $body = [ + 'repo' => $repo, + 'collection' => $collection, + 'limit' => $limit, + ]; + + if ( $cursor ) { + $body['cursor'] = $cursor; + } + + return $this->send_request( + [ + 'endpoint' => 'com.atproto.repo.listRecords', + 'method' => 'GET', + 'headers' => [ + 'Authorization' => 'Bearer ' . $access_token, + ], + 'body' => $body, + 'base_url' => $pds_endpoint, + ] + ); + } + + /** + * Create-or-update a record at a known rkey via com.atproto.repo.putRecord. + * + * @param array $record Must include repo, collection, rkey, record. + * @return array|\WP_Error + */ + public function put_record( array $record, string $access_token, string $did ) { + if ( ! $record || ! $access_token ) { + return new \WP_Error( 'autoblue_invalid_record_or_access_token', __( 'Invalid record or access token.', 'autoblue' ) ); + } + + $pds_endpoint = $this->resolve_pds_endpoint( $did ); + + if ( is_wp_error( $pds_endpoint ) ) { + return $pds_endpoint; + } + + return $this->send_request( + [ + 'endpoint' => 'com.atproto.repo.putRecord', + 'method' => 'POST', + 'headers' => [ + 'Authorization' => 'Bearer ' . $access_token, + ], + 'body' => $record, + 'base_url' => $pds_endpoint, + ] + ); + } + + /** + * Apply a batch of writes atomically via com.atproto.repo.applyWrites. + * + * @param array> $writes + * @return array|\WP_Error + */ + public function apply_writes( array $writes, string $access_token, string $did ) { + if ( empty( $writes ) || ! $access_token || ! $did ) { + return new \WP_Error( 'autoblue_invalid_apply_writes_args', __( 'Writes, access token, and DID are required.', 'autoblue' ) ); + } + + $pds_endpoint = $this->resolve_pds_endpoint( $did ); + + if ( is_wp_error( $pds_endpoint ) ) { + return $pds_endpoint; + } + + return $this->send_request( + [ + 'endpoint' => 'com.atproto.repo.applyWrites', + 'method' => 'POST', + 'headers' => [ + 'Authorization' => 'Bearer ' . $access_token, + ], + 'body' => [ + 'repo' => $did, + 'writes' => $writes, + ], + 'base_url' => $pds_endpoint, + ] + ); + } + /** * @return array|\WP_Error */ diff --git a/includes/ConnectionsManager.php b/includes/ConnectionsManager.php index 767a142..39af86d 100644 --- a/includes/ConnectionsManager.php +++ b/includes/ConnectionsManager.php @@ -71,6 +71,14 @@ public function add_connection( string $did, string $app_password ) { $this->log->success( __( 'Connection added.', 'autoblue' ), [ 'did' => $did ] ); } + /** + * Fires after a Bluesky connection is added or refreshed via reconnect. + * + * @param array $new_connection The freshly stored connection. + * @param bool $is_reconnect Whether this was a reconnect of an existing DID. + */ + do_action( 'autoblue/connection_added', $new_connection, $is_reconnect ); + return $new_connection; } diff --git a/includes/Endpoints/StandardRecordsController.php b/includes/Endpoints/StandardRecordsController.php new file mode 100644 index 0000000..cb49de3 --- /dev/null +++ b/includes/Endpoints/StandardRecordsController.php @@ -0,0 +1,113 @@ +namespace = 'autoblue/v1'; + $this->rest_base = 'standard/records'; + } + + public function register_routes(): void { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_records' ], + 'permission_callback' => [ $this, 'permissions_check' ], + 'args' => [ + 'cursor' => [ + 'type' => 'string', + 'default' => '', + ], + ], + ], + ] + ); + } + + public function permissions_check(): bool { + return current_user_can( 'manage_options' ); + } + + /** + * GET /autoblue/v1/standard/records?cursor=... + * + * @return \WP_REST_Response|\WP_Error + */ + public function get_records( \WP_REST_Request $request ) { + if ( ! Utils::is_standard_site_enabled() ) { + return new \WP_Error( + 'autoblue_standard_site_disabled', + __( 'standard.site publishing is not enabled.', 'autoblue' ), + [ 'status' => 400 ] + ); + } + + $connections_manager = new ConnectionsManager(); + $connections = $connections_manager->get_all_connections(); + if ( empty( $connections ) ) { + return new \WP_Error( 'autoblue_no_connection', __( 'No Bluesky connection found.', 'autoblue' ), [ 'status' => 400 ] ); + } + + $connection = $connections_manager->refresh_tokens( $connections[0]['did'] ); + if ( is_wp_error( $connection ) ) { + return $connection; + } + + $api = new API(); + $cursor = (string) $request->get_param( 'cursor' ); + + $publication = null; + if ( '' === $cursor ) { + $pub_uri = ( new Publication() )->get_uri(); + if ( $pub_uri ) { + $pub_response = $api->list_records( + $connection['did'], + 'site.standard.publication', + $connection['access_jwt'] + ); + if ( ! is_wp_error( $pub_response ) && ! empty( $pub_response['records'] ) ) { + foreach ( $pub_response['records'] as $record ) { + if ( isset( $record['uri'] ) && $record['uri'] === $pub_uri ) { + $publication = $record; + break; + } + } + } + } + } + + $docs_response = $api->list_records( + $connection['did'], + 'site.standard.document', + $connection['access_jwt'], + $cursor ?: null + ); + + if ( is_wp_error( $docs_response ) ) { + return $docs_response; + } + + return rest_ensure_response( + [ + 'publication' => $publication, + 'documents' => $docs_response['records'] ?? [], + 'cursor' => $docs_response['cursor'] ?? null, + ] + ); + } +} diff --git a/includes/Meta.php b/includes/Meta.php index 27ed6e1..8782c2e 100644 --- a/includes/Meta.php +++ b/includes/Meta.php @@ -80,6 +80,18 @@ public function register_post_meta(): void { 'sanitize_callback' => 'esc_url_raw', ] ); + + register_post_meta( + $post_type, + 'autoblue_publish_document', + [ + 'type' => 'boolean', + 'description' => __( 'Whether the post should be published as a standard.site document.', 'autoblue' ), + 'single' => true, + 'show_in_rest' => true, + 'default' => (bool) get_option( 'autoblue_publish_documents_enabled', false ), + ] + ); } } } diff --git a/includes/Setup.php b/includes/Setup.php index 0aa9269..6f90a60 100644 --- a/includes/Setup.php +++ b/includes/Setup.php @@ -11,12 +11,15 @@ public function init(): void { ( new PostHandler() )->register_hooks(); ( new ConnectionsManager() )->register_hooks(); ( new Logging\Setup() )->register_hooks(); + ( new Standard\Publication() )->register_hooks(); + ( new Standard\Verification() )->register_hooks(); ( new CLI\Commands() )->register_commands(); add_action( 'rest_api_init', [ new Endpoints\LogsController(), 'register_routes' ] ); add_action( 'rest_api_init', [ new Endpoints\SearchController(), 'register_routes' ] ); add_action( 'rest_api_init', [ new Endpoints\AccountController(), 'register_routes' ] ); add_action( 'rest_api_init', [ new Endpoints\ConnectionsController(), 'register_routes' ] ); + add_action( 'rest_api_init', [ new Endpoints\StandardRecordsController(), 'register_routes' ] ); } public static function activate(): void { diff --git a/includes/Standard/Document.php b/includes/Standard/Document.php new file mode 100644 index 0000000..ff1cb46 --- /dev/null +++ b/includes/Standard/Document.php @@ -0,0 +1,361 @@ +api_client = $api_client ?: new API(); + $this->log = $log ?: new Log(); + $this->publication = $publication ?: new Publication( $this->api_client, $this->log ); + } + + /** + * Publish a post as a standard.site document. + * + * @param int $post_id + * @param array{uri:string,cid:string}|null $bsky_post_ref + * Optional Bluesky strongRef. Pass null to publish a document without a + * back-reference (e.g. when writing the document BEFORE the bsky post so + * the bsky post can embed it via app.bsky.embed.record). + * @param array|null $cover_blob_ref + * Optional reusable blob ref from the bsky post upload. + * @return array|\WP_Error + */ + public function publish( int $post_id, ?array $bsky_post_ref, ?array $cover_blob_ref = null ) { + $post = get_post( $post_id ); + if ( ! $post ) { + return new \WP_Error( 'autoblue_document_post_not_found', __( 'Post not found.', 'autoblue' ) ); + } + + if ( null !== $bsky_post_ref && ( empty( $bsky_post_ref['uri'] ) || empty( $bsky_post_ref['cid'] ) ) ) { + return new \WP_Error( 'autoblue_document_invalid_bsky_ref', __( 'A Bluesky post URI and CID are required.', 'autoblue' ) ); + } + + $publication_uri = $this->publication->ensure_exists(); + if ( is_wp_error( $publication_uri ) ) { + $this->log->error( + __( 'Skipping standard.site document for post `{post_title}` with ID `{post_id}`: publication unavailable.', 'autoblue' ), + [ + 'post_id' => $post_id, + 'post_title' => $post->post_title, + 'message' => $publication_uri->get_error_message(), + ] + ); + return $publication_uri; + } + + $connection = $this->get_active_connection(); + if ( is_wp_error( $connection ) ) { + return $connection; + } + + $record = $this->build_record( $post, $publication_uri, $bsky_post_ref, $cover_blob_ref ); + $rkey = $this->get_rkey( $post_id ); + + $response = $this->api_client->create_record( + [ + 'repo' => $connection['did'], + 'collection' => self::COLLECTION, + 'rkey' => $rkey, + 'record' => $record, + ], + $connection['access_jwt'], + $connection['did'] + ); + + if ( is_wp_error( $response ) ) { + $this->log->error( + __( 'Failed to publish standard.site document for post `{post_title}` with ID `{post_id}`: {message}', 'autoblue' ), + [ + 'post_id' => $post_id, + 'post_title' => $post->post_title, + 'message' => $response->get_error_message(), + ] + ); + return $response; + } + + if ( empty( $response['uri'] ) ) { + return new \WP_Error( 'autoblue_invalid_document_response', __( 'Document record was created but no URI was returned.', 'autoblue' ) ); + } + + $stored = $this->store_meta( $post_id, $response, $rkey ); + + $this->log->success( + __( 'Published standard.site document for post `{post_title}` with ID `{post_id}` at {uri}.', 'autoblue' ), + [ + 'post_id' => $post_id, + 'post_title' => $post->post_title, + 'uri' => $stored['uri'], + ] + ); + + return $stored; + } + + /** + * Prepare the document record for an external batch write. + * + * @param array{uri:string,cid:string}|null $bsky_post_ref + * @param array|null $cover_blob_ref + * @return array{rkey:string,record:array}|\WP_Error + */ + public function prepare_record( int $post_id, string $publication_uri, ?array $bsky_post_ref, ?array $cover_blob_ref = null ) { + $post = get_post( $post_id ); + if ( ! $post ) { + return new \WP_Error( 'autoblue_document_post_not_found', __( 'Post not found.', 'autoblue' ) ); + } + + if ( null !== $bsky_post_ref && ( empty( $bsky_post_ref['uri'] ) || empty( $bsky_post_ref['cid'] ) ) ) { + return new \WP_Error( 'autoblue_document_invalid_bsky_ref', __( 'A Bluesky post URI and CID are required.', 'autoblue' ) ); + } + + return [ + 'rkey' => $this->get_rkey( $post_id ), + 'record' => $this->build_record( $post, $publication_uri, $bsky_post_ref, $cover_blob_ref ), + ]; + } + + /** + * Persist document metadata from a PDS write response. + * + * @param array $response + * @return array{uri:string,cid:string,rkey:string} + */ + public function store_meta( int $post_id, array $response, ?string $rkey = null ): array { + $uri = (string) ( $response['uri'] ?? '' ); + $stored = [ + 'uri' => $uri, + 'cid' => (string) ( $response['cid'] ?? '' ), + 'rkey' => $rkey ?: $this->extract_rkey( $uri ), + ]; + + update_post_meta( $post_id, self::META_KEY, $stored ); + + return $stored; + } + + public function get_rkey( int $post_id ): string { + $stored = get_post_meta( $post_id, self::META_KEY, true ); + if ( is_array( $stored ) && ! empty( $stored['rkey'] ) ) { + return (string) $stored['rkey']; + } + + $rkey = 'ab' . strtolower( str_replace( '-', '', wp_generate_uuid4() ) ); + update_post_meta( + $post_id, + self::META_KEY, + array_merge( + is_array( $stored ) ? $stored : [], + [ 'rkey' => $rkey ] + ) + ); + + return $rkey; + } + + /** + * Back-fill bskyPostRef onto an already-published document. + * + * Called after the Bluesky post lands so we can point the document at + * its corresponding bsky post (useful for comment threading and + * reverse navigation in standard.site-aware readers). + * + * @param array{uri:string,cid:string} $bsky_post_ref + * @param array|null $cover_blob_ref Reused so coverImage stays intact. + * @param array|null $connection Already-refreshed connection. + * @return array|\WP_Error + */ + public function update_bsky_ref( int $post_id, array $bsky_post_ref, ?array $cover_blob_ref = null, ?array $connection = null ) { + $post = get_post( $post_id ); + if ( ! $post ) { + return new \WP_Error( 'autoblue_document_post_not_found', __( 'Post not found.', 'autoblue' ) ); + } + + $stored = get_post_meta( $post_id, self::META_KEY, true ); + if ( ! is_array( $stored ) || empty( $stored['rkey'] ) ) { + return new \WP_Error( 'autoblue_document_not_published', __( 'Document not yet published.', 'autoblue' ) ); + } + + $publication_uri = $this->publication->get_uri(); + if ( ! $publication_uri ) { + return new \WP_Error( 'autoblue_publication_missing', __( 'Publication unavailable.', 'autoblue' ) ); + } + + if ( null === $connection ) { + $connection = $this->get_active_connection(); + if ( is_wp_error( $connection ) ) { + return $connection; + } + } + + $record = $this->build_record( $post, $publication_uri, $bsky_post_ref, $cover_blob_ref ); + + $response = $this->api_client->put_record( + [ + 'repo' => $connection['did'], + 'collection' => self::COLLECTION, + 'rkey' => $stored['rkey'], + 'record' => $record, + ], + $connection['access_jwt'], + $connection['did'] + ); + + if ( is_wp_error( $response ) ) { + $this->log->error( + __( 'Failed to back-fill bskyPostRef on standard.site document for post `{post_title}` with ID `{post_id}`: {message}', 'autoblue' ), + [ + 'post_id' => $post_id, + 'post_title' => $post->post_title, + 'message' => $response->get_error_message(), + ] + ); + return $response; + } + + update_post_meta( + $post_id, + self::META_KEY, + array_merge( + $stored, + [ 'cid' => (string) ( $response['cid'] ?? $stored['cid'] ) ] + ) + ); + + return $response; + } + + /** + * @param \WP_Post $post + * @param string $publication_uri + * @param array{uri:string,cid:string}|null $bsky_post_ref + * @param array|null $cover_blob_ref + * @return array + */ + public function build_record( \WP_Post $post, string $publication_uri, ?array $bsky_post_ref, ?array $cover_blob_ref ): array { + $record = [ + '$type' => self::COLLECTION, + 'site' => $publication_uri, + 'title' => html_entity_decode( get_the_title( $post ), ENT_QUOTES ), + 'publishedAt' => gmdate( 'c', strtotime( $post->post_date_gmt ) ?: time() ), + ]; + + if ( $bsky_post_ref ) { + $record['bskyPostRef'] = [ + 'uri' => $bsky_post_ref['uri'], + 'cid' => $bsky_post_ref['cid'], + ]; + } + + $path = wp_parse_url( get_permalink( $post ), PHP_URL_PATH ); + if ( is_string( $path ) && '' !== $path ) { + $record['path'] = $path; + } + + $description = $this->build_description( $post ); + if ( '' !== $description ) { + $record['description'] = $description; + } + + $text = $this->render_plain_text( $post ); + if ( '' !== $text ) { + $record['textContent'] = $text; + } + + $tags = $this->build_tags( $post ); + if ( ! empty( $tags ) ) { + $record['tags'] = $tags; + } + + if ( $cover_blob_ref ) { + $record['coverImage'] = $cover_blob_ref; + } + + if ( $post->post_modified_gmt && $post->post_modified_gmt !== $post->post_date_gmt ) { + $record['updatedAt'] = gmdate( 'c', strtotime( $post->post_modified_gmt ) ?: time() ); + } + + return $record; + } + + private function build_description( \WP_Post $post ): string { + $excerpt = get_the_excerpt( $post ); + return trim( html_entity_decode( wp_strip_all_tags( (string) $excerpt ), ENT_QUOTES ) ); + } + + private function render_plain_text( \WP_Post $post ): string { + $content = apply_filters( 'the_content', $post->post_content ); + $content = wp_strip_all_tags( (string) $content ); + $content = html_entity_decode( $content, ENT_QUOTES ); + $content = preg_replace( '/\s+/u', ' ', $content ); + return trim( (string) $content ); + } + + /** + * @return array + */ + private function build_tags( \WP_Post $post ): array { + $tags = []; + $terms = wp_get_post_tags( $post->ID ); + if ( ! is_wp_error( $terms ) ) { + foreach ( $terms as $term ) { + $tags[] = $term->name; + } + } + + $cats = wp_get_post_categories( $post->ID, [ 'fields' => 'all' ] ); + if ( ! is_wp_error( $cats ) ) { + foreach ( $cats as $cat ) { + if ( 'uncategorized' === $cat->slug ) { + continue; + } + $tags[] = $cat->name; + } + } + + $tags = array_values( array_unique( array_filter( array_map( 'strval', $tags ) ) ) ); + return array_slice( $tags, 0, 8 ); + } + + /** + * @return array|\WP_Error + */ + private function get_active_connection() { + $connections_manager = new \Autoblue\ConnectionsManager(); + $connections = $connections_manager->get_all_connections(); + if ( empty( $connections ) ) { + return new \WP_Error( 'autoblue_no_connection', __( 'No Bluesky connection found.', 'autoblue' ) ); + } + + $connection = $connections_manager->refresh_tokens( $connections[0]['did'] ); + if ( is_wp_error( $connection ) ) { + return $connection; + } + + return $connection; + } + + private function extract_rkey( string $uri ): string { + $parts = explode( '/', $uri ); + return (string) end( $parts ); + } +} diff --git a/includes/Standard/Publication.php b/includes/Standard/Publication.php new file mode 100644 index 0000000..dd85c14 --- /dev/null +++ b/includes/Standard/Publication.php @@ -0,0 +1,437 @@ +api_client = $api_client ?: new API(); + $this->log = $log ?: new Log(); + } + + public function register_hooks(): void { + add_action( 'update_option_blogname', [ $this, 'sync' ] ); + add_action( 'update_option_blogdescription', [ $this, 'sync' ] ); + add_action( 'update_option_site_icon', [ $this, 'sync' ] ); + add_action( 'update_option_' . self::OVERRIDES_KEY, [ $this, 'sync' ] ); + add_action( 'autoblue/connection_added', [ $this, 'maybe_adopt_existing' ], 10, 2 ); + } + + /** + * On a fresh Bluesky connection, search the user's PDS for a publication + * record whose `url` matches our home_url() and adopt it instead of writing + * a duplicate on the next publish. No-op on reconnects of the same DID. + * + * @param array $connection + */ + public function maybe_adopt_existing( array $connection, bool $is_reconnect = false ): void { + unset( $is_reconnect ); + + if ( empty( $connection['did'] ) || empty( $connection['access_jwt'] ) ) { + return; + } + + $stored = get_option( self::OPTION_KEY, [] ); + if ( ! empty( $stored['uri'] ) && ! empty( $stored['did'] ) && $stored['did'] === $connection['did'] ) { + // Already linked to this DID's publication. + return; + } + + $response = $this->api_client->list_records( + $connection['did'], + self::COLLECTION, + $connection['access_jwt'] + ); + + if ( is_wp_error( $response ) || empty( $response['records'] ) ) { + return; + } + + $home = untrailingslashit( home_url( '/' ) ); + foreach ( $response['records'] as $record ) { + $value = $record['value'] ?? []; + $url = isset( $value['url'] ) ? untrailingslashit( (string) $value['url'] ) : ''; + if ( $url !== $home ) { + continue; + } + + $uri = isset( $record['uri'] ) ? (string) $record['uri'] : ''; + $cid = isset( $record['cid'] ) ? (string) $record['cid'] : ''; + $rkey = $this->extract_rkey( $uri ); + if ( ! $uri || ! $rkey ) { + continue; + } + + update_option( + self::OPTION_KEY, + [ + 'did' => $connection['did'], + 'uri' => $uri, + 'cid' => $cid, + 'rkey' => $rkey, + 'fingerprint' => $this->fingerprint( $value ), + ] + ); + + $this->log->info( + __( 'Adopted existing standard.site publication record at {uri}.', 'autoblue' ), + [ 'uri' => $uri ] + ); + return; + } + } + + /** + * Return the publication's AT-URI, creating the record if missing. + * + * @return string|\WP_Error + */ + public function ensure_exists() { + $connection = $this->get_active_connection(); + if ( is_wp_error( $connection ) ) { + return $connection; + } + + return $this->ensure_exists_for_connection( $connection ); + } + + /** + * Return the publication AT-URI for an already-refreshed connection, + * creating the record if missing. + * + * @param array $connection + * @return string|\WP_Error + */ + public function ensure_exists_for_connection( array $connection ) { + $stored = get_option( self::OPTION_KEY, [] ); + if ( ! empty( $stored['uri'] ) && ! empty( $stored['did'] ) && $stored['did'] === $connection['did'] ) { + return $stored['uri']; + } + + return $this->create( $connection ); + } + + /** + * Get the cached publication AT-URI without making network calls. + */ + public function get_uri(): ?string { + $stored = get_option( self::OPTION_KEY, [] ); + return ! empty( $stored['uri'] ) ? (string) $stored['uri'] : null; + } + + /** + * Get the cached publication strongRef ({ uri, cid }) without making network calls. + * + * @return array{uri:string,cid:string}|null + */ + public function get_strongref(): ?array { + $stored = get_option( self::OPTION_KEY, [] ); + if ( empty( $stored['uri'] ) || empty( $stored['cid'] ) ) { + return null; + } + return [ + 'uri' => (string) $stored['uri'], + 'cid' => (string) $stored['cid'], + ]; + } + + /** + * Reactively update the publication record when WP settings or overrides change. + */ + public function sync(): void { + if ( ! Utils::is_standard_site_enabled() ) { + return; + } + + $stored = get_option( self::OPTION_KEY, [] ); + if ( empty( $stored['uri'] ) || empty( $stored['rkey'] ) || empty( $stored['did'] ) ) { + return; + } + + $connection = $this->get_active_connection(); + if ( is_wp_error( $connection ) || $connection['did'] !== $stored['did'] ) { + return; + } + + $record = $this->build_record( $connection ); + $fingerprint = $this->fingerprint( $record ); + if ( ! empty( $stored['fingerprint'] ) && $stored['fingerprint'] === $fingerprint ) { + return; + } + + $response = $this->api_client->put_record( + [ + 'repo' => $connection['did'], + 'collection' => self::COLLECTION, + 'rkey' => $stored['rkey'], + 'record' => $record, + ], + $connection['access_jwt'], + $connection['did'] + ); + + if ( is_wp_error( $response ) ) { + $this->log->error( + __( 'Failed to update standard.site publication record: {message}', 'autoblue' ), + [ 'message' => $response->get_error_message() ] + ); + return; + } + + update_option( + self::OPTION_KEY, + [ + 'did' => $connection['did'], + 'uri' => (string) ( $response['uri'] ?? $stored['uri'] ), + 'cid' => (string) ( $response['cid'] ?? '' ), + 'rkey' => $stored['rkey'], + 'fingerprint' => $fingerprint, + ] + ); + } + + /** + * Clear the cached publication record (e.g. on disconnect). + */ + public function clear(): void { + delete_option( self::OPTION_KEY ); + } + + /** + * Build the publication record payload from WP settings + overrides. + * + * @param array $connection + * @return array + */ + public function build_record( array $connection ): array { + $overrides = get_option( self::OVERRIDES_KEY, [] ); + if ( ! is_array( $overrides ) ) { + $overrides = []; + } + + $name = $this->resolve_string( $overrides['name'] ?? null, get_bloginfo( 'name' ) ); + $description = $this->resolve_string( $overrides['description'] ?? null, get_bloginfo( 'description' ) ); + + $record = [ + '$type' => self::COLLECTION, + 'url' => untrailingslashit( home_url( '/' ) ), + 'name' => $name, + ]; + + if ( '' !== $description ) { + $record['description'] = $description; + } + + $icon_blob = $this->resolve_icon_blob( $overrides['icon_id'] ?? null, $connection ); + if ( $icon_blob ) { + $record['icon'] = $icon_blob; + } + + $basic_theme = $this->build_basic_theme( $overrides ); + if ( $basic_theme ) { + $record['basicTheme'] = $basic_theme; + } + + return $record; + } + + /** + * Build the inline site.standard.theme.basic value from hex overrides. + * + * @param array $overrides + * @return array|null + */ + private function build_basic_theme( array $overrides ): ?array { + $mapping = [ + 'background' => 'theme_background', + 'foreground' => 'theme_foreground', + 'accent' => 'theme_accent', + 'accentForeground' => 'theme_accent_foreground', + ]; + + $theme = [ '$type' => 'site.standard.theme.basic' ]; + foreach ( $mapping as $field => $key ) { + $rgb = $this->hex_to_rgb( $overrides[ $key ] ?? null ); + if ( $rgb ) { + $theme[ $field ] = $rgb; + } + } + + // Only return the theme if at least one colour was set. + return count( $theme ) > 1 ? $theme : null; + } + + /** + * @param mixed $hex + * @return array{'$type':string,r:int,g:int,b:int}|null + */ + private function hex_to_rgb( $hex ): ?array { + if ( ! is_string( $hex ) ) { + return null; + } + $hex = ltrim( trim( $hex ), '#' ); + if ( 3 === strlen( $hex ) ) { + $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; + } + if ( ! preg_match( '/^[0-9a-fA-F]{6}$/', $hex ) ) { + return null; + } + return [ + '$type' => 'site.standard.theme.color#rgb', + 'r' => (int) hexdec( substr( $hex, 0, 2 ) ), + 'g' => (int) hexdec( substr( $hex, 2, 2 ) ), + 'b' => (int) hexdec( substr( $hex, 4, 2 ) ), + ]; + } + + /** + * @param array $connection + * @return string|\WP_Error + */ + private function create( array $connection ) { + $record = $this->build_record( $connection ); + + $response = $this->api_client->create_record( + [ + 'repo' => $connection['did'], + 'collection' => self::COLLECTION, + 'record' => $record, + ], + $connection['access_jwt'], + $connection['did'] + ); + + if ( is_wp_error( $response ) ) { + $this->log->error( + __( 'Failed to create standard.site publication record: {message}', 'autoblue' ), + [ 'message' => $response->get_error_message() ] + ); + return $response; + } + + if ( empty( $response['uri'] ) ) { + return new \WP_Error( 'autoblue_invalid_publication_response', __( 'Publication record was created but no URI was returned.', 'autoblue' ) ); + } + + $rkey = $this->extract_rkey( (string) $response['uri'] ); + + update_option( + self::OPTION_KEY, + [ + 'did' => $connection['did'], + 'uri' => (string) $response['uri'], + 'cid' => (string) ( $response['cid'] ?? '' ), + 'rkey' => $rkey, + 'fingerprint' => $this->fingerprint( $record ), + ] + ); + + $this->log->success( + __( 'Created standard.site publication record at {uri}.', 'autoblue' ), + [ 'uri' => $response['uri'] ] + ); + + return (string) $response['uri']; + } + + /** + * @return array|\WP_Error + */ + private function get_active_connection() { + $connections_manager = new ConnectionsManager(); + $connections = $connections_manager->get_all_connections(); + if ( empty( $connections ) ) { + return new \WP_Error( 'autoblue_no_connection', __( 'No Bluesky connection found.', 'autoblue' ) ); + } + + $connection = $connections_manager->refresh_tokens( $connections[0]['did'] ); + if ( is_wp_error( $connection ) ) { + return $connection; + } + + return $connection; + } + + /** + * @param mixed $override + */ + private function resolve_string( $override, string $fallback ): string { + if ( is_string( $override ) && '' !== trim( $override ) ) { + return $override; + } + return $fallback; + } + + /** + * @param mixed $override_id + * @param array $connection + * @return array|null + */ + private function resolve_icon_blob( $override_id, array $connection ): ?array { + $attachment_id = is_int( $override_id ) && $override_id > 0 + ? $override_id + : (int) get_option( 'site_icon', 0 ); + + if ( $attachment_id <= 0 ) { + return null; + } + + $path = get_attached_file( $attachment_id ); + if ( ! $path || ! file_exists( $path ) ) { + return null; + } + + $mime = get_post_mime_type( $attachment_id ); + $allowed = [ 'image/jpeg', 'image/png', 'image/webp' ]; + if ( ! $mime || ! in_array( $mime, $allowed, true ) ) { + return null; + } + + $compressor = new ImageCompressor( $path, $mime ); + $bytes = $compressor->compress_image(); + if ( ! $bytes ) { + return null; + } + + $blob = $this->api_client->upload_blob( $bytes, $mime, $connection['access_jwt'], $connection['did'] ); + if ( is_wp_error( $blob ) ) { + $this->log->error( + __( 'Failed to upload publication icon: {message}', 'autoblue' ), + [ 'message' => $blob->get_error_message() ] + ); + return null; + } + + return $blob; + } + + /** + * @param array $record + */ + private function fingerprint( array $record ): string { + return md5( (string) wp_json_encode( $record ) ); + } + + private function extract_rkey( string $uri ): string { + $parts = explode( '/', $uri ); + return (string) end( $parts ); + } +} diff --git a/includes/Standard/Verification.php b/includes/Standard/Verification.php new file mode 100644 index 0000000..f98bb20 --- /dev/null +++ b/includes/Standard/Verification.php @@ -0,0 +1,77 @@ + tags in singular post s + */ +class Verification { + public const WELL_KNOWN_PATH = '/.well-known/site.standard.publication'; + public const DOCUMENT_META = 'autoblue_document'; + + public function register_hooks(): void { + add_action( 'parse_request', [ $this, 'maybe_serve_well_known' ] ); + add_action( 'wp_head', [ $this, 'inject_document_link' ] ); + } + + public function maybe_serve_well_known(): void { + $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) : ''; + if ( self::WELL_KNOWN_PATH !== $request_uri ) { + return; + } + + if ( ! Utils::is_standard_site_enabled() ) { + $this->respond_not_found(); + return; + } + + $uri = ( new Publication() )->get_uri(); + if ( ! $uri ) { + $this->respond_not_found(); + return; + } + + header( 'Content-Type: text/plain; charset=utf-8' ); + header( 'X-Robots-Tag: noindex' ); + echo esc_html( $uri ); + exit; + } + + public function inject_document_link(): void { + if ( ! is_singular() ) { + return; + } + + $post_id = get_queried_object_id(); + if ( ! $post_id ) { + return; + } + + $post_type = get_post_type( $post_id ); + if ( ! $post_type || ! in_array( $post_type, Utils::get_enabled_post_types(), true ) ) { + return; + } + + $document = get_post_meta( $post_id, self::DOCUMENT_META, true ); + if ( ! is_array( $document ) || empty( $document['uri'] ) ) { + return; + } + + printf( + '' . "\n", + esc_attr( (string) $document['uri'] ) + ); + } + + private function respond_not_found(): void { + status_header( 404 ); + nocache_headers(); + header( 'Content-Type: text/plain; charset=utf-8' ); + echo 'Not found'; + exit; + } +} diff --git a/includes/Utils.php b/includes/Utils.php index bc3a93c..a17696b 100644 --- a/includes/Utils.php +++ b/includes/Utils.php @@ -56,4 +56,37 @@ public static function error_log( string $message ): void { error_log( $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } + + /** + * Whether WordPress is installed at the root of its domain. + * + * The standard.site verification endpoint must live at + * https://example.com/.well-known/site.standard.publication. WordPress can + * only serve that path when home_url() and site_url() are both at '/'. + */ + public static function is_root_install(): bool { + $home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH ); + $site_path = wp_parse_url( site_url( '/' ), PHP_URL_PATH ); + + return '/' === $home_path && '/' === $site_path; + } + + /** + * Whether the standard.site publishing feature is fully usable on this install. + * + * Requires the global toggle to be on, the install to be at the domain root, + * and at least one Bluesky connection to write records under. + */ + public static function is_standard_site_enabled(): bool { + if ( ! get_option( 'autoblue_publish_documents_enabled', false ) ) { + return false; + } + + if ( ! self::is_root_install() ) { + return false; + } + + $connections = get_option( 'autoblue_connections', [] ); + return ! empty( $connections ); + } } diff --git a/src/js/components/admin-page/index.js b/src/js/components/admin-page/index.js index d3385c4..47fb4fa 100644 --- a/src/js/components/admin-page/index.js +++ b/src/js/components/admin-page/index.js @@ -11,9 +11,11 @@ import { store as noticesStore } from '@wordpress/notices'; import { Logo } from './../../icons'; import Settings from './../settings'; import Logs from './../logs'; +import Records from './../records'; +import useSettings from './../../hooks/use-settings'; import styles from './styles.module.scss'; -const TABS = [ 'settings', 'logs' ]; +const TABS = [ 'settings', 'logs', 'records' ]; const Notices = () => { const { removeNotice } = useDispatch( noticesStore ); @@ -34,6 +36,8 @@ const Notices = () => { }; const AdminPage = () => { + const { isStandardSiteEnabled } = useSettings(); + const getInitialTab = () => { const hash = window.location.hash.replace( '#', '' ); return TABS.includes( hash ) ? hash : 'settings'; @@ -46,6 +50,27 @@ const AdminPage = () => { window.location.hash = tabName; }; + const tabs = [ + { + name: 'settings', + title: __( 'Settings', 'autoblue' ), + className: 'autoblue-settings', + }, + { + name: 'logs', + title: __( 'Logs', 'autoblue' ), + className: 'autoblue-logs', + }, + ]; + + if ( isStandardSiteEnabled ) { + tabs.push( { + name: 'records', + title: __( 'Records', 'autoblue' ), + className: 'autoblue-records', + } ); + } + return ( <>
@@ -56,18 +81,7 @@ const AdminPage = () => { className={ styles.tabs } initialTabName={ activeTab } onSelect={ handleTabChange } - tabs={ [ - { - name: 'settings', - title: __( 'Settings', 'autoblue' ), - className: 'autoblue-settings', - }, - { - name: 'logs', - title: __( 'Logs', 'autoblue' ), - className: 'autoblue-logs', - }, - ] } + tabs={ tabs } > { ( tab ) => { return ( @@ -81,6 +95,7 @@ const AdminPage = () => { > { tab.name === 'settings' && } { tab.name === 'logs' && } + { tab.name === 'records' && }
); diff --git a/src/js/components/admin-page/styles.module.scss b/src/js/components/admin-page/styles.module.scss index 72cc01a..bc250da 100644 --- a/src/js/components/admin-page/styles.module.scss +++ b/src/js/components/admin-page/styles.module.scss @@ -83,4 +83,8 @@ &.logs { max-width: 900px; } + + &.records { + max-width: 600px; + } } diff --git a/src/js/components/records/index.js b/src/js/components/records/index.js new file mode 100644 index 0000000..906e77a --- /dev/null +++ b/src/js/components/records/index.js @@ -0,0 +1,405 @@ +import clsx from 'clsx'; +import { __ } from '@wordpress/i18n'; +import { useEffect, useState, useCallback } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { dateI18n, humanTimeDiff } from '@wordpress/date'; +import { + BaseControl, + Button, + Card, + CardBody, + ColorIndicator, + Notice, + Spinner, + Tooltip, + __experimentalHStack as HStack, + __experimentalText as Text, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { RefreshIcon } from '../../icons'; +import styles from './styles.module.scss'; + +const extractDid = ( atUri = '' ) => { + const match = atUri.match( /^at:\/\/(did:[^/]+)/ ); + return match ? match[ 1 ] : ''; +}; + +const extractRkey = ( atUri = '' ) => { + const parts = atUri.split( '/' ); + return parts.length ? parts[ parts.length - 1 ] : ''; +}; + +const blobThumbnailUrl = ( did, blob ) => { + const cid = blob?.ref?.$link; + if ( ! did || ! cid ) return null; + return `https://cdn.bsky.app/img/feed_thumbnail/plain/${ did }/${ cid }@jpeg`; +}; + +const pdslsUrl = ( atUri ) => + atUri ? `https://pdsls.dev/${ atUri }` : null; + +const rgbToHex = ( rgb ) => { + if ( ! rgb ) return null; + const { r, g, b } = rgb; + if ( + typeof r !== 'number' || + typeof g !== 'number' || + typeof b !== 'number' + ) { + return null; + } + const toHex = ( n ) => + Math.max( 0, Math.min( 255, Math.round( n ) ) ) + .toString( 16 ) + .padStart( 2, '0' ); + return `#${ toHex( r ) }${ toHex( g ) }${ toHex( b ) }`; +}; + +const THEME_FIELDS = [ + { key: 'background', label: 'Background' }, + { key: 'foreground', label: 'Foreground' }, + { key: 'accent', label: 'Accent' }, + { key: 'accentForeground', label: 'Accent foreground' }, +]; + +const bskyPostUrl = ( bskyPostRef ) => { + const uri = bskyPostRef?.uri; + if ( ! uri ) return null; + const did = extractDid( uri ); + const rkey = extractRkey( uri ); + if ( ! did || ! rkey ) return null; + return `https://bsky.app/profile/${ did }/post/${ rkey }`; +}; + +const RelativeDate = ( { iso } ) => { + if ( ! iso ) return null; + const date = new Date( iso ); + const absolute = dateI18n( 'F j, Y g:i a', date ); + return ( + + + + + + ); +}; + +const Records = () => { + const [ publication, setPublication ] = useState( null ); + const [ documents, setDocuments ] = useState( [] ); + const [ cursor, setCursor ] = useState( null ); + const [ isLoading, setIsLoading ] = useState( true ); + const [ isLoadingMore, setIsLoadingMore ] = useState( false ); + const [ isRefreshing, setIsRefreshing ] = useState( false ); + const [ error, setError ] = useState( null ); + + const fetchPage = useCallback( async ( nextCursor = '' ) => { + const path = nextCursor + ? `/autoblue/v1/standard/records?cursor=${ encodeURIComponent( + nextCursor + ) }` + : '/autoblue/v1/standard/records'; + return apiFetch( { path } ); + }, [] ); + + const loadFirstPage = useCallback( async () => { + try { + const response = await fetchPage( '' ); + setPublication( response.publication ?? null ); + setDocuments( response.documents ?? [] ); + setCursor( response.cursor ?? null ); + setError( null ); + } catch ( e ) { + setError( + e?.message || __( 'Failed to load records.', 'autoblue' ) + ); + } + }, [ fetchPage ] ); + + useEffect( () => { + let cancelled = false; + ( async () => { + await loadFirstPage(); + if ( ! cancelled ) setIsLoading( false ); + } )(); + return () => { + cancelled = true; + }; + }, [ loadFirstPage ] ); + + const handleRefresh = async () => { + if ( isRefreshing ) return; + setIsRefreshing( true ); + await loadFirstPage(); + setIsRefreshing( false ); + }; + + const loadMore = async () => { + if ( ! cursor || isLoadingMore ) return; + setIsLoadingMore( true ); + try { + const response = await fetchPage( cursor ); + setDocuments( ( prev ) => [ + ...prev, + ...( response.documents ?? [] ), + ] ); + setCursor( response.cursor ?? null ); + } catch ( e ) { + setError( + e?.message || + __( 'Failed to load more documents.', 'autoblue' ) + ); + } finally { + setIsLoadingMore( false ); + } + }; + + if ( isLoading ) { + return ; + } + + if ( error ) { + return ( + + { error } + + ); + } + + const publicationDid = extractDid( publication?.uri ?? '' ); + const publicationIconUrl = blobThumbnailUrl( + publicationDid, + publication?.value?.icon + ); + + return ( + <> + + + ) } + + ) } + + + ); +}; + +export default Records; diff --git a/src/js/components/records/styles.module.scss b/src/js/components/records/styles.module.scss new file mode 100644 index 0000000..14d785d --- /dev/null +++ b/src/js/components/records/styles.module.scss @@ -0,0 +1,69 @@ +.row { + display: flex; + gap: 16px; + align-items: flex-start; +} + +.meta { + min-width: 0; + flex: 1; +} + +.icon { + width: 48px; + height: 48px; + border-radius: 6px; + object-fit: cover; + background: #f1f1f1; + flex-shrink: 0; +} + +.cover { + width: 80px; + height: 60px; + border-radius: 4px; + object-fit: cover; + background: #f1f1f1; + flex-shrink: 0; +} + +.links { + font-size: 12px; + margin-top: 4px; + + a { + text-decoration: none; + } + + a:hover { + text-decoration: underline; + } +} + +.swatches { + margin-top: 4px; +} + +.toolbar { + margin-bottom: 8px; +} + +@keyframes rotate { + 100% { + transform: rotate(360deg); + } +} + +.refreshing { + svg { + animation: rotate 0.6s linear infinite; + } +} + +.card.card { + padding: 20px; + + @media screen and (max-width: 500px) { + padding: 10px; + } +} diff --git a/src/js/components/settings/index.js b/src/js/components/settings/index.js index 7bbf27f..0e82e05 100644 --- a/src/js/components/settings/index.js +++ b/src/js/components/settings/index.js @@ -1,14 +1,25 @@ import { __ } from '@wordpress/i18n'; import { BaseControl, + Button, Card, CardBody, CheckboxControl, + Notice, Spinner, + TextControl, + TextareaControl, + ToggleControl, + __experimentalHStack as HStack, + __experimentalPaletteEdit as PaletteEdit, __experimentalText as Text, __experimentalVStack as VStack, } from '@wordpress/components'; -import { createInterpolateElement } from '@wordpress/element'; +import { + createInterpolateElement, + useEffect, + useState, +} from '@wordpress/element'; import NoAccountsPlaceholder from './../no-accounts-placeholder'; import AccountList from './../account-list'; import SettingToggle from './../setting-toggle'; @@ -25,8 +36,197 @@ const Settings = () => { isLoadingPostTypes, availablePostTypes, togglePostType, + isStandardSiteEnabled, + isLoadingStandardSite, + setIsStandardSiteEnabled, + publicationOverrides, + savePublicationOverrides, + standardSitePublication, + isRootInstall, + isSaving, } = useSettings(); + const savedName = publicationOverrides.name ?? ''; + const savedDescription = publicationOverrides.description ?? ''; + const savedIconId = publicationOverrides.icon_id ?? null; + const savedThemeBackground = publicationOverrides.theme_background ?? ''; + const savedThemeForeground = publicationOverrides.theme_foreground ?? ''; + const savedThemeAccent = publicationOverrides.theme_accent ?? ''; + const savedThemeAccentFg = publicationOverrides.theme_accent_foreground ?? ''; + + const [ draftName, setDraftName ] = useState( savedName ); + const [ draftDescription, setDraftDescription ] = useState( savedDescription ); + const [ draftIconId, setDraftIconId ] = useState( savedIconId ); + const [ draftIconUrl, setDraftIconUrl ] = useState( + standardSitePublication.iconOverrideUrl || '' + ); + const [ draftThemeBackground, setDraftThemeBackground ] = useState( savedThemeBackground ); + const [ draftThemeForeground, setDraftThemeForeground ] = useState( savedThemeForeground ); + const [ draftThemeAccent, setDraftThemeAccent ] = useState( savedThemeAccent ); + const [ draftThemeAccentFg, setDraftThemeAccentFg ] = useState( savedThemeAccentFg ); + + // Re-sync drafts when the saved overrides change (initial load + after our own save). + useEffect( () => { + setDraftName( savedName ); + }, [ savedName ] ); + useEffect( () => { + setDraftDescription( savedDescription ); + }, [ savedDescription ] ); + useEffect( () => { + setDraftIconId( savedIconId ); + }, [ savedIconId ] ); + useEffect( () => { + setDraftThemeBackground( savedThemeBackground ); + }, [ savedThemeBackground ] ); + useEffect( () => { + setDraftThemeForeground( savedThemeForeground ); + }, [ savedThemeForeground ] ); + useEffect( () => { + setDraftThemeAccent( savedThemeAccent ); + }, [ savedThemeAccent ] ); + useEffect( () => { + setDraftThemeAccentFg( savedThemeAccentFg ); + }, [ savedThemeAccentFg ] ); + + const savedHasTheme = Boolean( + savedThemeBackground || + savedThemeForeground || + savedThemeAccent || + savedThemeAccentFg + ); + const [ draftHasTheme, setDraftHasTheme ] = useState( savedHasTheme ); + useEffect( () => { + setDraftHasTheme( savedHasTheme ); + }, [ savedHasTheme ] ); + + const isPublicationDirty = + draftName !== savedName || + draftDescription !== savedDescription || + ( draftIconId || null ) !== ( savedIconId || null ) || + draftHasTheme !== savedHasTheme || + ( draftHasTheme && + ( draftThemeBackground !== savedThemeBackground || + draftThemeForeground !== savedThemeForeground || + draftThemeAccent !== savedThemeAccent || + draftThemeAccentFg !== savedThemeAccentFg ) ); + + const handleToggleTheme = ( isOn ) => { + setDraftHasTheme( isOn ); + if ( isOn ) { + // Seed sensible defaults for any slot that's still empty. + if ( ! draftThemeBackground ) setDraftThemeBackground( '#ffffff' ); + if ( ! draftThemeForeground ) setDraftThemeForeground( '#111111' ); + if ( ! draftThemeAccent ) setDraftThemeAccent( '#0073aa' ); + if ( ! draftThemeAccentFg ) setDraftThemeAccentFg( '#ffffff' ); + } + // Leave drafts populated when toggling off so flipping back on + // restores the user's last picks. The save handler gates on draftHasTheme. + }; + + const handleUpdatePublication = () => { + const next = {}; + if ( draftName.trim() !== '' ) { + next.name = draftName; + } + if ( draftDescription.trim() !== '' ) { + next.description = draftDescription; + } + if ( draftIconId ) { + next.icon_id = Number( draftIconId ); + } + if ( draftHasTheme ) { + if ( draftThemeBackground.trim() !== '' ) { + next.theme_background = draftThemeBackground; + } + if ( draftThemeForeground.trim() !== '' ) { + next.theme_foreground = draftThemeForeground; + } + if ( draftThemeAccent.trim() !== '' ) { + next.theme_accent = draftThemeAccent; + } + if ( draftThemeAccentFg.trim() !== '' ) { + next.theme_accent_foreground = draftThemeAccentFg; + } + } + savePublicationOverrides( next ); + }; + + const openIconPicker = () => { + if ( ! window.wp?.media ) { + return; + } + const frame = window.wp.media( { + title: __( 'Select publication icon', 'autoblue' ), + button: { text: __( 'Use this icon', 'autoblue' ) }, + library: { type: 'image' }, + multiple: false, + } ); + frame.on( 'select', () => { + const attachment = frame + .state() + .get( 'selection' ) + .first() + .toJSON(); + setDraftIconId( attachment.id ); + setDraftIconUrl( + attachment.sizes?.thumbnail?.url || + attachment.sizes?.medium?.url || + attachment.url || + '' + ); + } ); + frame.open(); + }; + + const handleRemoveIcon = () => { + setDraftIconId( null ); + setDraftIconUrl( '' ); + }; + + const currentIconUrl = + draftIconUrl || standardSitePublication.siteIconUrl || ''; + + const themePalette = [ + { + slug: 'background', + name: __( 'Background', 'autoblue' ), + color: draftThemeBackground || 'transparent', + }, + { + slug: 'foreground', + name: __( 'Foreground', 'autoblue' ), + color: draftThemeForeground || 'transparent', + }, + { + slug: 'accent', + name: __( 'Accent', 'autoblue' ), + color: draftThemeAccent || 'transparent', + }, + { + slug: 'accent_foreground', + name: __( 'Accent foreground', 'autoblue' ), + color: draftThemeAccentFg || 'transparent', + }, + ]; + + const themeSetters = { + background: setDraftThemeBackground, + foreground: setDraftThemeForeground, + accent: setDraftThemeAccent, + accent_foreground: setDraftThemeAccentFg, + }; + + const handleThemeChange = ( nextPalette ) => { + if ( ! Array.isArray( nextPalette ) ) return; + nextPalette.forEach( ( entry ) => { + const setter = themeSetters[ entry.slug ]; + if ( ! setter ) return; + const raw = entry.color || ''; + // Transparent (our sentinel for "no override") maps back to empty. + setter( raw === 'transparent' ? '' : raw ); + } ); + }; + return ( <> { ) } + { hasAccounts && ( + + + + { ! isRootInstall ? ( + + { __( + 'standard.site requires WordPress to be installed at your domain root. This install lives in a subdirectory, so the feature is unavailable.', + 'autoblue' + ) } + + ) : ( + + { isLoadingStandardSite ? ( + + ) : ( + + ) } + { isStandardSiteEnabled && ( + + + + +
+ + + { currentIconUrl ? ( + + ) : ( +
+ ) } + + { draftIconId && ( + + ) } + + + + + + { draftHasTheme && ( + <> +
+ +
+ + { __( + 'Theme colors are used by standard.site readers to style your publication.', + 'autoblue' + ) } + + + ) } +
+
+
+ + + { isSaving && } + + + ) } + + ) } + + +
+ ) } { createInterpolateElement( __( diff --git a/src/js/components/settings/styles.module.scss b/src/js/components/settings/styles.module.scss index 552be21..76d44f9 100644 --- a/src/js/components/settings/styles.module.scss +++ b/src/js/components/settings/styles.module.scss @@ -5,3 +5,37 @@ padding: 10px; } } + +.iconPreview, +.iconPlaceholder { + width: 64px; + height: 64px; + border-radius: 8px; + object-fit: cover; + background: #f1f1f1; + flex-shrink: 0; +} + +.iconPlaceholder { + border: 1px dashed rgba(0, 0, 0, 0.15); +} + +.split { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: flex-start; + + > * { + flex: 1 1 240px; + min-width: 0; + } +} + +.palette { + // Hide PaletteEdit's built-in header row (empty

+ 3-dot menu). + // canOnlyChangeValues makes the menu inert and the heading is empty. + > div > div:first-child { + display: none; + } +} diff --git a/src/js/components/share-panel/index.js b/src/js/components/share-panel/index.js index bcbb591..a1bdc49 100644 --- a/src/js/components/share-panel/index.js +++ b/src/js/components/share-panel/index.js @@ -13,23 +13,30 @@ import AccountInfo from './../account-info'; import PublishedPostPanel from './../published-post-panel'; import useConnectAccountModal from './../connect-account-modal'; import useAccounts from './../../hooks/use-accounts'; +import useSettings from './../../hooks/use-settings'; import styles from './styles.module.scss'; const SharePanel = () => { const { accounts } = useAccounts(); const { renderModal, openModal } = useConnectAccountModal(); const { editPost } = useDispatch( 'core/editor' ); + const { isStandardSiteEnabled, isRootInstall } = useSettings(); - const { postStatus, isEnabled, customMessage } = useSelect( ( select ) => { - const { getEditedPostAttribute } = select( 'core/editor' ); + const { postStatus, isEnabled, customMessage, publishDocument } = useSelect( + ( select ) => { + const { getEditedPostAttribute } = select( 'core/editor' ); - return { - postStatus: getEditedPostAttribute( 'status' ), - isEnabled: getEditedPostAttribute( 'meta' )?.autoblue_enabled, - customMessage: - getEditedPostAttribute( 'meta' )?.autoblue_custom_message, - }; - }, [] ); + return { + postStatus: getEditedPostAttribute( 'status' ), + isEnabled: getEditedPostAttribute( 'meta' )?.autoblue_enabled, + customMessage: + getEditedPostAttribute( 'meta' )?.autoblue_custom_message, + publishDocument: + getEditedPostAttribute( 'meta' )?.autoblue_publish_document, + }; + }, + [] + ); if ( postStatus === 'publish' ) { return ; @@ -58,8 +65,32 @@ const SharePanel = () => { } ); }; + const setPublishDocument = ( value ) => { + editPost( { + meta: { autoblue_publish_document: value }, + } ); + }; + const hasBrokenConnection = accounts.some( ( a ) => a.needs_reauth ); const effectiveIsEnabled = isEnabled && ! hasBrokenConnection; + const standardSiteDisabledReason = ! isRootInstall + ? __( + 'standard.site is not available on subdirectory installs.', + 'autoblue' + ) + : ! isStandardSiteEnabled + ? __( + 'Enable standard.site publishing in Autoblue settings.', + 'autoblue' + ) + : hasBrokenConnection + ? __( + 'Reconnect your Bluesky account to publish documents.', + 'autoblue' + ) + : null; + const standardSiteToggleEnabled = + isStandardSiteEnabled && isRootInstall && ! hasBrokenConnection; return ( @@ -86,6 +117,25 @@ const SharePanel = () => { onChange={ setIsEnabled } disabled={ hasBrokenConnection } /> + { effectiveIsEnabled && ( + + ) } { effectiveIsEnabled && ( <> { 'site', 'autoblue_enabled_post_types' ); + const [ standardSiteEnabled, setStandardSiteEnabledFn ] = useEntityProp( + 'root', + 'site', + 'autoblue_publish_documents_enabled' + ); + const [ publicationOverrides, setPublicationOverridesFn ] = useEntityProp( + 'root', + 'site', + 'autoblue_publication_overrides' + ); const { saveEditedEntityRecord } = useDispatch( 'core' ); const { createSuccessNotice, removeNotice } = useDispatch( noticesStore ); @@ -64,6 +74,42 @@ const useSettings = () => { } catch ( error ) {} }; + const setIsStandardSiteEnabled = async ( value ) => { + if ( isSaving ) { + return; + } + try { + setStandardSiteEnabledFn( value ); + await saveEditedEntityRecord( 'root', 'site' ); + } catch ( error ) {} + }; + + const resolvedOverrides = + publicationOverrides && typeof publicationOverrides === 'object' + ? publicationOverrides + : {}; + + const savePublicationOverrides = async ( next ) => { + if ( isSaving ) { + return; + } + try { + setPublicationOverridesFn( next ); + await saveEditedEntityRecord( 'root', 'site' ); + + const notice = await createSuccessNotice( + __( 'Publication updated.', 'autoblue' ), + { type: 'snackbar' } + ); + setTimeout( () => { + removeNotice( notice.notice.id ); + }, 2000 ); + } catch ( error ) {} + }; + + const standardSiteInitial = + autoblue?.initialState?.settings?.standardSite || {}; + return { isEnabled: isEnabled !== undefined && isEnabled !== null @@ -75,6 +121,13 @@ const useSettings = () => { availablePostTypes: autoblue?.initialState?.settings?.availablePostTypes || [], togglePostType, + isStandardSiteEnabled: !! standardSiteEnabled, + isLoadingStandardSite: standardSiteEnabled === undefined, + setIsStandardSiteEnabled, + publicationOverrides: resolvedOverrides, + savePublicationOverrides, + standardSitePublication: standardSiteInitial.publication || {}, + isRootInstall: standardSiteInitial.isRootInstall !== false, isSaving, }; }; diff --git a/tests/wpunit/BlueskyTest.php b/tests/wpunit/BlueskyTest.php index 41388e4..9d5f4ee 100644 --- a/tests/wpunit/BlueskyTest.php +++ b/tests/wpunit/BlueskyTest.php @@ -66,6 +66,77 @@ public function test_share_message_filter_applies() { $this->bluesky->share_to_bluesky( $post_id ); } + public function test_standard_site_share_uses_atomic_apply_writes_and_document_strong_ref() { + $post_id = $this->create_test_post( self::ORIGINAL_MESSAGE ); + $this->enable_standard_site_for_post( $post_id ); + + $this->log_mock->shouldReceive( 'success' ); + $this->api_mock->shouldReceive( 'apply_writes' ) + ->once() + ->withArgs( + static function ( $writes ) { + return count( $writes ) === 2 + && $writes[0]['collection'] === 'app.bsky.feed.post' + && $writes[1]['collection'] === 'site.standard.document' + && ! isset( $writes[0]['value']['embed']['external']['associatedRefs'] ) + && ! isset( $writes[1]['value']['bskyPostRef'] ); + } + ) + ->andReturn( + [ + 'results' => [ + [ + 'uri' => 'at://mock-did/app.bsky.feed.post/bsky123', + 'cid' => 'bsky-cid', + ], + [ + 'uri' => 'at://mock-did/site.standard.document/doc123', + 'cid' => 'doc-cid', + ], + ], + ] + ); + $this->api_mock->shouldReceive( 'put_record' ) + ->once() + ->withArgs( + static function ( $body ) { + return $body['collection'] === 'site.standard.document' + && $body['record']['bskyPostRef']['uri'] === 'at://mock-did/app.bsky.feed.post/bsky123' + && $body['record']['bskyPostRef']['cid'] === 'bsky-cid'; + } + ) + ->andReturn( + [ + 'uri' => 'at://mock-did/site.standard.document/doc123', + 'cid' => 'doc-cid-with-bsky-ref', + ] + ); + + $share = $this->bluesky->share_to_bluesky( $post_id ); + $doc = get_post_meta( $post_id, 'autoblue_document', true ); + + $this->assertSame( 'at://mock-did/app.bsky.feed.post/bsky123', $share['uri'] ); + $this->assertSame( 'at://mock-did/site.standard.document/doc123', $doc['uri'] ); + $this->assertSame( 'doc-cid-with-bsky-ref', $doc['cid'] ); + } + + public function test_standard_site_apply_writes_failure_does_not_store_document_uri() { + $post_id = $this->create_test_post( self::ORIGINAL_MESSAGE ); + $this->enable_standard_site_for_post( $post_id ); + + $this->log_mock->shouldReceive( 'error' )->once(); + $this->api_mock->shouldReceive( 'apply_writes' ) + ->once() + ->andReturn( new \WP_Error( 'mock_apply_writes_failed', 'No records were written.' ) ); + $this->api_mock->shouldReceive( 'put_record' )->never(); + + $this->assertFalse( $this->bluesky->share_to_bluesky( $post_id ) ); + + $doc = get_post_meta( $post_id, 'autoblue_document', true ); + $this->assertIsArray( $doc ); + $this->assertArrayNotHasKey( 'uri', $doc ); + } + private function create_test_post( string $original_message ): int { return wp_insert_post( [ @@ -93,8 +164,26 @@ static function ( $body ) use ( $expected_message ) { ); } + private function enable_standard_site_for_post( int $post_id ): void { + update_option( 'autoblue_publish_documents_enabled', true ); + update_option( 'autoblue_connections', [ self::MOCK_CONNECTION ] ); + update_option( + 'autoblue_publication_record', + [ + 'did' => self::MOCK_CONNECTION['did'], + 'uri' => 'at://mock-did/site.standard.publication/pub123', + 'cid' => 'pub-cid', + 'rkey' => 'pub123', + ] + ); + update_post_meta( $post_id, 'autoblue_publish_document', true ); + } + protected function tearDown(): void { remove_all_filters( 'autoblue/share_message' ); + delete_option( 'autoblue_publish_documents_enabled' ); + delete_option( 'autoblue_connections' ); + delete_option( 'autoblue_publication_record' ); Mockery::close(); parent::tearDown(); } diff --git a/tests/wpunit/Standard/DocumentTest.php b/tests/wpunit/Standard/DocumentTest.php new file mode 100644 index 0000000..9096ad8 --- /dev/null +++ b/tests/wpunit/Standard/DocumentTest.php @@ -0,0 +1,108 @@ + 'at://did:plc:testuser/app.bsky.feed.post/post123', + 'cid' => 'bafyreitestcid', + ]; + + public function test_build_record_has_required_fields(): void { + $post_id = $this->insert_post( 'Article Title', 'Body content here.' ); + $post = get_post( $post_id ); + + $record = ( new Document() )->build_record( $post, self::PUBLICATION_URI, self::BSKY_REF, null ); + + $this->assertSame( 'site.standard.document', $record['$type'] ); + $this->assertSame( self::PUBLICATION_URI, $record['site'] ); + $this->assertSame( 'Article Title', $record['title'] ); + $this->assertArrayHasKey( 'publishedAt', $record ); + $this->assertNotEmpty( $record['publishedAt'] ); + } + + public function test_build_record_includes_bsky_post_ref(): void { + $post_id = $this->insert_post( 'Article Title', 'Body content here.' ); + $post = get_post( $post_id ); + + $record = ( new Document() )->build_record( $post, self::PUBLICATION_URI, self::BSKY_REF, null ); + + $this->assertSame( self::BSKY_REF['uri'], $record['bskyPostRef']['uri'] ); + $this->assertSame( self::BSKY_REF['cid'], $record['bskyPostRef']['cid'] ); + } + + public function test_build_record_truncates_tags_to_8(): void { + $post_id = $this->insert_post( 'Article', 'Body.' ); + + $slugs = []; + for ( $i = 1; $i <= 12; $i++ ) { + $slugs[] = "tag-{$i}"; + } + wp_set_post_tags( $post_id, $slugs ); + + $post = get_post( $post_id ); + $record = ( new Document() )->build_record( $post, self::PUBLICATION_URI, self::BSKY_REF, null ); + + $this->assertCount( 8, $record['tags'] ); + } + + public function test_build_record_omits_cover_image_when_missing(): void { + $post_id = $this->insert_post( 'Article', 'Body.' ); + $post = get_post( $post_id ); + + $record = ( new Document() )->build_record( $post, self::PUBLICATION_URI, self::BSKY_REF, null ); + + $this->assertArrayNotHasKey( 'coverImage', $record ); + } + + public function test_build_record_includes_cover_image_when_provided(): void { + $post_id = $this->insert_post( 'Article', 'Body.' ); + $post = get_post( $post_id ); + + $blob = [ + '$type' => 'blob', + 'ref' => [ '$link' => 'bafyreiblob' ], + 'mimeType' => 'image/jpeg', + 'size' => 1024, + ]; + $record = ( new Document() )->build_record( $post, self::PUBLICATION_URI, self::BSKY_REF, $blob ); + + $this->assertSame( $blob, $record['coverImage'] ); + } + + public function test_build_record_includes_text_content(): void { + $post_id = $this->insert_post( 'Article', '

Hello world.

' ); + $post = get_post( $post_id ); + + $record = ( new Document() )->build_record( $post, self::PUBLICATION_URI, self::BSKY_REF, null ); + + $this->assertArrayHasKey( 'textContent', $record ); + $this->assertStringContainsString( 'Hello world.', $record['textContent'] ); + $this->assertStringNotContainsString( '', $record['textContent'] ); + } + + public function test_build_record_includes_path(): void { + $post_id = $this->insert_post( 'Article', 'Body.' ); + $post = get_post( $post_id ); + + $record = ( new Document() )->build_record( $post, self::PUBLICATION_URI, self::BSKY_REF, null ); + + $this->assertArrayHasKey( 'path', $record ); + $this->assertStringStartsWith( '/', $record['path'] ); + } + + private function insert_post( string $title, string $content ): int { + return wp_insert_post( + [ + 'post_title' => $title, + 'post_content' => $content, + 'post_status' => 'publish', + 'post_type' => 'post', + ] + ); + } +} diff --git a/tests/wpunit/Standard/PublicationTest.php b/tests/wpunit/Standard/PublicationTest.php new file mode 100644 index 0000000..3bcd4df --- /dev/null +++ b/tests/wpunit/Standard/PublicationTest.php @@ -0,0 +1,126 @@ + 'did:plc:testuser', + 'access_jwt' => 'test-access', + 'refresh_jwt' => 'test-refresh', + ]; + + public function test_build_record_uses_wp_defaults_when_no_overrides(): void { + update_option( 'blogname', 'My Blog' ); + update_option( 'blogdescription', 'Just my thoughts' ); + delete_option( 'autoblue_publication_overrides' ); + + $record = ( new Publication() )->build_record( self::CONNECTION ); + + $this->assertSame( 'site.standard.publication', $record['$type'] ); + $this->assertSame( 'My Blog', $record['name'] ); + $this->assertSame( 'Just my thoughts', $record['description'] ); + $this->assertSame( untrailingslashit( home_url( '/' ) ), $record['url'] ); + } + + public function test_build_record_uses_overrides_when_set(): void { + update_option( 'blogname', 'My Blog' ); + update_option( 'blogdescription', 'Just my thoughts' ); + update_option( + 'autoblue_publication_overrides', + [ + 'name' => 'Custom Publication', + 'description' => 'Custom description', + ] + ); + + $record = ( new Publication() )->build_record( self::CONNECTION ); + + $this->assertSame( 'Custom Publication', $record['name'] ); + $this->assertSame( 'Custom description', $record['description'] ); + } + + public function test_build_record_falls_back_per_field(): void { + update_option( 'blogname', 'My Blog' ); + update_option( 'blogdescription', 'Just my thoughts' ); + update_option( + 'autoblue_publication_overrides', + [ + 'name' => 'Custom Publication', + // Description not overridden. + ] + ); + + $record = ( new Publication() )->build_record( self::CONNECTION ); + + $this->assertSame( 'Custom Publication', $record['name'] ); + $this->assertSame( 'Just my thoughts', $record['description'] ); + } + + public function test_build_record_treats_empty_string_override_as_unset(): void { + update_option( 'blogname', 'My Blog' ); + update_option( + 'autoblue_publication_overrides', + [ + 'name' => ' ', + ] + ); + + $record = ( new Publication() )->build_record( self::CONNECTION ); + + $this->assertSame( 'My Blog', $record['name'] ); + } + + public function test_build_record_omits_description_when_blank(): void { + update_option( 'blogname', 'My Blog' ); + update_option( 'blogdescription', '' ); + delete_option( 'autoblue_publication_overrides' ); + + $record = ( new Publication() )->build_record( self::CONNECTION ); + + $this->assertArrayNotHasKey( 'description', $record ); + } + + public function test_get_uri_returns_stored_uri(): void { + update_option( + 'autoblue_publication_record', + [ + 'did' => self::CONNECTION['did'], + 'uri' => 'at://did:plc:testuser/site.standard.publication/abc123', + ] + ); + + $this->assertSame( + 'at://did:plc:testuser/site.standard.publication/abc123', + ( new Publication() )->get_uri() + ); + } + + public function test_get_uri_returns_null_when_unset(): void { + delete_option( 'autoblue_publication_record' ); + + $this->assertNull( ( new Publication() )->get_uri() ); + } + + public function test_clear_removes_stored_record(): void { + update_option( + 'autoblue_publication_record', + [ + 'did' => self::CONNECTION['did'], + 'uri' => 'at://did:plc:testuser/site.standard.publication/abc123', + ] + ); + + ( new Publication() )->clear(); + + $this->assertSame( [], get_option( 'autoblue_publication_record', [] ) ); + } + + protected function tearDown(): void { + delete_option( 'autoblue_publication_overrides' ); + delete_option( 'autoblue_publication_record' ); + parent::tearDown(); + } +} diff --git a/tests/wpunit/Standard/VerificationTest.php b/tests/wpunit/Standard/VerificationTest.php new file mode 100644 index 0000000..db031d4 --- /dev/null +++ b/tests/wpunit/Standard/VerificationTest.php @@ -0,0 +1,66 @@ + 'Article', + 'post_content' => 'Body', + 'post_status' => 'publish', + 'post_type' => 'post', + ] + ); + update_post_meta( + $post_id, + Verification::DOCUMENT_META, + [ + 'uri' => 'at://did:plc:testuser/site.standard.document/doc123', + 'cid' => 'bafyreitest', + 'rkey' => 'doc123', + ] + ); + + $this->go_to( get_permalink( $post_id ) ); + + ob_start(); + ( new Verification() )->inject_document_link(); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'rel="site.standard.document"', $output ); + $this->assertStringContainsString( 'at://did:plc:testuser/site.standard.document/doc123', $output ); + } + + public function test_inject_document_link_skipped_on_singular_without_document(): void { + $post_id = wp_insert_post( + [ + 'post_title' => 'Article', + 'post_content' => 'Body', + 'post_status' => 'publish', + 'post_type' => 'post', + ] + ); + + $this->go_to( get_permalink( $post_id ) ); + + ob_start(); + ( new Verification() )->inject_document_link(); + $output = ob_get_clean(); + + $this->assertSame( '', $output ); + } + + public function test_inject_document_link_skipped_when_not_singular(): void { + $this->go_to( home_url( '/' ) ); + + ob_start(); + ( new Verification() )->inject_document_link(); + $output = ob_get_clean(); + + $this->assertSame( '', $output ); + } +} diff --git a/tests/wpunit/UtilsTest.php b/tests/wpunit/UtilsTest.php index 6b6ec07..938aeff 100644 --- a/tests/wpunit/UtilsTest.php +++ b/tests/wpunit/UtilsTest.php @@ -25,8 +25,41 @@ public function test_get_enabled_post_types_coerces_non_array_to_default(): void $this->assertSame( [ 'post' ], Utils::get_enabled_post_types() ); } + public function test_is_root_install_true_for_root(): void { + // Default test install is at root. + $this->assertTrue( Utils::is_root_install() ); + } + + public function test_is_standard_site_enabled_requires_global_toggle(): void { + update_option( + 'autoblue_connections', + [ + [ + 'did' => 'did:plc:abc', + 'access_jwt' => 'a', + 'refresh_jwt' => 'r', + ], + ] + ); + + delete_option( 'autoblue_publish_documents_enabled' ); + $this->assertFalse( Utils::is_standard_site_enabled() ); + + update_option( 'autoblue_publish_documents_enabled', true ); + $this->assertTrue( Utils::is_standard_site_enabled() ); + } + + public function test_is_standard_site_enabled_requires_a_connection(): void { + update_option( 'autoblue_publish_documents_enabled', true ); + update_option( 'autoblue_connections', [] ); + + $this->assertFalse( Utils::is_standard_site_enabled() ); + } + protected function tearDown(): void { delete_option( 'autoblue_enabled_post_types' ); + delete_option( 'autoblue_publish_documents_enabled' ); + delete_option( 'autoblue_connections' ); parent::tearDown(); } }