From 39b156d26f5a9e740a8dc8a4905e801502afdec5 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Wed, 24 Dec 2025 15:22:24 +0100 Subject: [PATCH 1/5] WIP: notes for image blocks & paragraphs with links --- .../providers/class-content-review.php | 494 +++++++++++++++++- 1 file changed, 492 insertions(+), 2 deletions(-) diff --git a/classes/suggested-tasks/providers/class-content-review.php b/classes/suggested-tasks/providers/class-content-review.php index f0898f5865..bd16f188c9 100644 --- a/classes/suggested-tasks/providers/class-content-review.php +++ b/classes/suggested-tasks/providers/class-content-review.php @@ -16,6 +16,13 @@ class Content_Review extends Tasks { use Dismissable_Task; + /** + * The note prefix used to identify Progress Planner notes. + * + * @var string + */ + public const NOTE_PREFIX = '[PRPL]'; + /** * The capability required to perform the task. * @@ -116,6 +123,33 @@ public function init() { } $this->init_dismissable_task(); + + // Handle note injection. + if ( $this->supports_notes() ) { + \add_action( 'load-post.php', [ $this, 'maybe_inject_notes' ] ); + } + } + + /** + * Maybe inject notes when editing a post. + * + * @return void + */ + public function maybe_inject_notes() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- No action taken, just injecting notes. + if ( ! isset( $_GET['prpl_inject_notes'] ) ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $post_id = isset( $_GET['post'] ) ? (int) $_GET['post'] : 0; + if ( ! $post_id || ! \current_user_can( 'edit_post', $post_id ) ) { + return; + } + + // Inject all note types. + $this->inject_image_review_notes( $post_id ); + $this->inject_link_review_notes( $post_id ); } /** @@ -498,8 +532,31 @@ protected function is_specific_task_completed( $task_id ) { $data = $task->get_data(); - return $data && isset( $data['target_post_id'] ) - && (int) \get_post_modified_time( 'U', false, (int) $data['target_post_id'] ) > \strtotime( '-12 months' ); + if ( ! $data || ! isset( $data['target_post_id'] ) ) { + return false; + } + + $target_post_id = (int) $data['target_post_id']; + + // Check if notes-based completion is applicable (WP 6.9+ and notes exist). + if ( $this->supports_notes() ) { + $notes_resolved = $this->all_notes_resolved( $target_post_id ); + + // If notes exist and all are resolved, task is complete. + if ( true === $notes_resolved ) { + return true; + } + + // If notes exist but some are open, task is not complete. + if ( false === $notes_resolved ) { + return false; + } + + // If no notes exist (null), fall back to modification time check. + } + + // Original completion check: post was modified within the last 12 months. + return (int) \get_post_modified_time( 'U', false, $target_post_id ) > \strtotime( '-12 months' ); } /** @@ -576,8 +633,441 @@ public function add_task_actions( $data = [], $actions = [] ) { 'priority' => 10, 'html' => '' . \esc_html__( 'Review', 'progress-planner' ) . '', ]; + + // Add "Review with Notes" action for WordPress 6.9+. + if ( $this->supports_notes() ) { + $actions[] = [ + 'priority' => 11, + 'html' => '' . \esc_html__( 'Review with Notes', 'progress-planner' ) . '', + ]; + } } return $actions; } + + /** + * Check if WordPress supports the Notes feature (6.9+). + * + * @return bool + */ + public function supports_notes() { + global $wp_version; + return \version_compare( $wp_version, '6.9', '>=' ); + } + + /** + * Create a note on a specific post. + * + * @param int $post_id The post ID to attach the note to. + * @param string $content The note content. + * @param int $parent_id Parent note ID for threading (0 for top-level). + * + * @return int|false The note (comment) ID or false on failure. + */ + public function create_note( $post_id, $content, $parent_id = 0 ) { + $note_data = [ + 'comment_post_ID' => $post_id, + 'comment_content' => $content, + 'comment_type' => 'note', + 'comment_approved' => 0, // 0 = open, 1 = resolved. + 'comment_parent' => $parent_id, + 'user_id' => \get_current_user_id(), + ]; + + // Bypass filters that might interfere with note creation. + \add_filter( 'duplicate_comment_id', '__return_false' ); + \add_filter( 'comment_flood_filter', '__return_false' ); + + $note_id = \wp_insert_comment( $note_data ); + + \remove_filter( 'duplicate_comment_id', '__return_false' ); + \remove_filter( 'comment_flood_filter', '__return_false' ); + + return $note_id; + } + + /** + * Get all Progress Planner notes for a post. + * + * @param int $post_id The post ID. + * @param string $status 'open' for unresolved, 'resolved' for resolved, 'all' for both. + * + * @return array Array of note comments. + */ + public function get_notes( $post_id, $status = 'all' ) { + $args = [ + 'post_id' => $post_id, + 'type' => 'note', + 'search' => static::NOTE_PREFIX, // Only get our notes. + ]; + + if ( 'open' === $status ) { + $args['status'] = 'hold'; // WordPress maps 0 to 'hold'. + } elseif ( 'resolved' === $status ) { + $args['status'] = 'approve'; // WordPress maps 1 to 'approve'. + } + + return \get_comments( $args ); + } + + /** + * Check if all Progress Planner notes are resolved for a post. + * + * @param int $post_id The post ID. + * + * @return bool|null True if all resolved, false if open notes exist, null if no notes. + */ + public function all_notes_resolved( $post_id ) { + $all_notes = $this->get_notes( $post_id, 'all' ); + + if ( empty( $all_notes ) ) { + return null; // No notes created yet. + } + + $open_notes = $this->get_notes( $post_id, 'open' ); + + return empty( $open_notes ); + } + + /** + * Find image blocks in a post. + * + * @param int $post_id The post ID. + * + * @return array Array of image block data. + */ + public function find_image_blocks( $post_id ) { + $post = \get_post( $post_id ); + if ( ! $post || ! \has_blocks( $post->post_content ) ) { + return []; + } + + $blocks = \parse_blocks( $post->post_content ); + $image_blocks = []; + + foreach ( $blocks as $index => $block ) { + if ( 'core/image' === $block['blockName'] ) { + $image_blocks[] = [ + 'index' => $index, + 'block' => $block, + 'alt' => $block['attrs']['alt'] ?? '', + 'id' => $block['attrs']['id'] ?? 0, + ]; + } + + // Also check inner blocks (for groups, columns, etc.). + if ( ! empty( $block['innerBlocks'] ) ) { + $inner_images = $this->find_image_blocks_recursive( $block['innerBlocks'], $index ); + $image_blocks = \array_merge( $image_blocks, $inner_images ); + } + } + + return $image_blocks; + } + + /** + * Recursively find image blocks in inner blocks. + * + * @param array $blocks The blocks to search. + * @param string $parent_index The parent block index. + * + * @return array Array of image block data. + */ + protected function find_image_blocks_recursive( $blocks, $parent_index ) { + $image_blocks = []; + + foreach ( $blocks as $index => $block ) { + if ( 'core/image' === $block['blockName'] ) { + $image_blocks[] = [ + 'index' => "{$parent_index}.{$index}", + 'block' => $block, + 'alt' => $block['attrs']['alt'] ?? '', + 'id' => $block['attrs']['id'] ?? 0, + ]; + } + + if ( ! empty( $block['innerBlocks'] ) ) { + $inner = $this->find_image_blocks_recursive( $block['innerBlocks'], "{$parent_index}.{$index}" ); + $image_blocks = \array_merge( $image_blocks, $inner ); + } + } + + return $image_blocks; + } + + /** + * Inject review notes for images on a post. + * + * @param int $post_id The post ID to inject notes on. + * + * @return array Array of created note IDs. + */ + public function inject_image_review_notes( $post_id ) { + $post = \get_post( $post_id ); + if ( ! $post || ! \has_blocks( $post->post_content ) ) { + return []; + } + + $blocks = \parse_blocks( $post->post_content ); + $created_notes = []; + $image_num = 0; + $modified = false; + + $blocks = $this->process_blocks_for_notes( $blocks, $post_id, $created_notes, $image_num, $modified ); + + // Update post content with block metadata if we created notes. + if ( $modified && ! empty( $created_notes ) ) { + $new_content = \serialize_blocks( $blocks ); + + \wp_update_post( + [ + 'ID' => $post_id, + 'post_content' => $new_content, + ] + ); + } + + return $created_notes; + } + + /** + * Process blocks recursively to add notes to image blocks. + * + * @param array $blocks The blocks to process. + * @param int $post_id The post ID. + * @param array $created_notes Array to collect created note IDs (passed by reference). + * @param int $image_num Image counter (passed by reference). + * @param bool $modified Whether any blocks were modified (passed by reference). + * + * @return array The processed blocks. + */ + protected function process_blocks_for_notes( $blocks, $post_id, &$created_notes, &$image_num, &$modified ) { + foreach ( $blocks as $index => $block ) { + if ( 'core/image' === $block['blockName'] ) { + ++$image_num; + + // Skip if block already has a valid note (exists in database). + $existing_note_id = $block['attrs']['metadata']['noteId'] ?? 0; + if ( $existing_note_id && \get_comment( $existing_note_id ) ) { + continue; + } + + $alt_text = $block['attrs']['alt'] ?? ''; + $alt_text_str = $alt_text ? " (alt: \"{$alt_text}\")" : ' (no alt text)'; + $is_linked = $this->image_block_has_link( $block ); + + if ( $is_linked ) { + $note_content = \sprintf( + /* translators: %1$s: Note prefix, %2$d: Image number, %3$s: Alt text info. */ + \__( '%1$s Review Image %2$d%3$s: This image is linked. Check that both the image and link are still relevant and working.', 'progress-planner' ), + static::NOTE_PREFIX, + $image_num, + $alt_text_str + ); + } else { + $note_content = \sprintf( + /* translators: %1$s: Note prefix, %2$d: Image number, %3$s: Alt text info. */ + \__( '%1$s Review Image %2$d%3$s: Is this image still relevant and displaying correctly?', 'progress-planner' ), + static::NOTE_PREFIX, + $image_num, + $alt_text_str + ); + } + + $note_id = $this->create_note( $post_id, $note_content ); + + if ( $note_id ) { + $created_notes[] = $note_id; + + // Add note ID to block metadata. + if ( ! isset( $blocks[ $index ]['attrs']['metadata'] ) ) { + $blocks[ $index ]['attrs']['metadata'] = []; + } + $blocks[ $index ]['attrs']['metadata']['noteId'] = $note_id; + + $modified = true; + } + } + + // Process inner blocks recursively. + if ( ! empty( $block['innerBlocks'] ) ) { + $blocks[ $index ]['innerBlocks'] = $this->process_blocks_for_notes( + $block['innerBlocks'], + $post_id, + $created_notes, + $image_num, + $modified + ); + } + } + + return $blocks; + } + + /** + * Inject review notes for paragraphs with links on a post. + * + * @param int $post_id The post ID to inject notes on. + * + * @return array Array of created note IDs. + */ + public function inject_link_review_notes( $post_id ) { + $post = \get_post( $post_id ); + if ( ! $post || ! \has_blocks( $post->post_content ) ) { + return []; + } + + $blocks = \parse_blocks( $post->post_content ); + $created_notes = []; + $paragraph_num = 0; + $modified = false; + + $blocks = $this->process_blocks_for_link_notes( $blocks, $post_id, $created_notes, $paragraph_num, $modified ); + + // Update post content with block metadata if we created notes. + if ( $modified && ! empty( $created_notes ) ) { + $new_content = \serialize_blocks( $blocks ); + + \wp_update_post( + [ + 'ID' => $post_id, + 'post_content' => $new_content, + ] + ); + } + + return $created_notes; + } + + /** + * Process blocks recursively to add notes to paragraphs with links. + * + * @param array $blocks The blocks to process. + * @param int $post_id The post ID. + * @param array $created_notes Array to collect created note IDs (passed by reference). + * @param int $paragraph_num Paragraph counter (passed by reference). + * @param bool $modified Whether any blocks were modified (passed by reference). + * + * @return array The processed blocks. + */ + protected function process_blocks_for_link_notes( $blocks, $post_id, &$created_notes, &$paragraph_num, &$modified ) { + foreach ( $blocks as $index => $block ) { + // Check if this is a paragraph block with links. + if ( 'core/paragraph' === $block['blockName'] && $this->block_has_links( $block ) ) { + ++$paragraph_num; + + // Skip if block already has a valid note (exists in database). + $existing_note_id = $block['attrs']['metadata']['noteId'] ?? 0; + if ( $existing_note_id && \get_comment( $existing_note_id ) ) { + continue; + } + + $link_count = $this->count_links_in_block( $block ); + + $note_content = \sprintf( + /* translators: %1$s: Note prefix, %2$d: Paragraph number, %3$d: Number of links. */ + \_n( + '%1$s Review Paragraph %2$d: Check the %3$d link - is it still working and relevant?', + '%1$s Review Paragraph %2$d: Check the %3$d links - are they still working and relevant?', + $link_count, + 'progress-planner' + ), + static::NOTE_PREFIX, + $paragraph_num, + $link_count + ); + + $note_id = $this->create_note( $post_id, $note_content ); + + if ( $note_id ) { + $created_notes[] = $note_id; + + // Add note ID to block metadata. + if ( ! isset( $blocks[ $index ]['attrs']['metadata'] ) ) { + $blocks[ $index ]['attrs']['metadata'] = []; + } + $blocks[ $index ]['attrs']['metadata']['noteId'] = $note_id; + + $modified = true; + } + } + + // Process inner blocks recursively. + if ( ! empty( $block['innerBlocks'] ) ) { + $blocks[ $index ]['innerBlocks'] = $this->process_blocks_for_link_notes( + $block['innerBlocks'], + $post_id, + $created_notes, + $paragraph_num, + $modified + ); + } + } + + return $blocks; + } + + /** + * Check if a block contains links. + * + * @param array $block The block to check. + * + * @return bool True if block contains links. + */ + protected function block_has_links( $block ) { + $inner_html = $block['innerHTML'] ?? ''; + return false !== \strpos( $inner_html, '' ); + } + + /** + * Check if an image block is wrapped in a link. + * + * @param array $block The image block to check. + * + * @return bool True if image is linked. + */ + protected function image_block_has_link( $block ) { + // Check block attributes for link destination. + if ( ! empty( $block['attrs']['linkDestination'] ) && 'none' !== $block['attrs']['linkDestination'] ) { + return true; + } + + // Also check innerHTML for anchor tags. + $inner_html = $block['innerHTML'] ?? ''; + return false !== \strpos( $inner_html, 'get_notes( $post_id, 'all' ); + $deleted = 0; + + foreach ( $notes as $note ) { + if ( $note instanceof \WP_Comment && \wp_delete_comment( $note->comment_ID, $force_delete ) ) { + ++$deleted; + } + } + + return $deleted; + } } From a627c7214d4a7102da7bf6f3beb7e4e45bf4c2ca Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Wed, 24 Dec 2025 16:04:11 +0100 Subject: [PATCH 2/5] add avatars and apply styles --- .../providers/class-content-review.php | 62 ++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/classes/suggested-tasks/providers/class-content-review.php b/classes/suggested-tasks/providers/class-content-review.php index bd16f188c9..b0f8da3f91 100644 --- a/classes/suggested-tasks/providers/class-content-review.php +++ b/classes/suggested-tasks/providers/class-content-review.php @@ -124,9 +124,11 @@ public function init() { $this->init_dismissable_task(); - // Handle note injection. + // Handle note injection and avatar customization. if ( $this->supports_notes() ) { \add_action( 'load-post.php', [ $this, 'maybe_inject_notes' ] ); + \add_filter( 'pre_get_avatar_data', [ $this, 'filter_note_avatar' ], 10, 2 ); + \add_action( 'admin_head', [ $this, 'add_note_avatar_styles' ] ); } } @@ -656,6 +658,64 @@ public function supports_notes() { return \version_compare( $wp_version, '6.9', '>=' ); } + /** + * Filter avatar data for PRPL notes to show Progress Planner logo. + * + * @param array $args Arguments passed to get_avatar_data(). + * @param mixed $id_or_email User ID, email, WP_User, WP_Post, or WP_Comment. + * + * @return array Modified arguments. + */ + public function filter_note_avatar( $args, $id_or_email ) { + // Only process WP_Comment objects. + if ( ! $id_or_email instanceof \WP_Comment ) { + return $args; + } + + // Check if this is a note with our prefix. + if ( 'note' !== $id_or_email->comment_type ) { + return $args; + } + + if ( false === \strpos( $id_or_email->comment_content, static::NOTE_PREFIX ) ) { + return $args; + } + + // Use Progress Planner logo as avatar. + $args['url'] = \constant( 'PROGRESS_PLANNER_URL' ) . '/assets/images/icon_progress_planner.svg'; + $args['found_avatar'] = true; + + return $args; + } + + /** + * Add CSS styles for PRPL note avatars in the editor. + * + * @return void + */ + public function add_note_avatar_styles() { + $screen = \get_current_screen(); + if ( ! $screen || 'post' !== $screen->base ) { + return; + } + + $logo_url = \constant( 'PROGRESS_PLANNER_URL' ) . '/assets/images/icon_progress_planner.svg'; + ?> + + Date: Wed, 24 Dec 2025 16:58:59 +0100 Subject: [PATCH 3/5] php unit test --- tests/phpunit/test-class-content-review.php | 338 ++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 tests/phpunit/test-class-content-review.php diff --git a/tests/phpunit/test-class-content-review.php b/tests/phpunit/test-class-content-review.php new file mode 100644 index 0000000000..4192b705e6 --- /dev/null +++ b/tests/phpunit/test-class-content-review.php @@ -0,0 +1,338 @@ +content_review = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->get_task_provider( 'review-post' ); + + // Create a test post with blocks. + $this->test_post_id = $this->factory->post->create( + [ + 'post_title' => 'Test Post with Images', + 'post_content' => '
Test image

This is a test link in a paragraph.

', + 'post_status' => 'publish', + ] + ); + } + + /** + * Tear down test. + * + * @return void + */ + public function tearDown(): void { + // Delete test post and its notes. + if ( $this->test_post_id ) { + $this->content_review->delete_notes( $this->test_post_id ); + \wp_delete_post( $this->test_post_id, true ); + } + + \wp_set_current_user( 0 ); + parent::tearDown(); + } + + /** + * Test supports_notes method. + * + * @return void + */ + public function test_supports_notes() { + global $wp_version; + + $result = $this->content_review->supports_notes(); + + // Should return true for WP 6.9+ and false for earlier versions. + $expected = \version_compare( $wp_version, '6.9', '>=' ); + $this->assertEquals( $expected, $result ); + } + + /** + * Test create_note method. + * + * @return void + */ + public function test_create_note() { + $note_content = Content_Review::NOTE_PREFIX . ' Test note content'; + $note_id = $this->content_review->create_note( $this->test_post_id, $note_content ); + + $this->assertIsInt( $note_id ); + $this->assertGreaterThan( 0, $note_id ); + + // Verify the note was created correctly. + $note = \get_comment( $note_id ); + $this->assertInstanceOf( \WP_Comment::class, $note ); + $this->assertEquals( 'note', $note->comment_type ); + $this->assertEquals( $note_content, $note->comment_content ); + $this->assertEquals( '0', $note->comment_approved ); // 0 = open. + } + + /** + * Test get_notes method returns all notes. + * + * @return void + */ + public function test_get_notes_all() { + // Create two notes. + $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Note 1' ); + $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Note 2' ); + + $notes = $this->content_review->get_notes( $this->test_post_id, 'all' ); + + $this->assertCount( 2, $notes ); + } + + /** + * Test get_notes method filters by status. + * + * @return void + */ + public function test_get_notes_by_status() { + // Create an open note. + $open_note_id = $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Open note' ); + + // Create a resolved note. + $resolved_note_id = $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Resolved note' ); + \wp_update_comment( + [ + 'comment_ID' => $resolved_note_id, + 'comment_approved' => 1, // 1 = resolved. + ] + ); + + $open_notes = $this->content_review->get_notes( $this->test_post_id, 'open' ); + $this->assertCount( 1, $open_notes ); + + $resolved_notes = $this->content_review->get_notes( $this->test_post_id, 'resolved' ); + $this->assertCount( 1, $resolved_notes ); + } + + /** + * Test all_notes_resolved returns null when no notes exist. + * + * @return void + */ + public function test_all_notes_resolved_no_notes() { + $result = $this->content_review->all_notes_resolved( $this->test_post_id ); + + $this->assertNull( $result ); + } + + /** + * Test all_notes_resolved returns false when open notes exist. + * + * @return void + */ + public function test_all_notes_resolved_with_open_notes() { + $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Open note' ); + + $result = $this->content_review->all_notes_resolved( $this->test_post_id ); + + $this->assertFalse( $result ); + } + + /** + * Test all_notes_resolved returns true when all notes are resolved. + * + * @return void + */ + public function test_all_notes_resolved_all_resolved() { + $note_id = $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Note' ); + \wp_update_comment( + [ + 'comment_ID' => $note_id, + 'comment_approved' => 1, + ] + ); + + $result = $this->content_review->all_notes_resolved( $this->test_post_id ); + + $this->assertTrue( $result ); + } + + /** + * Test find_image_blocks method. + * + * @return void + */ + public function test_find_image_blocks() { + $image_blocks = $this->content_review->find_image_blocks( $this->test_post_id ); + + $this->assertCount( 1, $image_blocks ); + $this->assertEquals( 'core/image', $image_blocks[0]['block']['blockName'] ); + } + + /** + * Test find_image_blocks with nested blocks. + * + * @return void + */ + public function test_find_image_blocks_nested() { + $nested_post_id = $this->factory->post->create( + [ + 'post_title' => 'Test Post with Nested Image', + 'post_content' => '
Nested image
', + 'post_status' => 'publish', + ] + ); + + $image_blocks = $this->content_review->find_image_blocks( $nested_post_id ); + + $this->assertCount( 1, $image_blocks ); + $this->assertStringContainsString( '.', $image_blocks[0]['index'] ); // Nested index like "0.0". + + \wp_delete_post( $nested_post_id, true ); + } + + /** + * Test delete_notes method. + * + * @return void + */ + public function test_delete_notes() { + $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Note 1' ); + $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Note 2' ); + + $deleted = $this->content_review->delete_notes( $this->test_post_id ); + + $this->assertEquals( 2, $deleted ); + + $remaining_notes = $this->content_review->get_notes( $this->test_post_id, 'all' ); + $this->assertCount( 0, $remaining_notes ); + } + + /** + * Test filter_note_avatar for non-PRPL notes. + * + * @return void + */ + public function test_filter_note_avatar_non_prpl_note() { + // Create a regular comment (not a PRPL note). + $comment_id = $this->factory->comment->create( + [ + 'comment_post_ID' => $this->test_post_id, + 'comment_content' => 'Regular comment', + 'comment_type' => 'comment', + ] + ); + + $comment = \get_comment( $comment_id ); + $args = [ 'url' => 'https://gravatar.com/test' ]; + $result_args = $this->content_review->filter_note_avatar( $args, $comment ); + + // Should not modify args for regular comments. + $this->assertEquals( 'https://gravatar.com/test', $result_args['url'] ); + } + + /** + * Test filter_note_avatar for PRPL notes. + * + * @return void + */ + public function test_filter_note_avatar_prpl_note() { + $note_id = $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Test note' ); + $note = \get_comment( $note_id ); + + $args = [ 'url' => 'https://gravatar.com/test' ]; + $result_args = $this->content_review->filter_note_avatar( $args, $note ); + + // Should replace avatar URL with Progress Planner logo. + $this->assertStringContainsString( 'icon_progress_planner.svg', $result_args['url'] ); + $this->assertTrue( $result_args['found_avatar'] ); + } + + /** + * Test inject_image_review_notes method. + * + * @return void + */ + public function test_inject_image_review_notes() { + $created_notes = $this->content_review->inject_image_review_notes( $this->test_post_id ); + + $this->assertCount( 1, $created_notes ); + + // Verify the note was created. + $notes = $this->content_review->get_notes( $this->test_post_id, 'all' ); + $this->assertCount( 1, $notes ); + $this->assertStringContainsString( 'Review Image', $notes[0]->comment_content ); + } + + /** + * Test inject_image_review_notes does not create duplicate notes. + * + * @return void + */ + public function test_inject_image_review_notes_no_duplicates() { + // Inject notes twice. + $this->content_review->inject_image_review_notes( $this->test_post_id ); + $created_notes = $this->content_review->inject_image_review_notes( $this->test_post_id ); + + // Second call should not create new notes. + $this->assertCount( 0, $created_notes ); + + // Should still have only 1 note total. + $notes = $this->content_review->get_notes( $this->test_post_id, 'all' ); + $this->assertCount( 1, $notes ); + } + + /** + * Test inject_link_review_notes method. + * + * @return void + */ + public function test_inject_link_review_notes() { + $created_notes = $this->content_review->inject_link_review_notes( $this->test_post_id ); + + $this->assertCount( 1, $created_notes ); + + // Verify the note was created. + $notes = $this->content_review->get_notes( $this->test_post_id, 'all' ); + $this->assertCount( 1, $notes ); + $this->assertStringContainsString( 'Review Paragraph', $notes[0]->comment_content ); + } + + /** + * Test NOTE_PREFIX constant. + * + * @return void + */ + public function test_note_prefix_constant() { + $this->assertEquals( '[PRPL]', Content_Review::NOTE_PREFIX ); + } +} From 923316d386df824458bd59a35361770050954aa6 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Wed, 24 Dec 2025 17:02:53 +0100 Subject: [PATCH 4/5] remove notes when task is completed --- .../providers/class-content-review.php | 2 + tests/phpunit/test-class-content-review.php | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/classes/suggested-tasks/providers/class-content-review.php b/classes/suggested-tasks/providers/class-content-review.php index b0f8da3f91..caf51c5a1b 100644 --- a/classes/suggested-tasks/providers/class-content-review.php +++ b/classes/suggested-tasks/providers/class-content-review.php @@ -545,7 +545,9 @@ protected function is_specific_task_completed( $task_id ) { $notes_resolved = $this->all_notes_resolved( $target_post_id ); // If notes exist and all are resolved, task is complete. + // Clean up the notes so new ones can be created in the next review cycle. if ( true === $notes_resolved ) { + $this->delete_notes( $target_post_id ); return true; } diff --git a/tests/phpunit/test-class-content-review.php b/tests/phpunit/test-class-content-review.php index 4192b705e6..47fac7f4e3 100644 --- a/tests/phpunit/test-class-content-review.php +++ b/tests/phpunit/test-class-content-review.php @@ -335,4 +335,45 @@ public function test_inject_link_review_notes() { public function test_note_prefix_constant() { $this->assertEquals( '[PRPL]', Content_Review::NOTE_PREFIX ); } + + /** + * Test that notes are cleaned up when task is completed. + * + * @return void + */ + public function test_notes_cleaned_up_on_task_completion() { + // Create and resolve a note. + $note_id = $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Test note' ); + \wp_update_comment( + [ + 'comment_ID' => $note_id, + 'comment_approved' => 1, // Resolved. + ] + ); + + // Verify note exists before completion check. + $notes_before = $this->content_review->get_notes( $this->test_post_id, 'all' ); + $this->assertCount( 1, $notes_before ); + + // Create a task in the database so is_specific_task_completed can find it. + $task_id = 'review-post-' . $this->test_post_id; + $task_data = [ + 'task_id' => $task_id, + 'provider_id' => 'review-post', + 'target_post_id' => $this->test_post_id, + ]; + \progress_planner()->get_suggested_tasks_db()->add( $task_data ); + + // Call the method that checks completion (this should trigger cleanup). + $reflection = new \ReflectionClass( $this->content_review ); + $method = $reflection->getMethod( 'is_specific_task_completed' ); + $method->setAccessible( true ); + $is_completed = $method->invoke( $this->content_review, $task_id ); + + $this->assertTrue( $is_completed ); + + // Notes should be cleaned up after task completion. + $notes_after = $this->content_review->get_notes( $this->test_post_id, 'all' ); + $this->assertCount( 0, $notes_after ); + } } From 97f8361f32547ebd135119131831b5e6e48ffa00 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Wed, 24 Dec 2025 17:08:55 +0100 Subject: [PATCH 5/5] update php unit test --- tests/phpunit/test-class-content-review.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/phpunit/test-class-content-review.php b/tests/phpunit/test-class-content-review.php index 47fac7f4e3..dc239bb6d1 100644 --- a/tests/phpunit/test-class-content-review.php +++ b/tests/phpunit/test-class-content-review.php @@ -342,6 +342,11 @@ public function test_note_prefix_constant() { * @return void */ public function test_notes_cleaned_up_on_task_completion() { + // Skip test if notes are not supported (WP < 6.9). + if ( ! $this->content_review->supports_notes() ) { + $this->markTestSkipped( 'Notes feature requires WordPress 6.9+' ); + } + // Create and resolve a note. $note_id = $this->content_review->create_note( $this->test_post_id, Content_Review::NOTE_PREFIX . ' Test note' ); \wp_update_comment( @@ -361,6 +366,7 @@ public function test_notes_cleaned_up_on_task_completion() { 'task_id' => $task_id, 'provider_id' => 'review-post', 'target_post_id' => $this->test_post_id, + 'post_title' => 'Review Test Post', ]; \progress_planner()->get_suggested_tasks_db()->add( $task_data );