Repository: oppia/oppia
Pull request #21241: Fix #21087 : Enforce image validation in translation review
Author: login: TARishabh
Labels: name: PR: LGTM
Created: 2024-11-10T19:10:30Z | Merged: 2025-04-13T12:46:17Z

## Overview

1. This PR fixes or fixes part of #21087.
2. This PR enforces image validation in translation updates to ensure consistency in the translation review process. Specifically, it prevents reviewers from accidentally removing images that were originally present in the translation content. This change includes:

- Adding logic to detect if an image that was originally present has been removed during the editing process.
- Disabling the "Update" button when an image is removed.
- Providing a warning message to the reviewer if the removal of an image is attempted.

3. The original bug occurred because there were no checks in place to prevent the removal of images during the translation editing process. As a result, reviewers could unintentionally remove images from the translation content, leading to translations that did not preserve the original content's context or completeness. This caused issues where essential visual elements were lost, resulting in an incomplete or misleading translation. This PR adds the necessary validation to avoid such unintended removals.

## Essential Checklist

Please follow the [instructions for making a code change](https://github.com/oppia/oppia/wiki/Make-a-pull-request).

- [x] I have linked the issue that this PR fixes in the "Development" section of the sidebar.
- [x] I have checked the "Files Changed" tab and confirmed that the changes are what I want to make.
- [x] I have written tests for my code.
- [x] The **PR title** starts with "Fix #bugnum: " or "Fix part of #bugnum: ...", followed by a short, clear summary of the changes.
- [x] I have assigned the correct reviewers to this PR (or will leave a comment with the phrase "@{{reviewer_username}} PTAL" if I can't assign them directly).

## Proof that changes are correct

https://github.com/user-attachments/assets/79cc2fe8-89f3-4ae1-a544-e3c3fb4542d0

initially has No image:
https://github.com/user-attachments/assets/0a43b4ea-f06e-49c6-a0e5-18444b8b5822

#### Proof of changes on mobile phone

https://github.com/user-attachments/assets/54e0766a-36f4-40b2-9d8c-f194385b5d9f


### Proof for Backend Checks

https://github.com/user-attachments/assets/7d323c12-8aeb-4d02-8687-ad58529517b5


## PR Pointers

- Never force push! If you do, your PR will be closed.
- To reply to reviewers, follow these instructions: https://github.com/oppia/oppia/wiki/Make-a-pull-request#step-5-address-review-comments-until-all-reviewers-approve
- Some e2e tests are flaky, and can fail for reasons unrelated to your PR. We are working on fixing this, but in the meantime, if you need to restart the tests, please check the ["If your build fails" wiki page](https://github.com/oppia/oppia/wiki/If-CI-checks-fail-on-your-PR).
- See the [Code Owner's wiki page](https://github.com/oppia/oppia/wiki/Oppia's-code-owners-and-checks-to-be-carried-out-by-developers) for what code owners will expect.





Linked issue #21087: [BUG]: Removing picture is allowed when reviewing translation
### Describe the bug

Removing picture is allowed when reviewing translation
When submitting translation, users are required to keep the original picture in their translation, however, when reviewers edit the transition, they can remove the picture and no error will appear

### URL of the page where the issue is observed.

https://www.oppiatestserver.org/contributor-dashboard

### Steps To Reproduce

**Preconditions:**
user has to have translation reviewer permissions, a translation with picture is submitted to review

- Steps:

1. Log in to Oppia as a Contributor
2. Navigate to Contributor dashboard
3. Select Review Translation Tab
4. Open a translation to review (with picture)
5. Click "Edit"
6. Remove picture
7. Click "Update"
8. Click "Accept and review next"


### Expected Behavior

Error message should appear saying that removing picture is not allowed

### Screenshots/Videos


https://github.com/user-attachments/assets/38641e4f-5d02-4c1a-93ca-a3948f305362



### What device are you using?

Desktop

### Operating System

MacOS

### What browsers are you seeing the problem on?

Chrome, Firefox

### Browser version

Chrome 126.0.6478.57, Firefox 127.0

### Additional context

_No response_

### Tips for developers

Before addressing the bug, please identify which PR caused the issue (you can follow the steps [here](https://github.com/oppia/oppia/wiki/How-to-find-the-commit-which-introduced-a-bug)). If you identify the PR, comment on the issue with a link to it. If not, mention the commit hash of the oldest commit you saw the bug on (and the month and year it was made in).

Then, please leave a comment with details of the approach that you plan to take to fix the issue (see [example](https://github.com/oppia/oppia/issues/19157#issuecomment-1858788463)).

**Note:** If this is your first Oppia issue, please make sure to follow our guidelines for [choosing an issue](https://github.com/oppia/oppia/wiki/Contributing-code-to-Oppia#choosing-a-good-first-issue) and [setting things up](https://github.com/oppia/oppia/wiki/Contributing-code-to-Oppia#setting-things-up). You will also need to show a demo of the fix working correctly on your local machine. Thanks!

Linked issue comments: user: login: sksingh2005
created_at: 2024-10-15T10:05:12Z
body: Is  this issue open?
I would like to work on this issue 
author_association: NONE
user: login: HardikGoyal2003
created_at: 2024-10-15T13:33:25Z
body: Hey @sksingh2005 This issue is open to work but as per the guidance at https://github.com/oppia/oppia/wiki/Contributing-code-to-Oppia#choosing-a-good-first-issue, please provide an explanation of what your PR will do (with names of files you're changing, what you plan to change in each file, etc.). If it looks good, we can assign you to this issue.

Please also follow the other instructions on that wiki page if you have not yet done so. Thanks!
author_association: MEMBER
user: login: sahilkumar100
created_at: 2024-10-16T14:41:24Z
body: I Want to work on this issues please assign me this issues.
author_association: NONE
user: login: mon4our
created_at: 2024-10-16T15:02:38Z
body: Hey @sahilkumar100  Per the guidance at https://github.com/oppia/oppia/wiki/Contributing-code-to-Oppia#choosing-a-good-first-issue, please provide an explanation of what your PR will do (with names of files you're changing, what you plan to change in each file, etc.). If it looks good, we can assign you to this issue.

Please also follow the other instructions on that wiki page if you have not yet done so. Thanks!
author_association: MEMBER
user: login: Kanav-1822
created_at: 2024-10-18T16:51:10Z
body: Hey @mon4our  , @HardikGoyal2003 can you please guide how can I as user have translation reviewer permissions , as it is one of the precondition


author_association: NONE
user: login: ineverydomain
created_at: 2024-10-20T06:58:03Z
body: 
I've seen the issue, but edit option is not available in on the page now. I hope you have removed it.
![Screenshot 2024-10-20 121511](https://github.com/user-attachments/assets/d92acbfc-a8de-43d6-a258-3f273784f5b5)


Approach- 1
To solve the issue, my approach would be to  set the image option default to the image in the to be translated section, and if there's no image added then the default image would be displayed.

Approach -2
If the user adds a image and removes it in the edit tab then the edit tab would produce an alert to add the image, and the edit option would not work until the image is added.


author_association: NONE
user: login: HardikGoyal2003
created_at: 2024-10-21T03:56:54Z
body: @masterboy376 PTAL!! Thanks!
author_association: MEMBER
user: login: ineverydomain
created_at: 2024-10-21T05:57:19Z
body: > @masterboy376 PTAL!! Thanks!

May I get the issue to solve?
author_association: NONE
user: login: masterboy376
created_at: 2024-10-21T09:48:16Z
body: Hey @Devil1104, from the screenshot you provided above it seems like you are viewing the translation suggestion, as a submitter. Edit options only show up during translation review, by translation reviewer. You can try to reproduce the error locally.
![Screenshot (233)](https://github.com/user-attachments/assets/1217e7a9-72d7-41f3-98e0-3d1b13cebd09)

Also, could you please elaborate on the approach you are willing to choose. Per the guidance at https://github.com/oppia/oppia/wiki/Contributing-code-to-Oppia#choosing-a-good-first-issue, please provide an explanation of what your PR will do (with names of files you're changing, what you plan to change in each file, etc.). If it looks good, we can assign you to this issue.
author_association: CONTRIBUTOR
user: login: masterboy376
created_at: 2024-11-01T04:42:58Z
body: Steps to reproduce:
1. login as an admin user let us call it "a",
2. go to roll tab in admin page and give "a" curriculum admin and translation admin roll.
3. go to activities tab and generate dummy topic.
4. go to rolls tab and give "a" topic editor role for recently generated dummy topic.
5. now go to creator dashboard and create an exploration with an image and copy it is.
6. now go to topic and skill dashboard.
7. open generated dummy topic.
8. create a new story in it and a lesson inside this story, add the exploration id to the lesson, and the publish/save the story.
9. logout as "a".
10. login as a user "u".
11. go to contributor dashboard and submit a translation suggestion, in Akan language, for the lesson you added previously.
12. now logout as "u".
13. login as "a".
14. go to contributor admin dashboard and give "a" translation reviewer right in Akan language.
15. go to contributor dashboard and translation reviews tab and select Akan language in language drop down.
16. try to review the translation suggestion review which has an image.
17. while reviewing click the edit button.
18. now remove the image and click save. (this is the issue)
author_association: CONTRIBUTOR
user: login: TARishabh
created_at: 2024-11-01T18:47:01Z
body: Thanks @masterboy376 for the steps. I reproduced the steps. Here are a few possible approaches we could consider:

**1.** **Disable the Save/Update Button:**

  - Approach: If the reviewer removes the image, we keep the "Save/Update" button disabled.
  - Pros: Gives immediate feedback to the reviewer without backend calls
  - Cons: It may not clearly communicate why the button is disabled, so additional hints in the UI might be helpful.
  
 **2.** **Show an Error Modal Prompt on Save:**

-  Approach: When the reviewer tries to save without an image, a modal appears explaining that removing the image is not allowed.
- Pros: Clearly informs the reviewer why saving isn’t possible without an image.
- Cons: Adds an extra step but provides clarity on the issue.

**3.** **Backend Validation with Error Message:**

- Approach: If the reviewer submits the edit without an image, the backend responds with an error message indicating that the image is required.
- Pros: Ensures validation happens server-side, which helps keep things consistent if frontend constraints are bypassed.
- Cons: May lead to a slower response as feedback only appears after submission.

**4. Automatic Placeholder Enforcement:**

- Approach: If the image is removed, automatically replace it with a default image.
- Pros: Keeps submissions consistent without needing reviewer action.
- Cons: Could lead to confusion if reviewers aren’t aware why a placeholder is used.

**5. Warning Tooltip/Message:**

- Approach: Display a tooltip or warning message in the editor if the image is removed, reminding reviewers that the image is required.
- Pros: Non-intrusive and provides immediate feedback.
- Cons: Relies on the reviewer noticing the warning.

cc - @masterboy376 




author_association: CONTRIBUTOR
user: login: masterboy376
created_at: 2024-11-02T13:20:56Z
body: Thanks @TARishabh, 
I would have preferred approach 1 since it is less invasive and it can also clearly convey the issue to the suggestion editors, given we show a clear message in red to the users along with a disabled save button. 
Also, I am not really sure how approach 4 will look in practice, is it like the editor deletes the image and  saves but actually the image was not deleted. In this case it might give a false assurance to the editor that he/she successfully deleted the image. 
We can also think about the backend validation, but we need to verify whether we use backend validation in such scenarios somewhere in the codebase or not. 

In conclusion, we can use either the approach 1 or 3 based on what we already prefer to use in the codebase. So, it would be great if you could take a look into it. 
author_association: CONTRIBUTOR
user: login: TARishabh
created_at: 2024-11-07T20:29:31Z
body: Thanks for sharing your thoughts, @masterboy376! I agree that approach 1 makes the most sense—disabling the button with a clear red message would give reviewers immediate feedback without being too intrusive.

I'll also take a look into whether we use backend validation (approach 3) in similar cases, as it could be useful to have that extra safety net.

For now, I'll start by working on approach 1 and keep approach 3 as a backup, depending on what I find. Let me know if you have any other suggestions!"
author_association: CONTRIBUTOR
user: login: seanlip
created_at: 2024-11-08T10:27:55Z
body: Just a note, generally we try to have both frontend and backend validation for something like this. The frontend validation is so that the user experience is smooth (it is a bit jarring to get everything ready for the backend and only then learn that the submission is not valid) and the backend validation is for defense-in-depth. So I would recommend doing both, with the priority being approach 1.
author_association: MEMBER
user: login: yuanzhew-um
created_at: 2024-12-06T18:18:19Z
body: @seanlip Hi, Is this issue still open? Can I work on this?
author_association: NONE
user: login: masterboy376
created_at: 2024-12-08T12:22:52Z
body: Hey @yuanzhew-um, this issue is already assigned. You can try to find other issues. Thanks!

author_association: CONTRIBUTOR
user: login: ShivankXD
created_at: 2025-03-01T10:27:19Z
body: Hi ! is this issue still open?
author_association: NONE
user: login: masterboy376
created_at: 2025-03-01T11:11:17Z
body: Hey @ShivankXD, this issue is already assigned. You can try to find other issues that are available. Thanks!
author_association: CONTRIBUTOR
user: login: masterboy376
created_at: 2025-04-19T04:13:53Z
body: closing as completed https://github.com/oppia/oppia/pull/21241
author_association: CONTRIBUTOR

Diff:
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 7eb296e500d5e..05006329241fa 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -135,6 +135,8 @@
 /core/templates/services/html-length.service.ts @oppia/contributor-experience-team
 /core/templates/services/html-length.service.spec.ts @oppia/contributor-experience-team
 /core/templates/services/iso-datepicker-adapter.service.ts @oppia/contributor-experience-team
+/core/templates/services/translation-validation.service.ts @oppia/contributor-experience-team
+/core/templates/services/translation-validation.service.spec.ts @oppia/contributor-experience-team
 /core/templates/components/translation-suggestion-page/confirm-translation-exit-modal/confirm-translation-exit-modal.component.html @oppia/contributor-experience-team
 /core/templates/components/translation-suggestion-page/confirm-translation-exit-modal/confirm-translation-exit-modal.component.ts @oppia/contributor-experience-team
 /core/templates/components/translation-suggestion-page/confirm-translation-exit-modal/confirm-translation-exit-modal.component.spec.ts @oppia/contributor-experience-team
diff --git a/core/domain/suggestion_services.py b/core/domain/suggestion_services.py
index acf5a685629c3..9dd84de020056 100644
--- a/core/domain/suggestion_services.py
+++ b/core/domain/suggestion_services.py
@@ -24,6 +24,7 @@
 import re
 
 from core import feconf
+from core import utils
 from core.constants import constants
 from core.domain import contribution_stats_services
 from core.domain import email_manager
@@ -33,6 +34,7 @@
 from core.domain import html_validation_service
 from core.domain import opportunity_services
 from core.domain import question_domain
+from core.domain import rte_component_registry
 from core.domain import skill_services
 from core.domain import state_domain
 from core.domain import suggestion_registry
@@ -42,9 +44,11 @@
 from core.domain import user_services
 from core.platform import models
 
+import bs4
 from typing import (
     Callable, Dict, Final, List, Literal, Mapping, Match,
-    Optional, Sequence, Set, Tuple, Union, cast, overload)
+    Optional, Sequence, Set, Tuple, Union, cast, overload
+)
 
 MYPY = False
 if MYPY:  # pragma: no cover
@@ -98,6 +102,7 @@
 }
 
 RECENT_REVIEW_OUTCOMES_LIMIT: Final = 100
+MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION: Final = 100
 
 
 @overload
@@ -2142,8 +2147,91 @@ def _update_suggestion_counts_in_community_contribution_stats(
         suggestions, amount)
 
 
+def _strip_prefix(component_name: str) -> str:
+    """Removes the 'oppia-noninteractive-' prefix from component names.
+
+    Args:
+        component_name: str. The full component name.
+
+    Returns:
+        str. The component name without the prefix.
+    """
+    return component_name.removeprefix('oppia-noninteractive-')
+
+
+def highlight_differences(
+        original: str,
+        updated: str,
+        truncation_limit: int = MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION
+) -> Tuple[str, str]:
+    """Finds the first difference between two strings and truncates accordingly.
+
+    Args:
+        original: str. The original string.
+        updated: str. The updated string.
+        truncation_limit: int. Maximum length of the returned snippets.
+
+    Returns:
+        tuple. A pair of truncated strings highlighting where they differ.
+    """
+    # If the strings are identical, simply return truncated versions.
+    if original == updated:
+        return original[:truncation_limit], updated[:truncation_limit]
+
+    min_length = min(len(original), len(updated))
+
+    # Find the first index where the strings differ.
+    diff_index = min_length
+    for i in range(min_length):
+        if original[i] != updated[i]:
+            diff_index = i
+            break
+    # Calculate start_index with 10 characters before the first difference.
+    start_index = max(0, diff_index - 10)
+
+    # Truncate both original and updated strings.
+    truncated_original = original[start_index:start_index + truncation_limit]
+    truncated_updated = updated[start_index:start_index + truncation_limit]
+
+    # Apply '...' at the start if truncation happens and it's not
+    # already at the start of the string.
+    if len(truncated_original) < truncation_limit and start_index > 0:
+        truncated_original = '...' + truncated_original
+    if len(truncated_updated) < truncation_limit and start_index > 0:
+        truncated_updated = '...' + truncated_updated
+
+    truncated_original = truncated_original[:truncation_limit]
+    truncated_updated = truncated_updated[:truncation_limit]
+
+    return truncated_original, truncated_updated
+
+
+def count_rte_components(html_content: str) -> Dict[str, int]:
+    """Counts the number of each RTE component in the provided HTML content.
+
+    Args:
+        html_content: str. The HTML content to analyze.
+
+    Returns:
+        Dict[str, int]. A dictionary where keys are RTE component names
+        (e.g., 'oppia-noninteractive-image') and values are their counts.
+    """
+    soup = bs4.BeautifulSoup(html_content, 'html.parser')
+    component_counts = {}
+    rte_tags_with_attrs = (
+        rte_component_registry.Registry.get_tag_list_with_attrs()
+    )
+    rte_tags = list(rte_tags_with_attrs.keys())
+
+    for tag in rte_tags:
+        component_counts[tag] = len(soup.find_all(tag))
+
+    return component_counts
+
+
 def update_translation_suggestion(
-    suggestion_id: str, translation_html: str
+    suggestion_id: str,
+    translation_html: str
 ) -> None:
     """Updates the translation_html of a suggestion with the given
     suggestion_id.
@@ -2153,10 +2241,13 @@ def update_translation_suggestion(
         translation_html: str. The new translation_html string.
 
     Raises:
-        Exception. Expected SuggestionTranslateContent suggestion but found
-            different suggestion.
+        InvalidInputException. The RTE component counts in
+            the updated translation do not match the original content.
+        Exception. The suggestion is not of type
+            SuggestionTranslateContent.
     """
     suggestion = get_suggestion_by_id(suggestion_id)
+
     if not isinstance(
         suggestion, suggestion_registry.SuggestionTranslateContent
     ):
@@ -2164,6 +2255,64 @@ def update_translation_suggestion(
             'Expected SuggestionTranslateContent suggestion but found: %s.'
             % type(suggestion).__name__
         )
+
+    original_text_html = suggestion.change_cmd.content_html
+    original_rte_counts = count_rte_components(original_text_html)
+    updated_rte_counts = count_rte_components(translation_html)
+
+    # We use a sorted approach to compare component counts because it ensures
+    # consistency in comparison regardless of the order components appear.
+    all_component_names = sorted(
+        {_strip_prefix(name)
+        for name in original_rte_counts.keys() | updated_rte_counts.keys()}
+    )
+
+    discrepancy_components = []
+
+    for component_name in all_component_names:
+        full_name = f'oppia-noninteractive-{component_name}'
+        original_count = original_rte_counts.get(full_name, 0)
+        updated_count = updated_rte_counts.get(full_name, 0)
+
+        if original_count != updated_count:
+            discrepancy_components.append((
+                component_name, original_count, updated_count
+            ))
+
+    if discrepancy_components:
+        # Create the component differences summary.
+        original_summary = [
+            f'{count} {name}' for name, count, _ in discrepancy_components
+        ]
+        updated_summary = [
+            f'{count} {name}' for name, _, count in discrepancy_components
+        ]
+
+        original_summary_text = (
+            f'Components in original text: {", ".join(original_summary)}.'
+        )
+        updated_summary_text = (
+            f'Components in translated text: {", ".join(updated_summary)}.'
+        )
+
+        # Get truncated versions of both original and translated
+        # text for the error message.
+        original_text_preview, translation_text_preview = (
+            highlight_differences(
+                original_text_html,
+                translation_html,
+                truncation_limit=MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION
+            )
+        )
+
+        # Raise the error with detailed information.
+        raise utils.InvalidInputException(
+            f'{original_summary_text} {updated_summary_text}\n'
+            f'Original text preview: {original_text_preview}\n'
+            f'Translated text preview: {translation_text_preview}'
+        )
+
+    # Store the full translation HTML without truncation.
     suggestion.change_cmd.translation_html = (
         html_cleaner.clean(translation_html)
         if isinstance(translation_html, str)
diff --git a/core/domain/suggestion_services_test.py b/core/domain/suggestion_services_test.py
index bdc644b043a61..313e5806a011a 100644
--- a/core/domain/suggestion_services_test.py
+++ b/core/domain/suggestion_services_test.py
@@ -30,6 +30,7 @@
 from core.domain import question_domain
 from core.domain import rights_domain
 from core.domain import rights_manager
+from core.domain import rte_component_registry
 from core.domain import skill_services
 from core.domain import state_domain
 from core.domain import story_domain
@@ -1034,6 +1035,463 @@ def test_wrong_suggestion_raise_error_while_updating_translation_suggestion(
                 suggestion.suggestion_id, 'test_translation'
             )
 
+    def create_translation_suggestion(
+            self, content_html: str, translation_html: str
+        ) -> suggestion_registry.SuggestionTranslateContent:
+        """Creates a translation suggestion for testing purposes."""
+        exploration = (
+            self.save_new_linear_exp_with_state_names_and_interactions(
+                'exploration1', self.author_id, ['state 1'], ['TextInput'],
+                category='Algebra'))
+        old_content = state_domain.SubtitledHtml(
+            'content', content_html).to_dict()
+        exploration.states['state 1'].update_content(
+            state_domain.SubtitledHtml.from_dict(old_content))
+        change_list = [exp_domain.ExplorationChange({
+            'cmd': exp_domain.CMD_EDIT_STATE_PROPERTY,
+            'property_name': exp_domain.STATE_PROPERTY_CONTENT,
+            'state_name': 'state 1',
+            'new_value': {
+                'content_id': 'content_0',
+                'html': content_html
+            }
+        })]
+        exp_services.update_exploration(
+            self.author_id, exploration.id, change_list, '')
+
+        add_translation_change_dict = {
+            'cmd': exp_domain.CMD_ADD_WRITTEN_TRANSLATION,
+            'state_name': 'state 1',
+            'content_id': 'content_0',
+            'language_code': 'hi',
+            'content_html': content_html,
+            'translation_html': translation_html,
+            'data_format': 'html'
+        }
+        return suggestion_services.create_suggestion(
+            feconf.SUGGESTION_TYPE_TRANSLATE_CONTENT,
+            feconf.ENTITY_TYPE_EXPLORATION,
+            'exploration1', self.target_version_at_submission,
+            self.author_id, add_translation_change_dict, 'test description')
+
+    def test_update_translation_suggestion_error_on_rte_removal(self) -> None:
+        """Tests that an InvalidInputException is raised when a translation
+        suggestion update removes an RTE component.
+        """
+        content_html_with_rte_components = (
+            '<p>Original content with image.</p>'
+            '<oppia-noninteractive-image '
+            'alt-with-value="Image description" '
+            'caption-with-value="Sample caption" '
+            'filepath-with-value="img.svg"> '
+            '</oppia-noninteractive-image>'
+            '<oppia-noninteractive-math '
+            'math_content-with-value="{&amp;q'
+            'uot;raw_latex&amp;quot;: &amp;quot;(x - a_1)(x - a_2)&amp;quot;, '
+            '&amp;quot;svg_filename&amp;quot;: &amp;quot;file.svg&amp;quot;}">'
+            '</oppia-noninteractive-math>'
+        )
+        translation_html_with_rte_components = (
+            '<p>Original content with image.</p>'
+            '<oppia-noninteractive-image '
+            'alt-with-value="Image description" '
+            'caption-with-value="Sample caption" '
+            'filepath-with-value="img.svg"> '
+            '</oppia-noninteractive-image>'
+            '<oppia-noninteractive-math '
+            'math_content-with-value="{&amp;q'
+            'uot;raw_latex&amp;quot;: &amp;quot;(x - a_1)(x - a_2)&amp;quot;, '
+            '&amp;quot;svg_filename&amp;quot;: &amp;quot;file.svg&amp;quot;}">'
+            '</oppia-noninteractive-math>'
+        )
+        suggestion = self.create_translation_suggestion(
+            content_html=content_html_with_rte_components,
+            translation_html=translation_html_with_rte_components
+        )
+
+        updated_translation_without_image = (
+            '<p>Updated translation without image</p>'
+            '<oppia-noninteractive-math '
+            'math_content-with-value="{&amp;q'
+            'uot;raw_latex&amp;quot;: &amp;quot;(x - a_1)(x - a_2)&amp;quot;, '
+            '&amp;quot;svg_filename&amp;quot;: &amp;quot;file.svg&amp;quot;}">'
+            '</oppia-noninteractive-math>'
+        )
+
+        with self.assertRaisesRegex(
+            utils.InvalidInputException,
+            'Components in original text: 1 image. '
+            'Components in translated text: 0 image.'
+        ):
+            suggestion_services.update_translation_suggestion(
+                suggestion.suggestion_id,
+                updated_translation_without_image
+            )
+
+    def test_update_translation_suggestion_error_when_component_is_added(
+        self
+    ) -> None:
+        content_html_without_image = (
+            '<p>Original content without image.</p>'
+        )
+        translation_html_without_image = (
+            '<p>Translation for original content without image.</p>'
+        )
+
+        suggestion = self.create_translation_suggestion(
+            content_html_without_image,
+            translation_html_without_image
+        )
+        new_translation_html_with_image = (
+            '<p><oppia-noninteractive-image '
+            'alt-with-value="Another description" '
+            'caption-with-value="Another caption" ' 
+            'filepath-with-value="another_img.svg">'
+            '</oppia-noninteractive-image> '
+            'Updated translation with image</p>'
+        )
+
+        with self.assertRaisesRegex(
+            utils.InvalidInputException,
+            'Components in original text: 0 image. '
+            'Components in translated text: 1 image.'
+        ):
+            suggestion_services.update_translation_suggestion(
+                suggestion.suggestion_id,
+                new_translation_html_with_image
+            )
+
+    def test_update_translation_suggestion_error_when_component_count_changes(
+        self
+    ) -> None:
+        content_html_with_multiple_images = (
+            '<p>Original content with multiple images.</p>'
+            '<oppia-noninteractive-image '
+            'alt-with-value="Image description 1" '
+            'caption-with-value="Sample Caption 1" '
+            'filepath-with-value="img1.svg"> '
+            '</oppia-noninteractive-image>'
+            '<oppia-noninteractive-image '
+            'alt-with-value="Image description 2" '
+            'caption-with-value="Sample Caption 2" '
+            'filepath-with-value="img2.svg"> '
+            '</oppia-noninteractive-image>'
+        )
+        translation_html_with_multiple_images = (
+            '<p>Translation with multiple images.</p>'
+            '<oppia-noninteractive-image '
+            'alt-with-value="Image description 1" '
+            'caption-with-value="Sample Caption 1" '
+            'filepath-with-value="img1.svg"> '
+            '</oppia-noninteractive-image>'
+            '<oppia-noninteractive-image '
+            'alt-with-value="Image description 2" '
+            'caption-with-value="Sample Caption 2" '
+            'filepath-with-value="img2.svg"> '
+            '</oppia-noninteractive-image>'
+        )
+        suggestion = self.create_translation_suggestion(
+            content_html_with_multiple_images,
+            translation_html_with_multiple_images
+        )
+        updated_translation = (
+            '<p>Updated translation with an extra video.</p>'
+            '<oppia-noninteractive-image '
+            'alt-with-value="Image description 1" '
+            'caption-with-value="Sample Caption 1" '
+            'filepath-with-value="img1.svg"> '
+            '</oppia-noninteractive-image>'
+            '<oppia-noninteractive-math '
+            'math_content-with-value="{&amp;q'
+            'uot;raw_latex&amp;quot;: &amp;quot;(x - a_1)(x - a_2)&amp;quot;, '
+            '&amp;quot;svg_filename&amp;quot;: &amp;quot;file.svg&amp;quot;}">'
+            '</oppia-noninteractive-math>'
+            '<oppia-noninteractive-math '
+            'math_content-with-value="{&amp;q'
+            'uot;raw_latex&amp;quot;: &amp;quot;(x - a_1)(x - a_2)&amp;quot;, '
+            '&amp;quot;svg_filename&amp;quot;: &amp;quot;file.svg&amp;quot;}">'
+            '</oppia-noninteractive-math>'
+        )
+        with self.assertRaisesRegex(
+            utils.InvalidInputException,
+            'Components in original text: 2 image, 0 math. '
+            'Components in translated text: 1 image, 2 math.'
+        ):
+            suggestion_services.update_translation_suggestion(
+                suggestion.suggestion_id,
+                updated_translation
+            )
+
+        updated_suggestion = suggestion_services.get_suggestion_by_id(
+            suggestion.suggestion_id
+        )
+        original_component_counts = suggestion_services.count_rte_components(
+            updated_suggestion.change_cmd.translation_html
+        )
+
+        rte_tags_with_attrs = (
+            rte_component_registry.Registry.get_tag_list_with_attrs()
+        )
+        rte_tags = list(rte_tags_with_attrs.keys())
+
+        expected_counts = {tag: 0 for tag in rte_tags}
+
+        expected_counts['oppia-noninteractive-image'] = 2
+
+        self.assertEqual(expected_counts, original_component_counts)
+
+    def test_update_translation_suggestion_error_when_component_types_mismatch(
+        self
+    ) -> None:
+        content_html_with_image = (
+            '<p>Original content with image.</p>'
+            '<oppia-noninteractive-image '
+            'alt-with-value="Image description" '
+            'caption-with-value="Sample Caption" '
+            'filepath-with-value="img.svg"> '
+            '</oppia-noninteractive-image>'
+        )
+        translation_html_with_image = (
+            '<p>Translation with image.</p>'
+            '<oppia-noninteractive-image '
+            'alt-with-value="Image description" '
+            'caption-with-value="Sample Caption" '
+            'filepath-with-value="img.svg"> '
+            '</oppia-noninteractive-image>'
+        )
+        suggestion = self.create_translation_suggestion(
+            content_html_with_image,
+            translation_html_with_image
+        )
+        updated_translation_with_math = (
+            '<p>Updated translation with math instead of image.</p>'
+            '<oppia-noninteractive-math '
+            'math_content-with-value="{&amp;q'
+            'uot;raw_latex&amp;quot;: &amp;quot;(x - a_1)(x - a_2)&amp;quot;, '
+            '&amp;quot;svg_filename&amp;quot;: &amp;quot;file.svg&amp;quot;}">'
+            '</oppia-noninteractive-math>'
+        )
+        with self.assertRaisesRegex(
+            utils.InvalidInputException,
+            'Components in original text: 1 image, 0 math. '
+            'Components in translated text: 0 image, 1 math.'
+        ):
+            suggestion_services.update_translation_suggestion(
+                suggestion.suggestion_id,
+                updated_translation_with_math
+            )
+
+        updated_suggestion = suggestion_services.get_suggestion_by_id(
+            suggestion.suggestion_id
+        )
+        self.assertEqual(
+            updated_suggestion.change_cmd.translation_html,
+            translation_html_with_image
+        )
+
+    def test_update_translation_suggestion_with_extra_characters(self) -> None:
+        original = 'This is a test string.'
+        updated = 'This is a test string. Extra text.'
+
+        suggestion = self.create_translation_suggestion(original, original)
+
+        suggestion_services.update_translation_suggestion(
+            suggestion.suggestion_id, updated
+        )
+
+        suggestion_services.get_suggestion_by_id(
+            suggestion.suggestion_id
+        )
+
+        truncated_original, truncated_updated = (
+            suggestion_services.highlight_differences(
+                original, updated
+            )
+        )
+
+        self.assertTrue(truncated_original.startswith('...'))
+        self.assertTrue(truncated_updated.startswith('...'))
+        self.assertTrue(truncated_updated.endswith('Extra text.'))
+
+    def test_update_translation_suggestion_with_long_text(self) -> None:
+        original = f'{"A" * 50}DIFFERENT{"B" * 50}'
+        updated = f'{"A" * 50}CHANGED{"B" * 50}'
+
+        suggestion = self.create_translation_suggestion(original, original)
+
+        suggestion_services.update_translation_suggestion(
+            suggestion.suggestion_id, updated
+        )
+
+        suggestion_services.get_suggestion_by_id(
+            suggestion.suggestion_id
+        )
+
+        truncated_original, truncated_updated = (
+            suggestion_services.highlight_differences(
+                original, updated
+            )
+        )
+
+        self.assertTrue(truncated_original.startswith('...'))
+        self.assertTrue(truncated_updated.startswith('...'))
+
+        self.assertIn('DIFFERENT', truncated_original)
+        self.assertIn('CHANGED', truncated_updated)
+
+    def test_highlight_differences_identical_strings(self) -> None:
+        original_string = 'This is the same string.'
+        updated_string = 'This is the same string.'
+
+        suggestion = self.create_translation_suggestion(
+            original_string, original_string
+        )
+
+        suggestion_services.update_translation_suggestion(
+            suggestion.suggestion_id, updated_string
+        )
+
+        suggestion_services.get_suggestion_by_id(
+            suggestion.suggestion_id
+        )
+
+        truncated_original, truncated_updated = (
+            suggestion_services.highlight_differences(
+                original_string, updated_string
+            )
+        )
+
+        self.assertEqual(truncated_original, original_string)
+        self.assertEqual(truncated_updated, updated_string)
+
+    def test_update_translation_suggestion_with_change_in_middle(self) -> None:
+        original = 'This is a test string.'
+        updated = 'This is a best string.'
+
+        suggestion = self.create_translation_suggestion(original, original)
+
+        suggestion_services.update_translation_suggestion(
+            suggestion.suggestion_id, updated
+        )
+
+        updated_suggestion = suggestion_services.get_suggestion_by_id(
+            suggestion.suggestion_id
+        )
+
+        self.assertIn('test', original)
+        self.assertIn('best', updated_suggestion.change_cmd.translation_html)
+
+    def test_update_translation_suggestion_with_change_at_start(self) -> None:
+        original = 'Alpha test string.'
+        updated = 'Beta test string.'
+
+        suggestion = self.create_translation_suggestion(original, original)
+
+        suggestion_services.update_translation_suggestion(
+            suggestion.suggestion_id, updated
+        )
+
+        updated_suggestion = suggestion_services.get_suggestion_by_id(
+            suggestion.suggestion_id
+        )
+
+        self.assertIn('Alpha', original)
+        self.assertIn('Beta', updated_suggestion.change_cmd.translation_html)
+
+    def test_update_translation_suggestion_error_with_two_discrepancies(
+        self
+    ) -> None:
+        content_html_with_image = (
+            '<p>Original content with image.</p>'
+            '<oppia-noninteractive-image '
+            'alt-with-value="Image description" '
+            'caption-with-value="Sample Caption" '
+            'filepath-with-value="img.svg"> '
+            '</oppia-noninteractive-image>'
+            '<oppia-noninteractive-math '
+            'math_content-with-value="{&amp;q'
+            'uot;raw_latex&amp;quot;: &amp;quot;(x - a_1)(x - a_2)&amp;quot;, '
+            '&amp;quot;svg_filename&amp;quot;: &amp;quot;file.svg&amp;quot;}">'
+            '</oppia-noninteractive-math>'
+        )
+        translation_html_without_math = (
+            '<p>Translation without math.</p>'
+            '<oppia-noninteractive-image '
+            'alt-with-value="Image description" '
+            'caption-with-value="Sample Caption" '
+            'filepath-with-value="img.svg"> '
+            '</oppia-noninteractive-image>'
+            '<oppia-noninteractive-image '
+            'alt-with-value="Image description" '
+            'caption-with-value="Sample Caption" '
+            'filepath-with-value="img.svg"> '
+            '</oppia-noninteractive-image>'
+        )
+        suggestion = self.create_translation_suggestion(
+            content_html_with_image, content_html_with_image
+        )
+        with self.assertRaisesRegex(
+            utils.InvalidInputException,
+            'Components in original text: 1 image, 1 math. '
+            'Components in translated text: 2 image, 0 math.'
+        ):
+            suggestion_services.update_translation_suggestion(
+                suggestion.suggestion_id, translation_html_without_math
+            )
+
+    def test_update_translation_suggestion_no_rte_components(self) -> None:
+        original = 'This is a test string.'
+        updated = 'This is a best string.'
+
+        suggestion = self.create_translation_suggestion(original, original)
+
+        suggestion_services.update_translation_suggestion(
+            suggestion.suggestion_id, updated)
+
+    def test_update_translation_suggestion_error_with_truncated_text(
+        self
+    ) -> None:
+        truncation_limit = (
+            suggestion_services.MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION
+        )
+        long_original_html = (
+            f'<p>{"a" * 250}</p>'
+            '<oppia-noninteractive-image '
+            'alt-with-value="Original Alt" '
+            'caption-with-value="Original Caption" '
+            'filepath-with-value="original.svg">'
+            '</oppia-noninteractive-image>'
+        )
+        long_updated_html = f'<p>{"b" * 250}</p>'
+        suggestion = self.create_translation_suggestion(
+            long_original_html, long_original_html
+        )
+
+        original_preview_start = long_original_html.find('<p>')
+        original_preview_end = original_preview_start + truncation_limit
+        truncated_original_expected = long_original_html[
+            original_preview_start:original_preview_end
+        ]
+
+        updated_preview_start = long_updated_html.find('<p>')
+        updated_preview_end = updated_preview_start + truncation_limit
+        truncated_updated_expected = long_updated_html[
+            updated_preview_start:updated_preview_end
+        ]
+
+        with self.assertRaisesRegex(
+            utils.InvalidInputException,
+            (
+                f'Components in original text: 1 image. '
+                f'Components in translated text: 0 image.\n'
+                f'Original text preview: {truncated_original_expected}\n'
+                f'Translated text preview: {truncated_updated_expected}'
+            )
+        ):
+            suggestion_services.update_translation_suggestion(
+                suggestion.suggestion_id, long_updated_html
+            )
+
     def test_wrong_suggestion_raise_error_when_updating_add_question_suggestion(
         self
     ) -> None:
@@ -1900,7 +2358,10 @@ def test_get_target_ids_of_translations_in_user_reviewable_languages_when_not_fi
         )
 
         self.assertCountEqual(
-            target_ids, [fetched_target_id_1, fetched_target_id_2]
+            target_ids, [
+                fetched_target_id_1,
+                fetched_target_id_2
+            ]
         )
 
     def test_get_no_translation_target_ids_when_user_cannot_review_in_given_language( # pylint: disable=line-too-long
diff --git a/core/templates/pages/contributor-dashboard-page/modal-templates/translation-modal.component.spec.ts b/core/templates/pages/contributor-dashboard-page/modal-templates/translation-modal.component.spec.ts
index 9ff391582f228..55fc68364e88e 100644
--- a/core/templates/pages/contributor-dashboard-page/modal-templates/translation-modal.component.spec.ts
+++ b/core/templates/pages/contributor-dashboard-page/modal-templates/translation-modal.component.spec.ts
@@ -1000,7 +1000,7 @@ describe('Translation Modal Component', () => {
 
   it('should close modal and return the new translation when updating translated text', () => {
     spyOn(activeModal, 'close');
-    spyOn(component, 'translatedTextCanBeSubmitted').and.returnValue(true);
+    spyOn(component, 'canTranslatedTextBeSubmitted').and.returnValue(true);
     component.activeWrittenTranslation = 'Test translation';
     component.updateTranslatedText();
 
diff --git a/core/templates/pages/contributor-dashboard-page/modal-templates/translation-modal.component.ts b/core/templates/pages/contributor-dashboard-page/modal-templates/translation-modal.component.ts
index 74e1aa9c0def2..e1a19f65617f7 100644
--- a/core/templates/pages/contributor-dashboard-page/modal-templates/translation-modal.component.ts
+++ b/core/templates/pages/contributor-dashboard-page/modal-templates/translation-modal.component.ts
@@ -16,8 +16,6 @@
  * @fileoverview Component for the translation modal.
  */
 
-import isEqual from 'lodash/isEqual';
-
 import {
   ChangeDetectorRef,
   Component,
@@ -39,6 +37,7 @@ import {
 } from 'pages/contributor-dashboard-page/services/translate-text.service';
 import {TranslationLanguageService} from 'pages/exploration-editor-page/translation-tab/services/translation-language.service';
 import {UserService} from 'services/user.service';
+import {TranslationValidationService} from 'services/translation-validation.service';
 import {AppConstants} from 'app.constants';
 import {ListSchema, UnicodeSchema} from 'services/schema-default-value.service';
 import {
@@ -96,25 +95,6 @@ export interface ImageDetails {
   alts: string[];
   descriptions: string[];
 }
-export class TranslationError {
-  constructor(
-    private _hasDuplicateAltTexts: boolean,
-    private _hasDuplicateDescriptions: boolean,
-    private _hasUntranslatedElements: boolean
-  ) {}
-
-  get hasDuplicateDescriptions(): boolean {
-    return this._hasDuplicateDescriptions;
-  }
-
-  get hasDuplicateAltTexts(): boolean {
-    return this._hasDuplicateAltTexts;
-  }
-
-  get hasUntranslatedElements(): boolean {
-    return this._hasUntranslatedElements;
-  }
-}
 
 @Component({
   selector: 'oppia-translation-modal',
@@ -200,6 +180,7 @@ export class TranslationModalComponent {
     private readonly userService: UserService,
     private readonly changeDetectorRef: ChangeDetectorRef,
     private readonly wds: WindowDimensionsService,
+    private readonly translationValidationService: TranslationValidationService,
     private readonly windowRef: WindowRef
   ) {}
 
@@ -509,138 +490,13 @@ export class TranslationModalComponent {
     );
   }
 
-  getElementAttributeTexts(
-    elements: HTMLCollectionOf<Element>,
-    type: string
-  ): string[] {
-    const textWrapperLength = 6;
-    const attributes = Array.from(elements, function (element: Element) {
-      // A sample element would be as <oppia-noninteractive-image alt-with-value
-      // ="&amp;quot;Image description&amp;quot;" caption-with-value=
-      // "&amp;quot;Image caption&amp;quot;" filepath-with-value="&amp;quot;
-      // img_2021029_210552_zbmdt94_height_54_width_490.png&amp;quot;">
-      // </oppia-noninteractive-image>
-      if (element.localName === 'oppia-noninteractive-image') {
-        const attribute = element.attributes[
-          type as keyof NamedNodeMap
-        ] as Attr;
-        const attributeValue = attribute.value;
-        return attributeValue.substring(
-          textWrapperLength,
-          attributeValue.length - textWrapperLength
-        );
-      }
-    });
-    // Typecasted to string[] because Array.from() returns
-    // (string | undefined)[] and we need to remove the undefined elements.
-    return attributes.filter(attributeValues => attributeValues) as string[];
-  }
-
-  getImageAttributeTexts(
-    htmlElements: HTMLCollectionOf<Element>
-  ): ImageDetails {
-    return {
-      filePaths: this.getElementAttributeTexts(
-        htmlElements,
-        'filepath-with-value'
-      ),
-      alts: this.getElementAttributeTexts(htmlElements, 'alt-with-value'),
-      descriptions: this.getElementAttributeTexts(
-        htmlElements,
-        'caption-with-value'
-      ),
-    };
-  }
-
-  hasSomeDuplicateElements(
-    originalElements: string[],
-    translatedElements: string[]
-  ): boolean {
-    // This regular expression matches a number, optionally negative, with an
-    // optional decimal number followed by zero or more operators (including
-    // equals sign) and number pairs. It also allows for whitespace between
-    // numbers and operators. Examples 1+1=2 1+1 1*1=1.
-    const mathEquationRegex = new RegExp(
-      /(?:(?:^|[-+_*/=])(?:\s*-?\d+(\.\d+)?(?:[eE][+-]?\d+)?\s*))+$/
-    );
-    if (originalElements.length === 0) {
-      return false;
-    }
-    const hasMatchingTranslatedElement = (element: string) =>
-      translatedElements.includes(element) &&
-      originalElements.length > 0 &&
-      !mathEquationRegex.test(element);
-    return originalElements.some(hasMatchingTranslatedElement);
-  }
-
-  isTranslationCompleted(
-    originalElements: HTMLCollectionOf<Element>,
-    translatedElements: HTMLCollectionOf<Element>
-  ): boolean {
-    // Checks if there are custom tags present in the original content but not
-    // in the translated content.
-    const filteredOriginalElements = Array.from(originalElements, el =>
-      el.tagName.toLowerCase()
-    )
-      .filter(tagName =>
-        this.ALLOWED_CUSTOM_TAGS_IN_TRANSLATION_SUGGESTION.includes(tagName)
-      )
-      .sort();
-    const filteredTranslatedElements = Array.from(translatedElements, el =>
-      el.tagName.toLowerCase()
-    )
-      .filter(tagName =>
-        this.ALLOWED_CUSTOM_TAGS_IN_TRANSLATION_SUGGESTION.includes(tagName)
-      )
-      .sort();
-    return isEqual(filteredOriginalElements, filteredTranslatedElements);
-  }
-
-  validateTranslation(
-    textToTranslate: HTMLCollectionOf<Element>,
-    translatedText: HTMLCollectionOf<Element>
-  ): TranslationError {
-    const translatedElements: ImageDetails =
-      this.getImageAttributeTexts(translatedText);
-    const originalElements: ImageDetails =
-      this.getImageAttributeTexts(textToTranslate);
-
-    const hasDuplicateAltTexts = this.hasSomeDuplicateElements(
-      originalElements.alts,
-      translatedElements.alts
-    );
-    const hasDuplicateDescriptions = this.hasSomeDuplicateElements(
-      originalElements.descriptions,
-      translatedElements.descriptions
-    );
-    const hasUntranslatedElements = !this.isTranslationCompleted(
-      textToTranslate,
-      translatedText
-    );
-
-    return new TranslationError(
-      hasDuplicateAltTexts,
-      hasDuplicateDescriptions,
-      hasUntranslatedElements
-    );
-  }
-
-  translatedTextCanBeSubmitted(): boolean {
+  canTranslatedTextBeSubmitted(): boolean {
     if (!this.isSetOfStringDataFormat()) {
-      const domParser = new DOMParser();
-      const originalElements = domParser.parseFromString(
-        this.textToTranslate as string,
-        'text/html'
-      );
-      const translatedElements = domParser.parseFromString(
-        this.activeWrittenTranslation as string,
-        'text/html'
-      );
-
-      const translationError = this.validateTranslation(
-        originalElements.getElementsByTagName('*'),
-        translatedElements.getElementsByTagName('*')
-      );
+      const translationError =
+        this.translationValidationService.validateTranslationFromHtmlStrings(
+          this.textToTranslate as string,
+          this.activeWrittenTranslation as string
+        );
 
       this.hasImgTextError =
         translationError.hasDuplicateAltTexts ||
@@ -665,7 +521,7 @@ export class TranslationModalComponent {
   }
 
   suggestTranslatedText(): void {
-    if (!this.translatedTextCanBeSubmitted()) {
+    if (!this.canTranslatedTextBeSubmitted()) {
       return;
     }
 
@@ -707,7 +563,7 @@ export class TranslationModalComponent {
     }
   }
   updateTranslatedText(): void {
-    if (!this.translatedTextCanBeSubmitted()) {
+    if (!this.canTranslatedTextBeSubmitted()) {
       return;
     }
     this.activeModal.close(this.activeWrittenTranslation);
diff --git a/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.html b/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.html
index f7457f8e22eec..cf118fac929a0 100644
--- a/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.html
+++ b/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.html
@@ -26,6 +26,12 @@ <h4 class="oppia-translation-modal-author-header">
   </div>
 </div>
 
+
+<div *ngIf="updateIsDisabled" tabindex="0" class="alert alert-danger oppia-input-error">
+  <strong>Warning:</strong> Please ensure all components (images, math formulas, concept cards) in your translation match
+  the original content to proceed.
+</div>
+
 <div class="modal-body">
   <div *ngIf="status == 'rejected'" class="translation-suggestion-review-message alert alert-danger">
     <p>See your submitted translation below. Please submit a new translation to address the requested revisions.</p>
@@ -105,7 +111,8 @@ <h4 class="oppia-translation-modal-author-header">
         </button>
         <button class="btn btn-success ml-2"
                 *ngIf="startedEditing"
-                (click)="updateSuggestion()">
+                (click)="updateSuggestion()"
+                [disabled]="updateIsDisabled">
           Update
         </button>
       </div>
diff --git a/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts b/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts
index 24d6ff16f5255..19d8868c2e853 100644
--- a/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts
+++ b/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts
@@ -127,6 +127,7 @@ describe('Translation Suggestion Review Modal Component', function () {
     userService = TestBed.inject(UserService);
     contributionAndReviewService = TestBed.inject(ContributionAndReviewService);
     languageUtilService = TestBed.inject(LanguageUtilService);
+
     spyOn(
       siteAnalyticsService,
       'registerContributorDashboardViewSuggestionForReview'
@@ -153,6 +154,36 @@ describe('Translation Suggestion Review Modal Component', function () {
       new ElementRef({offsetHeight: 200}),
       null
     );
+    component.initialSuggestionId = 'suggestion_1';
+    component.suggestionIdToContribution = {
+      suggestion_1: {
+        suggestion: {
+          author_name: 'author_name',
+          language_code: 'language_code',
+          last_updated_msecs: 1559074000000,
+          status: 'status',
+          suggestion_id: 'suggestion_1',
+          target_id: '1',
+          target_type: 'target_type',
+          suggestion_type: 'translate_content',
+          change_cmd: {
+            content_id: 'hint_1',
+            content_html: '<p>content</p>',
+            translation_html: 'Translation content',
+            state_name: 'StateName',
+            cmd: 'edit_state_property',
+            data_format: 'html',
+            language_code: 'language_code',
+          },
+          exploration_content_html: '<p>content</p>',
+        },
+        details: {
+          topic_name: 'topic_1',
+          story_title: 'story_1',
+          chapter_title: 'chapter_1',
+        },
+      },
+    };
   });
 
   describe('when initializing the modal ', () => {
@@ -2684,4 +2715,473 @@ describe('Translation Suggestion Review Modal Component', function () {
 
     expect(result).toEqual(expectedHtmlString);
   });
+
+  describe('Translation Suggestion Review Modal - Component Validation', () => {
+    const htmlWithComponents = `
+    <p>Content with components</p>
+    <oppia-noninteractive-image
+      alt-with-value="&amp;quot;Image description&amp;quot;"
+      caption-with-value="&amp;quot;Image caption&amp;quot;"
+      filepath-with-value="&amp;quot;img_20241109_030945_oc195e5356_height_350_width_450.svg&amp;quot;">
+    </oppia-noninteractive-image>
+    <oppia-noninteractive-math 
+      math_content-with-value="{\u0026amp;quot;raw_latex\u0026amp;quot;:\u0026amp;quot;\\\\frac{x}{y}\u0026amp;quot;,\u0026amp;quot;svg_filename\u0026amp;quot;:\u0026amp;quot;mathImg_20250126_225215_x5vy0sjj6v_height_3d205_width_1d784_vertical_1d306.svg\u0026amp;quot;}">
+    </oppia-noninteractive-math>
+    <oppia-noninteractive-skillreview 
+      skill_id-with-value="&amp;quot;wfLsQD3CTfrI&amp;quot;" 
+      text-with-value="&amp;quot;concept card&amp;quot;">
+    </oppia-noninteractive-skillreview>
+  `;
+    const htmlWithoutComponents = '<p>Content without components</p>';
+    const htmlWithMultipleComponents = `
+  <p>Content with multiple components</p>
+  <oppia-noninteractive-image
+    alt-with-value="&amp;quot;Image 1&amp;quot;"
+    caption-with-value="&amp;quot;Image 1 caption&amp;quot;"
+    filepath-with-value="&amp;quot;img1.svg&amp;quot;">
+  </oppia-noninteractive-image>
+  <oppia-noninteractive-image
+    alt-with-value="&amp;quot;Image 2&amp;quot;"
+    caption-with-value="&amp;quot;Image 2 caption&amp;quot;"
+    filepath-with-value="&amp;quot;img2.svg&amp;quot;">
+  </oppia-noninteractive-image>
+  <oppia-noninteractive-math 
+    math_content-with-value="{\u0026amp;quot;raw_latex\u0026amp;quot;:\u0026amp;quot;\\\\frac{x}{y}\u0026amp;quot;}">
+  </oppia-noninteractive-math>
+  <oppia-noninteractive-math 
+    math_content-with-value="{\u0026amp;quot;raw_latex\u0026amp;quot;:\u0026amp;quot;\\\\frac{a}{b}\u0026amp;quot;}">
+  </oppia-noninteractive-math>
+  <oppia-noninteractive-skillreview 
+    skill_id-with-value="&amp;quot;skill1&amp;quot;"
+    text-with-value="&amp;quot;concept card 1&amp;quot;">
+  </oppia-noninteractive-skillreview>
+  <oppia-noninteractive-skillreview 
+    skill_id-with-value="&amp;quot;skill2&amp;quot;"
+    text-with-value="&amp;quot;concept card 2&amp;quot;">
+  </oppia-noninteractive-skillreview>
+`;
+
+    beforeEach(() => {
+      component.initialSuggestionId = 'suggestion_1';
+      component.suggestionIdToContribution = {
+        suggestion_1: {
+          suggestion: {
+            author_name: 'author_name',
+            language_code: 'language_code',
+            last_updated_msecs: 1559074000000,
+            status: 'status',
+            suggestion_id: 'suggestion_1',
+            target_id: '1',
+            target_type: 'target_type',
+            suggestion_type: 'translate_content',
+            change_cmd: {
+              content_id: 'hint_1',
+              content_html: htmlWithComponents,
+              translation_html: htmlWithComponents,
+              state_name: 'StateName',
+              cmd: 'edit_state_property',
+              data_format: 'html',
+              language_code: 'language_code',
+            },
+            exploration_content_html: '<p>content</p>',
+          },
+          details: {
+            topic_name: 'topic_1',
+            story_title: 'story_1',
+            chapter_title: 'chapter_1',
+          },
+        },
+        suggestion_2: {
+          suggestion: {
+            author_name: 'author_name',
+            language_code: 'language_code',
+            last_updated_msecs: 1559074000000,
+            status: 'status',
+            suggestion_id: 'suggestion_2',
+            target_id: '2',
+            target_type: 'target_type',
+            suggestion_type: 'translate_content',
+            change_cmd: {
+              content_id: 'hint_2',
+              content_html: htmlWithoutComponents,
+              translation_html: htmlWithoutComponents,
+              state_name: 'StateName',
+              cmd: 'edit_state_property',
+              data_format: 'html',
+              language_code: 'language_code',
+            },
+            exploration_content_html: '<p>content</p>',
+          },
+          details: {
+            topic_name: 'topic_2',
+            story_title: 'story_2',
+            chapter_title: 'chapter_2',
+          },
+        },
+        suggestion_3: {
+          suggestion: {
+            author_name: 'author_name',
+            language_code: 'language_code',
+            last_updated_msecs: 1559074000000,
+            status: 'status',
+            suggestion_id: 'suggestion_3',
+            target_id: '3',
+            target_type: 'target_type',
+            suggestion_type: 'translate_content',
+            change_cmd: {
+              content_id: 'hint_3',
+              content_html: htmlWithMultipleComponents,
+              translation_html: htmlWithMultipleComponents,
+              state_name: 'StateName',
+              cmd: 'edit_state_property',
+              data_format: 'html',
+              language_code: 'language_code',
+            },
+            exploration_content_html: '<p>content</p>',
+          },
+          details: {
+            topic_name: 'topic_3',
+            story_title: 'story_3',
+            chapter_title: 'chapter_3',
+          },
+        },
+      };
+    });
+
+    it('should initialize validation state correctly from original content', () => {
+      expect(component.initialSuggestionId).toBeDefined();
+      expect(component.suggestionIdToContribution).toBeDefined();
+      expect(component.suggestionIdToContribution.suggestion_1).toBeDefined();
+      expect(
+        component.suggestionIdToContribution.suggestion_1.suggestion
+      ).toBeDefined();
+      expect(
+        component.suggestionIdToContribution.suggestion_1.suggestion.change_cmd
+      ).toBeDefined();
+      expect(
+        component.suggestionIdToContribution.suggestion_1.suggestion.change_cmd
+          .content_html
+      ).toBe(htmlWithComponents);
+
+      expect(component.incompleteTranslationErrorIsShown).toBeFalse();
+
+      component.ngOnInit();
+
+      expect(component.incompleteTranslationErrorIsShown).toBeFalse();
+
+      expect(component.translationHtml).toBe(htmlWithComponents);
+    });
+
+    it('should detect component mismatch when removing components', () => {
+      expect(component.initialSuggestionId).toBeDefined();
+      expect(component.suggestionIdToContribution).toBeDefined();
+      expect(
+        component.suggestionIdToContribution.suggestion_1.suggestion.change_cmd
+          .content_html
+      ).toBe(htmlWithComponents);
+      expect(component.incompleteTranslationErrorIsShown).toBeFalse();
+
+      component.ngOnInit();
+
+      component.editedContent = {html: htmlWithoutComponents};
+      expect(component.editedContent).toBeDefined();
+      expect(component.editedContent.html).toBe(htmlWithoutComponents);
+
+      expect(component.areComponentsMismatched()).toBeTrue();
+      expect(component.incompleteTranslationErrorIsShown).toBeTrue();
+    });
+
+    it('should detect component mismatch when adding components', () => {
+      expect(component.initialSuggestionId).toBeDefined();
+      expect(component.suggestionIdToContribution).toBeDefined();
+
+      component.initialSuggestionId = 'suggestion_2';
+      expect(
+        component.suggestionIdToContribution.suggestion_2.suggestion.change_cmd
+          .content_html
+      ).toBe(htmlWithoutComponents);
+      expect(component.incompleteTranslationErrorIsShown).toBeFalse();
+
+      component.ngOnInit();
+      expect(component.incompleteTranslationErrorIsShown).toBeFalse();
+
+      component.editedContent = {html: htmlWithComponents};
+      expect(component.editedContent).toBeDefined();
+      expect(component.editedContent.html).toBe(htmlWithComponents);
+
+      expect(component.areComponentsMismatched()).toBeTrue();
+      expect(component.incompleteTranslationErrorIsShown).toBeTrue();
+    });
+
+    it('should detect no mismatch when components match', () => {
+      expect(component.initialSuggestionId).toBeDefined();
+      expect(component.suggestionIdToContribution).toBeDefined();
+      expect(
+        component.suggestionIdToContribution.suggestion_1.suggestion.change_cmd
+          .content_html
+      ).toBe(htmlWithComponents);
+      expect(component.incompleteTranslationErrorIsShown).toBeFalse();
+
+      component.ngOnInit();
+      expect(component.incompleteTranslationErrorIsShown).toBeFalse();
+
+      component.editedContent = {html: htmlWithComponents};
+      expect(component.editedContent).toBeDefined();
+      expect(component.editedContent.html).toBe(htmlWithComponents);
+
+      expect(component.areComponentsMismatched()).toBeFalse();
+      expect(component.incompleteTranslationErrorIsShown).toBeFalse();
+    });
+
+    it('should handle multiple components correctly', () => {
+      expect(component.initialSuggestionId).toBeDefined();
+      expect(component.suggestionIdToContribution).toBeDefined();
+
+      component.initialSuggestionId = 'suggestion_3';
+      expect(
+        component.suggestionIdToContribution.suggestion_3.suggestion.change_cmd
+          .content_html
+      ).toBe(htmlWithMultipleComponents);
+      expect(component.incompleteTranslationErrorIsShown).toBeFalse();
+
+      component.ngOnInit();
+      expect(component.incompleteTranslationErrorIsShown).toBeFalse();
+
+      component.editedContent = {html: htmlWithMultipleComponents};
+      expect(component.editedContent).toBeDefined();
+      expect(component.editedContent.html).toBe(htmlWithMultipleComponents);
+
+      expect(component.areComponentsMismatched()).toBeFalse();
+      expect(component.incompleteTranslationErrorIsShown).toBeFalse();
+    });
+
+    it('should disable update button when components mismatch', () => {
+      expect(component.initialSuggestionId).toBeDefined();
+      expect(component.suggestionIdToContribution).toBeDefined();
+      expect(
+        component.suggestionIdToContribution.suggestion_1.suggestion.change_cmd
+          .content_html
+      ).toBe(htmlWithComponents);
+      expect(component.incompleteTranslationErrorIsShown).toBeFalse();
+      expect(component.startedEditing).toBeFalse();
+
+      component.ngOnInit();
+      expect(component.incompleteTranslationErrorIsShown).toBeFalse();
+
+      component.startedEditing = true;
+      expect(component.startedEditing).toBeTrue();
+
+      component.editedContent = {html: htmlWithoutComponents};
+      expect(component.editedContent).toBeDefined();
+      expect(component.editedContent.html).toBe(htmlWithoutComponents);
+
+      expect(component.updateIsDisabled).toBeTrue();
+      expect(component.incompleteTranslationErrorIsShown).toBeTrue();
+    });
+
+    it('should handle successful update when components match', () => {
+      expect(component.initialSuggestionId).toBeDefined();
+      expect(component.suggestionIdToContribution).toBeDefined();
+      expect(
+        component.suggestionIdToContribution.suggestion_1.suggestion.change_cmd
+          .content_html
+      ).toBe(htmlWithComponents);
+      expect(component.incompleteTranslationErrorIsShown).toBeFalse();
+      expect(component.errorFound).toBeFalse();
+
+      const updateSpy = spyOn(
+        contributionAndReviewService,
+        'updateTranslationSuggestionAsync'
+      );
+      expect(updateSpy).not.toHaveBeenCalled();
+
+      component.ngOnInit();
+      expect(component.incompleteTranslationErrorIsShown).toBeFalse();
+
+      component.editedContent = {html: htmlWithComponents};
+      expect(component.editedContent).toBeDefined();
+      expect(component.editedContent.html).toBe(htmlWithComponents);
+
+      component.updateSuggestion();
+      expect(component.errorFound).toBeFalse();
+      expect(updateSpy).toHaveBeenCalledWith(
+        component.initialSuggestionId,
+        htmlWithComponents,
+        jasmine.any(Function),
+        jasmine.any(Function)
+      );
+    });
+
+    it('should show error message when attempting update with mismatched components', () => {
+      expect(component.initialSuggestionId).toBeDefined();
+      expect(component.suggestionIdToContribution).toBeDefined();
+      expect(
+        component.suggestionIdToContribution.suggestion_1.suggestion.change_cmd
+          .content_html
+      ).toBe(htmlWithComponents);
+
+      component.translationHtml = htmlWithComponents;
+      component.ngOnInit();
+
+      component.editedContent = {html: htmlWithoutComponents};
+      component.updateSuggestion();
+
+      expect(component.errorMessage).toBe(
+        'Please ensure all components (images, math formulas, concept cards, videos) in your translation match the original content.'
+      );
+      expect(component.errorFound).toBeTrue();
+    });
+
+    it('should clear error when no component mismatch', () => {
+      component.errorMessage = 'Some error';
+      component.errorFound = true;
+      component.incompleteTranslationErrorIsShown = false;
+
+      component.activeSuggestion = {
+        author_name: 'author_name',
+        change_cmd: {
+          cmd: 'edit_state_property',
+          content_html: htmlWithComponents,
+          content_id: 'content_1',
+          data_format: 'html',
+          language_code: 'en',
+          state_name: 'Introduction',
+          translation_html: htmlWithComponents,
+        },
+        exploration_content_html: '<p>content</p>',
+        language_code: 'en',
+        last_updated_msecs: 1559074000000,
+        status: 'review',
+        suggestion_id: 'suggestion_1',
+        suggestion_type: 'translate_content',
+        target_id: '1',
+        target_type: 'exploration',
+      };
+
+      component.editedContent = {html: htmlWithComponents};
+
+      component.ngOnChanges({
+        editedContent: {
+          currentValue: {html: htmlWithComponents},
+          previousValue: undefined,
+          firstChange: true,
+          isFirstChange: () => true,
+        },
+      });
+
+      expect(component.errorMessage).toBe('');
+      expect(component.errorFound).toBeFalse();
+    });
+
+    it('should keep error when component mismatch exists', () => {
+      component.errorMessage = 'Some error';
+      component.errorFound = true;
+      component.incompleteTranslationErrorIsShown = true;
+
+      component.activeSuggestion = {
+        author_name: 'author_name',
+        change_cmd: {
+          cmd: 'edit_state_property',
+          content_html: htmlWithComponents,
+          content_id: 'content_1',
+          data_format: 'html',
+          language_code: 'en',
+          state_name: 'Introduction',
+          translation_html: htmlWithComponents,
+        },
+        exploration_content_html: '<p>content</p>',
+        language_code: 'en',
+        last_updated_msecs: 1559074000000,
+        status: 'review',
+        suggestion_id: 'suggestion_1',
+        suggestion_type: 'translate_content',
+        target_id: '1',
+        target_type: 'exploration',
+      };
+
+      component.editedContent = {html: htmlWithoutComponents};
+
+      component.ngOnChanges({
+        editedContent: {
+          currentValue: {html: htmlWithoutComponents},
+          previousValue: undefined,
+          firstChange: true,
+          isFirstChange: () => true,
+        },
+      });
+
+      expect(component.errorMessage).toBe('Some error');
+      expect(component.errorFound).toBeTrue();
+    });
+
+    it('should not process when editedContent is null', () => {
+      component.errorMessage = 'Some error';
+      component.errorFound = true;
+      component.editedContent = {html: ''};
+
+      component.activeSuggestion = {
+        author_name: 'author_name',
+        change_cmd: {
+          cmd: 'edit_state_property',
+          content_html: htmlWithComponents,
+          content_id: 'content_1',
+          data_format: 'html',
+          language_code: 'en',
+          state_name: 'Introduction',
+          translation_html: htmlWithComponents,
+        },
+        exploration_content_html: '<p>content</p>',
+        language_code: 'en',
+        last_updated_msecs: 1559074000000,
+        status: 'review',
+        suggestion_id: 'suggestion_1',
+        suggestion_type: 'translate_content',
+        target_id: '1',
+        target_type: 'exploration',
+      };
+
+      component.ngOnChanges({
+        editedContent: {
+          currentValue: null,
+          previousValue: {html: htmlWithComponents},
+          firstChange: false,
+          isFirstChange: () => false,
+        },
+      });
+
+      expect(component.errorMessage).toBe('Some error');
+      expect(component.errorFound).toBeTrue();
+    });
+
+    it('should not update when an image is replaced with a math formula', () => {
+      expect(component.initialSuggestionId).toBeDefined();
+      expect(component.suggestionIdToContribution).toBeDefined();
+
+      component.translationHtml = `
+        <p>Content with components</p>
+        <oppia-noninteractive-image
+          alt-with-value="&amp;quot;Image description&amp;quot;"
+          caption-with-value="&amp;quot;Image caption&amp;quot;"
+          filepath-with-value="&amp;quot;img_20241109_030945_oc195e5356_height_350_width_450.svg&amp;quot;">
+        </oppia-noninteractive-image>
+      `;
+      component.ngOnInit();
+      const updatedHtml = `
+        <p>Content with components</p>
+        <oppia-noninteractive-math
+          math_content-with-value="{\u0026amp;quot;raw_latex\u0026amp;quot;:\u0026amp;quot;\\\\frac{x}{y}\u0026amp;quot;,\u0026amp;quot;svg_filename\u0026amp;quot;:\u0026amp;quot;mathImg_20250126_225215_x5vy0sjj6v_height_3d205_width_1d784_vertical_1d306.svg\u0026amp;quot;}">
+        </oppia-noninteractive-math>
+      `;
+      component.editedContent = {
+        html: updatedHtml,
+      };
+
+      component.updateSuggestion();
+
+      expect(component.errorMessage).toBe(
+        'Please ensure all components (images, math formulas, concept cards, videos) in your translation match the original content.'
+      );
+      expect(component.errorFound).toBeTrue();
+    });
+  });
 });
diff --git a/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts b/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
index 60f000a7e3e3a..d2c281ff6c7e2 100644
--- a/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
+++ b/core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
@@ -23,6 +23,7 @@ import {
   ViewChild,
   ElementRef,
   Input,
+  SimpleChanges,
 } from '@angular/core';
 import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
 import {AlertsService} from 'services/alerts.service';
@@ -33,6 +34,7 @@ import {LanguageUtilService} from 'domain/utilities/language-util.service';
 import {SiteAnalyticsService} from 'services/site-analytics.service';
 import {ThreadDataBackendApiService} from 'pages/exploration-editor-page/feedback-tab/services/thread-data-backend-api.service';
 import {UserService} from 'services/user.service';
+import {TranslationValidationService} from 'services/translation-validation.service';
 import {ValidatorsService} from 'services/validators.service';
 import {ThreadMessage} from 'domain/feedback_message/ThreadMessage.model';
 import {AppConstants} from 'app.constants';
@@ -60,7 +62,7 @@ interface ActiveContributionDetailsDict {
   topic_name: string;
 }
 
-interface SuggestionChangeDict {
+export interface SuggestionChangeDict {
   cmd: string;
   content_html: string | string[];
   content_id: string;
@@ -70,7 +72,7 @@ interface SuggestionChangeDict {
   translation_html: string;
 }
 
-interface ActiveSuggestionDict {
+export interface ActiveSuggestionDict {
   author_name: string;
   change_cmd: SuggestionChangeDict;
   exploration_content_html: string | string[] | null;
@@ -163,6 +165,8 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
   hasQueuedSuggestion: boolean = false;
   currentSnackbarRef?: MatSnackBarRef<UndoSnackbarComponent>;
   isUndoFeatureEnabled: boolean = false;
+  incompleteTranslationErrorIsShown: boolean = false;
+
   @Input() altTextIsDisplayed: boolean = false;
 
   @ViewChild('contentPanel')
@@ -206,7 +210,8 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     private userService: UserService,
     private validatorsService: ValidatorsService,
     private snackBar: MatSnackBar,
-    private platformFeatureService: PlatformFeatureService
+    private platformFeatureService: PlatformFeatureService,
+    private translationValidationService: TranslationValidationService
   ) {}
 
   ngOnInit(): void {
@@ -245,8 +250,15 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     this.isLastItem = this.remainingContributionIds.length === 0;
     this.allContributions = this.suggestionIdToContribution;
     this.allContributions[this.activeSuggestionId] = this.activeContribution;
-
     this.refreshActiveContributionState();
+    const translationError =
+      this.translationValidationService.validateTranslationFromHtmlStrings(
+        this.activeSuggestion.change_cmd.content_html as string,
+        this.translationHtml
+      );
+    this.incompleteTranslationErrorIsShown =
+      translationError.hasUntranslatedElements;
+
     // The 'html' value is passed as an object as it is required for
     // schema-based-editor. Otherwise the corrrectly updated value for
     // the translation is not received from the editor when the translation
@@ -367,9 +379,45 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     }
   }
 
+  areComponentsMismatched(): boolean {
+    const translationError =
+      this.translationValidationService.validateTranslationFromHtmlStrings(
+        this.activeSuggestion.change_cmd.content_html as string,
+        this.editedContent.html
+      );
+
+    this.incompleteTranslationErrorIsShown =
+      translationError.hasUntranslatedElements;
+    return translationError.hasUntranslatedElements;
+  }
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (changes.editedContent && this.editedContent) {
+      const componentsAreMismatched = this.areComponentsMismatched();
+
+      if (!componentsAreMismatched) {
+        this.errorMessage = '';
+        this.errorFound = false;
+      }
+    }
+  }
+
+  get updateIsDisabled(): boolean {
+    return this.startedEditing && this.areComponentsMismatched();
+  }
+
   updateSuggestion(): void {
     const updatedTranslation = this.editedContent.html;
     const suggestionId = this.activeSuggestion.suggestion_id;
+
+    if (this.areComponentsMismatched()) {
+      this.errorMessage =
+        'Please ensure all components (images, math formulas, concept cards, videos) ' +
+        'in your translation match the original content.';
+      this.errorFound = true;
+      return;
+    }
+
     this.preEditTranslationHtml = this.translationHtml;
     this.translationHtml = updatedTranslation;
     this.contributionAndReviewService.updateTranslationSuggestionAsync(
diff --git a/core/templates/services/translation-validation.service.spec.ts b/core/templates/services/translation-validation.service.spec.ts
new file mode 100644
index 0000000000000..ac6bbe05e8a45
--- /dev/null
+++ b/core/templates/services/translation-validation.service.spec.ts
@@ -0,0 +1,231 @@
+// Copyright 2023 The Oppia Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS-IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * @fileoverview Unit tests for TranslationValidationService.
+ */
+
+import {TestBed} from '@angular/core/testing';
+import {TranslationValidationService} from './translation-validation.service';
+
+describe('TranslationValidationService', () => {
+  let translationValidationService: TranslationValidationService;
+
+  const htmlWithComponents = `
+    <p>Content with components</p>
+    <oppia-noninteractive-image
+      alt-with-value="&amp;quot;Image description&amp;quot;"
+      caption-with-value="&amp;quot;Image caption&amp;quot;"
+      filepath-with-value="&amp;quot;img_20241109_030945_oc195e5356_height_350_width_450.svg&amp;quot;">
+    </oppia-noninteractive-image>
+    <oppia-noninteractive-math 
+      math_content-with-value="{\u0026amp;quot;raw_latex\u0026amp;quot;:\u0026amp;quot;\\\\frac{x}{y}\u0026amp;quot;,\u0026amp;quot;svg_filename\u0026amp;quot;:\u0026amp;quot;mathImg_20250126_225215_x5vy0sjj6v_height_3d205_width_1d784_vertical_1d306.svg\u0026amp;quot;}">
+    </oppia-noninteractive-math>
+    <oppia-noninteractive-skillreview 
+      skill_id-with-value="&amp;quot;wfLsQD3CTfrI&amp;quot;" 
+      text-with-value="&amp;quot;concept card&amp;quot;">
+    </oppia-noninteractive-skillreview>
+  `;
+  const htmlWithoutComponents = '<p>Content without components</p>';
+  const htmlWithMultipleComponents = `
+  <p>Content with multiple components</p>
+  <oppia-noninteractive-image
+    alt-with-value="&amp;quot;Image 1&amp;quot;"
+    caption-with-value="&amp;quot;Image 1 caption&amp;quot;"
+    filepath-with-value="&amp;quot;img1.svg&amp;quot;">
+  </oppia-noninteractive-image>
+  <oppia-noninteractive-image
+    alt-with-value="&amp;quot;Image 2&amp;quot;"
+    caption-with-value="&amp;quot;Image 2 caption&amp;quot;"
+    filepath-with-value="&amp;quot;img2.svg&amp;quot;">
+  </oppia-noninteractive-image>
+  <oppia-noninteractive-math 
+    math_content-with-value="{\u0026amp;quot;raw_latex\u0026amp;quot;:\u0026amp;quot;\\\\frac{x}{y}\u0026amp;quot;}">
+  </oppia-noninteractive-math>
+  <oppia-noninteractive-math 
+    math_content-with-value="{\u0026amp;quot;raw_latex\u0026amp;quot;:\u0026amp;quot;\\\\frac{a}{b}\u0026amp;quot;}">
+  </oppia-noninteractive-math>
+  <oppia-noninteractive-skillreview 
+    skill_id-with-value="&amp;quot;skill1&amp;quot;"
+    text-with-value="&amp;quot;concept card 1&amp;quot;">
+  </oppia-noninteractive-skillreview>
+  <oppia-noninteractive-skillreview 
+    skill_id-with-value="&amp;quot;skill2&amp;quot;"
+    text-with-value="&amp;quot;concept card 2&amp;quot;">
+  </oppia-noninteractive-skillreview>
+  `;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({});
+    translationValidationService = TestBed.inject(TranslationValidationService);
+  });
+
+  describe('getElementAttributeTexts', () => {
+    it('should extract image attributes correctly', () => {
+      const domParser = new DOMParser();
+      const elements = domParser
+        .parseFromString(htmlWithComponents, 'text/html')
+        .getElementsByTagName('*');
+
+      const result = translationValidationService.getElementAttributeTexts(
+        elements,
+        'filepath-with-value'
+      );
+      expect(result.length).toBe(1);
+      expect(result[0]).toBe(
+        'img_20241109_030945_oc195e5356_height_350_width_450.svg'
+      );
+    });
+
+    it('should handle missing attributes', () => {
+      const domParser = new DOMParser();
+      const elements = domParser
+        .parseFromString(
+          '<oppia-noninteractive-image></oppia-noninteractive-image>',
+          'text/html'
+        )
+        .getElementsByTagName('*');
+
+      const result = translationValidationService.getElementAttributeTexts(
+        elements,
+        'filepath-with-value'
+      );
+      expect(result.length).toBe(0);
+    });
+  });
+
+  describe('isTranslationCompleted', () => {
+    it('should return true for matching components', () => {
+      const domParser = new DOMParser();
+      const originalElements = domParser
+        .parseFromString(htmlWithComponents, 'text/html')
+        .getElementsByTagName('*');
+      const translatedElements = domParser
+        .parseFromString(htmlWithComponents, 'text/html')
+        .getElementsByTagName('*');
+
+      expect(
+        translationValidationService.isTranslationCompleted(
+          originalElements,
+          translatedElements
+        )
+      ).toBeTrue();
+    });
+
+    it('should return false for mismatched components', () => {
+      const domParser = new DOMParser();
+      const originalElements = domParser
+        .parseFromString(htmlWithComponents, 'text/html')
+        .getElementsByTagName('*');
+      const translatedElements = domParser
+        .parseFromString(htmlWithoutComponents, 'text/html')
+        .getElementsByTagName('*');
+
+      expect(
+        translationValidationService.isTranslationCompleted(
+          originalElements,
+          translatedElements
+        )
+      ).toBeFalse();
+    });
+  });
+
+  describe('validateTranslation', () => {
+    it('should validate content with matching components', () => {
+      const domParser = new DOMParser();
+      const originalElements = domParser
+        .parseFromString(htmlWithComponents, 'text/html')
+        .getElementsByTagName('*');
+      const translatedElements = domParser
+        .parseFromString(htmlWithComponents, 'text/html')
+        .getElementsByTagName('*');
+
+      const result = translationValidationService.validateTranslation(
+        originalElements,
+        translatedElements
+      );
+      expect(result.hasUntranslatedElements).toBeFalse();
+      expect(result.hasDuplicateAltTexts).toBeTrue();
+      expect(result.hasDuplicateDescriptions).toBeTrue();
+    });
+
+    it('should detect missing components in translation', () => {
+      const domParser = new DOMParser();
+      const originalElements = domParser
+        .parseFromString(htmlWithComponents, 'text/html')
+        .getElementsByTagName('*');
+      const translatedElements = domParser
+        .parseFromString(htmlWithoutComponents, 'text/html')
+        .getElementsByTagName('*');
+
+      const result = translationValidationService.validateTranslation(
+        originalElements,
+        translatedElements
+      );
+      expect(result.hasUntranslatedElements).toBeTrue();
+    });
+
+    it('should validate when original has no components', () => {
+      const domParser = new DOMParser();
+      const originalElements = domParser
+        .parseFromString(htmlWithoutComponents, 'text/html')
+        .getElementsByTagName('*');
+      const translatedElements = domParser
+        .parseFromString(htmlWithoutComponents, 'text/html')
+        .getElementsByTagName('*');
+
+      const result = translationValidationService.validateTranslation(
+        originalElements,
+        translatedElements
+      );
+      expect(result.hasUntranslatedElements).toBeFalse();
+      expect(result.hasDuplicateAltTexts).toBeFalse();
+      expect(result.hasDuplicateDescriptions).toBeFalse();
+    });
+
+    it('should validate multiple components correctly', () => {
+      const domParser = new DOMParser();
+      const originalElements = domParser
+        .parseFromString(htmlWithMultipleComponents, 'text/html')
+        .getElementsByTagName('*');
+      const translatedElements = domParser
+        .parseFromString(htmlWithMultipleComponents, 'text/html')
+        .getElementsByTagName('*');
+
+      const result = translationValidationService.validateTranslation(
+        originalElements,
+        translatedElements
+      );
+      expect(result.hasUntranslatedElements).toBeFalse();
+      expect(result.hasDuplicateAltTexts).toBeTrue();
+      expect(result.hasDuplicateDescriptions).toBeTrue();
+    });
+
+    it('should detect when translation adds extra components', () => {
+      const domParser = new DOMParser();
+      const originalElements = domParser
+        .parseFromString(htmlWithoutComponents, 'text/html')
+        .getElementsByTagName('*');
+      const translatedElements = domParser
+        .parseFromString(htmlWithComponents, 'text/html')
+        .getElementsByTagName('*');
+
+      const result = translationValidationService.validateTranslation(
+        originalElements,
+        translatedElements
+      );
+      expect(result.hasUntranslatedElements).toBeTrue();
+    });
+  });
+});
diff --git a/core/templates/services/translation-validation.service.ts b/core/templates/services/translation-validation.service.ts
new file mode 100644
index 0000000000000..bfdd56e8be44c
--- /dev/null
+++ b/core/templates/services/translation-validation.service.ts
@@ -0,0 +1,197 @@
+// Copyright 2023 The Oppia Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS-IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * @fileoverview Service for handling HTML parsing
+ * operations focused on image tag detection and comparison.
+ */
+
+import isEqual from 'lodash/isEqual';
+
+import {Injectable} from '@angular/core';
+
+export interface ImageDetails {
+  filePaths: string[];
+  alts: string[];
+  descriptions: string[];
+}
+
+export class TranslationError {
+  constructor(
+    private _hasDuplicateAltTexts: boolean,
+    private _hasDuplicateDescriptions: boolean,
+    private _hasUntranslatedElements: boolean
+  ) {}
+
+  get hasDuplicateDescriptions(): boolean {
+    return this._hasDuplicateDescriptions;
+  }
+
+  get hasDuplicateAltTexts(): boolean {
+    return this._hasDuplicateAltTexts;
+  }
+
+  get hasUntranslatedElements(): boolean {
+    return this._hasUntranslatedElements;
+  }
+}
+
+@Injectable({
+  providedIn: 'root',
+})
+export class TranslationValidationService {
+  private readonly ALLOWED_CUSTOM_TAGS_IN_TRANSLATION_SUGGESTION = [
+    'oppia-noninteractive-image',
+    'oppia-noninteractive-link',
+    'oppia-noninteractive-math',
+    'oppia-noninteractive-skillreview',
+  ];
+
+  constructor() {}
+
+  getElementAttributeTexts(
+    elements: HTMLCollectionOf<Element>,
+    type: string
+  ): string[] {
+    const textWrapperLength = 6;
+    const attributes = Array.from(elements, function (element: Element) {
+      // A sample element would be as <oppia-noninteractive-image alt-with-value
+      // ="&amp;quot;Image description&amp;quot;" caption-with-value=
+      // "&amp;quot;Image caption&amp;quot;" filepath-with-value="&amp;quot;
+      // img_2021029_210552_zbmdt94_height_54_width_490.png&amp;quot;">
+      // </oppia-noninteractive-image>
+      if (element.localName === 'oppia-noninteractive-image') {
+        const attribute = element.attributes[
+          type as keyof NamedNodeMap
+        ] as Attr;
+        if (!attribute) {
+          return null;
+        }
+        const attributeValue = attribute.value;
+        return attributeValue.substring(
+          textWrapperLength,
+          attributeValue.length - textWrapperLength
+        );
+      }
+    });
+    // Typecasted to string[] because Array.from() returns
+    // (string | undefined)[] and we need to remove the undefined elements.
+    return attributes.filter(attributeValues => attributeValues) as string[];
+  }
+
+  getImageAttributeTexts(
+    htmlElements: HTMLCollectionOf<Element>
+  ): ImageDetails {
+    return {
+      filePaths: this.getElementAttributeTexts(
+        htmlElements,
+        'filepath-with-value'
+      ),
+      alts: this.getElementAttributeTexts(htmlElements, 'alt-with-value'),
+      descriptions: this.getElementAttributeTexts(
+        htmlElements,
+        'caption-with-value'
+      ),
+    };
+  }
+
+  hasSomeDuplicateElements(
+    originalElements: string[],
+    translatedElements: string[]
+  ): boolean {
+    const mathEquationRegex = new RegExp(
+      /(?:(?:^|[-+_*/=])(?:\s*-?\d+(\.\d+)?(?:[eE][+-]?\d+)?\s*))+$/
+    );
+    if (originalElements.length === 0) {
+      return false;
+    }
+    const hasMatchingTranslatedElement = (element: string) =>
+      translatedElements.includes(element) &&
+      originalElements.length > 0 &&
+      !mathEquationRegex.test(element);
+    return originalElements.some(hasMatchingTranslatedElement);
+  }
+
+  validateTranslationFromHtmlStrings(
+    originalHtml: string,
+    translatedHtml: string
+  ): TranslationError {
+    const domParser = new DOMParser();
+    const originalElements = domParser.parseFromString(
+      originalHtml,
+      'text/html'
+    );
+    const translatedElements = domParser.parseFromString(
+      translatedHtml,
+      'text/html'
+    );
+
+    return this.validateTranslation(
+      originalElements.getElementsByTagName('*'),
+      translatedElements.getElementsByTagName('*')
+    );
+  }
+
+  isTranslationCompleted(
+    originalElements: HTMLCollectionOf<Element>,
+    translatedElements: HTMLCollectionOf<Element>
+  ): boolean {
+    // Checks if there are custom tags present in the original content but not
+    // in the translated content.
+    const filteredOriginalElements = Array.from(originalElements, el =>
+      el.tagName.toLowerCase()
+    )
+      .filter(tagName =>
+        this.ALLOWED_CUSTOM_TAGS_IN_TRANSLATION_SUGGESTION.includes(tagName)
+      )
+      .sort();
+    const filteredTranslatedElements = Array.from(translatedElements, el =>
+      el.tagName.toLowerCase()
+    )
+      .filter(tagName =>
+        this.ALLOWED_CUSTOM_TAGS_IN_TRANSLATION_SUGGESTION.includes(tagName)
+      )
+      .sort();
+    return isEqual(filteredOriginalElements, filteredTranslatedElements);
+  }
+
+  validateTranslation(
+    textToTranslate: HTMLCollectionOf<Element>,
+    translatedText: HTMLCollectionOf<Element>
+  ): TranslationError {
+    const translatedElements: ImageDetails =
+      this.getImageAttributeTexts(translatedText);
+    const originalElements: ImageDetails =
+      this.getImageAttributeTexts(textToTranslate);
+
+    const hasDuplicateAltTexts = this.hasSomeDuplicateElements(
+      originalElements.alts,
+      translatedElements.alts
+    );
+    const hasDuplicateDescriptions = this.hasSomeDuplicateElements(
+      originalElements.descriptions,
+      translatedElements.descriptions
+    );
+    const hasUntranslatedElements = !this.isTranslationCompleted(
+      textToTranslate,
+      translatedText
+    );
+
+    return new TranslationError(
+      hasDuplicateAltTexts,
+      hasDuplicateDescriptions,
+      hasUntranslatedElements
+    );
+  }
+}


Reviews:
user: login: masterboy376
body: I think the PR is missing tests for the changes you made. Kindly add them.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2024-11-11T00:52:40Z
user: login: StephenYu2018
body: Several concerns before merging.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2024-11-26T15:11:20Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-12-01T11:36:43Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-12-01T11:41:26Z
user: login: StephenYu2018
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-12-08T13:26:52Z
user: login: StephenYu2018
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-12-08T13:58:31Z
user: login: seanlip
state: COMMENTED
author_association: MEMBER
submitted_at: 2024-12-09T03:53:56Z
user: login: seanlip
state: COMMENTED
author_association: MEMBER
submitted_at: 2024-12-09T03:55:11Z
user: login: StephenYu2018
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-12-09T22:18:29Z
user: login: seanlip
state: COMMENTED
author_association: MEMBER
submitted_at: 2024-12-10T06:31:09Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-12-12T05:45:09Z
user: login: StephenYu2018
body: One concern.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2024-12-16T20:02:06Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-12-17T08:23:09Z
user: login: seanlip
state: COMMENTED
author_association: MEMBER
submitted_at: 2024-12-17T08:28:47Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-12-23T03:39:07Z
user: login: StephenYu2018
body: LGTM!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2024-12-30T17:28:18Z
user: login: Vir-8
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-12-31T11:48:35Z
user: login: masterboy376
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-01-01T06:09:11Z
user: login: chris7716
body: Thanks! Took a pass.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-02T04:50:16Z
user: login: Nik-09
state: APPROVED
author_association: MEMBER
submitted_at: 2025-01-02T16:42:18Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-03T18:02:26Z
user: login: seanlip
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-01-04T03:05:54Z
user: login: seanlip
body: @TARishabh Please show an example video where you have multiple components -- concept cards, math, images. The number of components of each type should match between the original and the translation. What does the error message look like in that case?

Thanks!
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-01-11T09:17:52Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-02T19:41:51Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-02T19:43:05Z
user: login: seanlip
body: Thanks @TARishabh, took a pass -- PTAL. 
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-02-04T18:03:40Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-15T08:11:40Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-15T08:13:50Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-15T08:17:19Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-22T09:56:11Z
user: login: seanlip
body: Thanks @TARishabh, took a pass.

Also one question. What did you mean exactly when you said "I also double-checked with @masterboy376 , and couldn't find any unresolved comments." in https://github.com/oppia/oppia/pull/21241#issuecomment-2676116315 -- what sort of double-checking did you do, exactly? This is different from what @masterboy376 told me, which was that he sent you the link of unanswered comments and asked you to reply to those and request a re-review.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-02-23T04:38:29Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-25T07:47:24Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-25T07:48:41Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-25T07:49:30Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-25T07:50:46Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-25T07:51:41Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-25T07:51:54Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-25T07:53:52Z
user: login: seanlip
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-25T08:01:30Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-25T08:48:49Z
user: login: seanlip
body: Almost there!
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-02-28T18:41:41Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T11:24:48Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T12:02:38Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T12:03:11Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T12:05:37Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T12:06:23Z
user: login: seanlip
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-03T17:55:00Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T10:15:45Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T10:25:35Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T17:14:56Z
user: login: seanlip
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T17:20:21Z
user: login: seanlip
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T17:58:29Z
user: login: chris7716
body: LGTM! Please address concerns highlighted by @seanlip Thanks!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T16:09:12Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T09:29:06Z
user: login: seanlip
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-21T13:13:12Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T19:12:51Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T19:27:55Z
user: login: seanlip
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-23T17:34:19Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T10:51:59Z
user: login: seanlip
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T19:09:17Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T19:20:50Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T19:32:06Z
user: login: seanlip
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-01T16:33:19Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-06T09:30:34Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-06T09:31:42Z
user: login: seanlip
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-06T09:39:47Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-06T09:54:22Z
user: login: seanlip
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-06T10:13:26Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-06T11:04:05Z
user: login: seanlip
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-06T11:10:10Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-06T15:08:26Z
user: login: seanlip
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T17:35:35Z
user: login: seanlip
body: One more comment.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-08T17:35:55Z
user: login: TARishabh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T20:59:06Z
user: login: seanlip
body: Thanks, LGTM!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-11T02:33:10Z

Review comments:
diff_hunk: @@ -367,9 +374,26 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     }
   }
 
+  isImageRemoved(): boolean {
+    const tempDiv = this.renderer.createElement('div');
+    this.renderer.setProperty(tempDiv, 'innerHTML', this.editedContent.html);
+    return tempDiv.querySelector('oppia-noninteractive-image') === null;
+  }
+
+  get isUpdateDisabled(): boolean {
+    return this.startedEditing && this.isImageRemoved();
+  }
+
   updateSuggestion(): void {
     const updatedTranslation = this.editedContent.html;
     const suggestionId = this.activeSuggestion.suggestion_id;
+    if (this.initialHasImage && this.isImageRemoved()) {
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: masterboy376
body: Here the condition is not considering the count of images in the translation suggestion. Imagine if there are 2 images in a suggestion and the reviewer removes one of them then this check can be bypassed. Consider matching the number of images.
created_at: 2024-11-11T00:40:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1034,6 +1034,82 @@ def test_wrong_suggestion_raise_error_while_updating_translation_suggestion(
                 suggestion.suggestion_id, 'test_translation'
             )
 
+    def test_update_translation_suggestion_rejects_image_removal(
path: core/domain/suggestion_services_test.py
user: login: masterboy376
body: I think renaming this function to "test_updating_translation_suggestion_raise_error_when_image_is_removed" would be good.
created_at: 2024-11-25T04:12:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2679,4 +2732,151 @@ describe('Translation Suggestion Review Modal Component', function () {
 
     expect(result).toEqual(expectedHtmlString);
   });
+
+  describe('Translation Suggestion Review Modal - Image Count Feature', () => {
+    const htmlWithImage = `
+      <p>Content with image</p>
+      <oppia-noninteractive-image
+        alt-with-value="&amp;quot;Image description&amp;quot;"
+        filepath-with-value="&amp;quot;img_20241109_030945_oc195e5356_height_350_width_450.svg&amp;quot;">
+      </oppia-noninteractive-image>
+    `;
+    const htmlWithoutImage = '<p>Content without image</p>';
+
+    beforeEach(() => {
+      component.initialSuggestionId = 'suggestion_1';
+      component.suggestionIdToContribution = {
+        suggestion_1: {
+          suggestion: {
+            author_name: 'author_name',
+            language_code: 'language_code',
+            last_updated_msecs: 1559074000000,
+            status: 'status',
+            suggestion_id: 'suggestion_1',
+            target_id: '1',
+            target_type: 'target_type',
+            suggestion_type: 'translate_content',
+            change_cmd: {
+              content_id: 'hint_1',
+              content_html: '<p>content</p>',
+              translation_html: htmlWithImage,
+              state_name: 'StateName',
+              cmd: 'edit_state_property',
+              data_format: 'html',
+              language_code: 'language_code',
+            },
+            exploration_content_html: '<p>content</p>',
+          },
+          details: {
+            topic_name: 'topic_1',
+            story_title: 'story_1',
+            chapter_title: 'chapter_1',
+          },
+        },
+        suggestion_2: {
+          suggestion: {
+            author_name: 'author_name',
+            language_code: 'language_code',
+            last_updated_msecs: 1559074000000,
+            status: 'status',
+            suggestion_id: 'suggestion_2',
+            target_id: '2',
+            target_type: 'target_type',
+            suggestion_type: 'translate_content',
+            change_cmd: {
+              content_id: 'hint_2',
+              content_html: '<p>content</p>',
+              translation_html: htmlWithoutImage,
+              state_name: 'StateName',
+              cmd: 'edit_state_property',
+              data_format: 'html',
+              language_code: 'language_code',
+            },
+            exploration_content_html: '<p>content</p>',
+          },
+          details: {
+            topic_name: 'topic_2',
+            story_title: 'story_2',
+            chapter_title: 'chapter_2',
+          },
+        },
+      };
+      component.translationHtml = htmlWithImage;
+      component.initialImageCount = 1;
+      spyOn(renderer, 'setProperty').and.callThrough();
+    });
+
+    it('should set the initial image count based on the translation HTML', () => {
+      component.ngOnInit();
+      expect(component.initialImageCount).toBe(1);
+
+      expect(renderer.setProperty).toHaveBeenCalledWith(
+        jasmine.any(Object),
+        'innerHTML',
+        component.translationHtml || ''
+      );
+    });
+
+    it('should correctly detect when an image is removed', () => {
+      component.translationHtml = htmlWithImage;
+
+      component.ngOnInit();
+
+      component.editedContent = {
+        html: htmlWithoutImage,
+      };
+      const isImageRemoved = component.isImageRemoved();
+
+      expect(isImageRemoved).toBeTrue();
+    });
+
+    it('should disable the update button if an image is removed', () => {
+      component.translationHtml = htmlWithImage;
+      component.ngOnInit();
+
+      component.startedEditing = true;
+      component.editedContent = {
+        html: htmlWithoutImage,
+      };
+
+      expect(component.isUpdateDisabled).toBeTrue();
+    });
+
+    it('should allow the update button if no image was initially present', () => {
+      component.initialSuggestionId = 'suggestion_2';
+      component.translationHtml = htmlWithoutImage;
+      component.ngOnInit();
+
+      component.startedEditing = true;
+      component.editedContent = {
+        html: htmlWithoutImage,
+      };
+      expect(component.isUpdateDisabled).toBeFalse();
+    });
+
+    it('should allow the update button if the image was not removed', () => {
+      component.translationHtml = htmlWithImage;
+      component.ngOnInit();
+
+      component.startedEditing = true;
+      component.editedContent = {
+        html: htmlWithImage,
+      };
+
+      expect(component.isUpdateDisabled).toBeFalse();
+    });
+
+    it('should allow the update button if images are added instead of being removed', () => {
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts
user: login: masterboy376
body: I am not sure about this. Whether we should allow adding image to a translation suggestion when there is no suggestion in the actual content. @seanlip could you PTAL, if possible.
Edited: Hey @TARishabh actually translator should also not be allowed to add an image to a translation suggestion if the original content do not contain any image.
created_at: 2024-11-25T04:22:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -99,6 +101,8 @@
 
 RECENT_REVIEW_OUTCOMES_LIMIT: Final = 100
 
+IMAGE_TAG_REGEX = r'<oppia-noninteractive-image\b[^>]*>'
path: core/domain/suggestion_services.py
user: login: StephenYu2018
body: Consider `<oppia-noninteractive-image link="path/to/img>.png">`, where a user uploaded an image with the filename `img\>.png` (this is possible in Unix Operating Systems as long as it is quoted or escaped). This regex will only match  `<oppia-noninteractive-image link="path/to/img>`, which we should not accept. Please rewrite this regex to handle this example properly. 
created_at: 2024-11-26T14:20:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2142,6 +2146,21 @@ def _update_suggestion_counts_in_community_contribution_stats(
         suggestions, amount)
 
 
+def count_images(html_content: str) -> int:
+    """Counts the number of image tags in the provided HTML content.
+
+    This function uses a regular expression to count the number of
+    <oppia-noninteractive-image> tags within the HTML content.
path: core/domain/suggestion_services.py
user: login: StephenYu2018
body: I don't think its necessary to document this. The function's implementation is 1 line long. It's very clear that we're using regexes from the implementation.
created_at: 2024-11-26T14:26:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -245,8 +248,13 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     this.isLastItem = this.remainingContributionIds.length === 0;
     this.allContributions = this.suggestionIdToContribution;
     this.allContributions[this.activeSuggestionId] = this.activeContribution;
-
     this.refreshActiveContributionState();
+    const tempDiv = this.renderer.createElement('div');
+    this.renderer.setProperty(tempDiv, 'innerHTML', this.translationHtml || '');
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: StephenYu2018
body: The current implementation for might pose a security risk as `this.translationHtml` is user-supplied data. Read more about this at https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML#security_considerations

Also, what's stopping us from counting the number of images using regexes? Ideally we should only render content to the DOM that we intend to show users.
created_at: 2024-11-26T15:09:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -367,9 +375,29 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     }
   }
 
+  isImageRemoved(): boolean {
+    const tempDiv = this.renderer.createElement('div');
+    this.renderer.setProperty(tempDiv, 'innerHTML', this.editedContent.html);
+    const currentImageCount = tempDiv.querySelectorAll(
+      'oppia-noninteractive-image'
+    ).length;
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: StephenYu2018
body: Ditto here.
created_at: 2024-11-26T15:10:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -245,8 +248,13 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     this.isLastItem = this.remainingContributionIds.length === 0;
     this.allContributions = this.suggestionIdToContribution;
     this.allContributions[this.activeSuggestionId] = this.activeContribution;
-
     this.refreshActiveContributionState();
+    const tempDiv = this.renderer.createElement('div');
+    this.renderer.setProperty(tempDiv, 'innerHTML', this.translationHtml || '');
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: TARishabh
body: Hey @StephenYu2018 , thanks for pointing out the security risk and for suggesting some alternative approaches.

Right now, I'm using innerHTML, but I see how that could pose a security risk. The linting rule (oppia/no-bypass-security-phrase) also means I can’t use bypassSecurityTrustHtml for sanitizing translationHtml.

I was considering regex to count the images, but I’m aware regex can be a bit unreliable for HTML parsing, especially with complex or malformed HTML, and it might miss edge cases like nested tags or different attribute variations.

Do you think regex could still work in this case, given the limitations, or would you recommend a better way to sanitize the HTML before I render and count the images?

Thanks !
created_at: 2024-12-01T11:36:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -99,6 +101,8 @@
 
 RECENT_REVIEW_OUTCOMES_LIMIT: Final = 100
 
+IMAGE_TAG_REGEX = r'<oppia-noninteractive-image\b[^>]*>'
path: core/domain/suggestion_services.py
user: login: TARishabh
body: @StephenYu2018 Thanks for pointing this out too. 
I’ve updated the regex to:
`IMAGE_TAG_REGEX = r'<oppia-noninteractive-image\b[^>]*?>(?:.*?)</oppia-noninteractive-image>' `
I also tested this regex with some test cases, including filenames with special characters, and it worked as expected. Just wanted to check if this addresses the issue you mentioned with the > character in filenames. Does this look okay, or is there something else I should tweak?
created_at: 2024-12-01T11:41:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -99,6 +101,8 @@
 
 RECENT_REVIEW_OUTCOMES_LIMIT: Final = 100
 
+IMAGE_TAG_REGEX = r'<oppia-noninteractive-image\b[^>]*>'
path: core/domain/suggestion_services.py
user: login: StephenYu2018
body: Actually nvm. I believe the image filenames that are saved to the datastore are auto-generated by the Oppia app.
created_at: 2024-12-08T13:26:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -245,8 +248,13 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     this.isLastItem = this.remainingContributionIds.length === 0;
     this.allContributions = this.suggestionIdToContribution;
     this.allContributions[this.activeSuggestionId] = this.activeContribution;
-
     this.refreshActiveContributionState();
+    const tempDiv = this.renderer.createElement('div');
+    this.renderer.setProperty(tempDiv, 'innerHTML', this.translationHtml || '');
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: StephenYu2018
body: I'm not sure how exactly we can approach this. Initially, I was thinking about modifying by `textContent` instead of `innerHtml`. But I don't think we can add HTML elements within the `div` element's `textContent` property.

In regards to using regexes, I don't think we nest elements within non-interactive images. I think a regex like: `<oppia-noninteractive-image(?: *attribute-name="value")*><\/oppia-noninteractive-image>` with the global flag and the case insensitive flag could be used to get all the matches for images, and then we count each match as an image.
created_at: 2024-12-08T13:58:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -245,8 +248,13 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     this.isLastItem = this.remainingContributionIds.length === 0;
     this.allContributions = this.suggestionIdToContribution;
     this.allContributions[this.activeSuggestionId] = this.activeContribution;
-
     this.refreshActiveContributionState();
+    const tempDiv = this.renderer.createElement('div');
+    this.renderer.setProperty(tempDiv, 'innerHTML', this.translationHtml || '');
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: seanlip
body: I think a regex approach would be the way to go here as well, and I confirm that we don't nest elements within images. You can probably just check for the number of oppia-noninteractive-image tags along the lines that Stephen proposes.

Also, thanks, @StephenYu2018, for catching this issue!
created_at: 2024-12-09T03:53:55Z
author_association: MEMBER
diff_hunk: @@ -245,8 +248,13 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     this.isLastItem = this.remainingContributionIds.length === 0;
     this.allContributions = this.suggestionIdToContribution;
     this.allContributions[this.activeSuggestionId] = this.activeContribution;
-
     this.refreshActiveContributionState();
+    const tempDiv = this.renderer.createElement('div');
+    this.renderer.setProperty(tempDiv, 'innerHTML', this.translationHtml || '');
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: seanlip
body: Also one question. How do we currently handle this "image count" check in the translation submission process? It would be good to standardize the approaches used there and here.
created_at: 2024-12-09T03:55:11Z
author_association: MEMBER
diff_hunk: @@ -245,8 +248,13 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     this.isLastItem = this.remainingContributionIds.length === 0;
     this.allContributions = this.suggestionIdToContribution;
     this.allContributions[this.activeSuggestionId] = this.activeContribution;
-
     this.refreshActiveContributionState();
+    const tempDiv = this.renderer.createElement('div');
+    this.renderer.setProperty(tempDiv, 'innerHTML', this.translationHtml || '');
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: StephenYu2018
body: @seanlip I believe the image count check is handled entirely on the backend (specifically in `/core/domain/suggestion_registry.py` with the `BaseSuggestion.get_new_image_filenames_added_in_suggestion()` method) in the translation submission process. But this information is needed on the frontend, and I'm having trouble finding any existing frontend functionality that counts HTML tags from strings.
created_at: 2024-12-09T22:18:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -245,8 +248,13 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     this.isLastItem = this.remainingContributionIds.length === 0;
     this.allContributions = this.suggestionIdToContribution;
     this.allContributions[this.activeSuggestionId] = this.activeContribution;
-
     this.refreshActiveContributionState();
+    const tempDiv = this.renderer.createElement('div');
+    this.renderer.setProperty(tempDiv, 'innerHTML', this.translationHtml || '');
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: seanlip
body: Ah ok, thanks @StephenYu2018! Yeah, in that case we'd need to build it, and I think the regex approach sounds reasonable.

@TARishabh One suggestion I have is to maybe implement that as a separate "html parsing" service so that both the submission and review workflows can use it.
created_at: 2024-12-10T06:31:09Z
author_association: MEMBER
diff_hunk: @@ -367,9 +374,26 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     }
   }
 
+  isImageRemoved(): boolean {
+    const tempDiv = this.renderer.createElement('div');
+    this.renderer.setProperty(tempDiv, 'innerHTML', this.editedContent.html);
+    return tempDiv.querySelector('oppia-noninteractive-image') === null;
+  }
+
+  get isUpdateDisabled(): boolean {
+    return this.startedEditing && this.isImageRemoved();
+  }
+
   updateSuggestion(): void {
     const updatedTranslation = this.editedContent.html;
     const suggestionId = this.activeSuggestion.suggestion_id;
+    if (this.initialHasImage && this.isImageRemoved()) {
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: TARishabh
body: @masterboy376 I have added the frontend test cases. Thanks !
created_at: 2024-12-12T05:45:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -367,9 +373,30 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     }
   }
 
+  isImageRemoved(): boolean {
+    const imageTags = this.editedContent.html.match(this.IMAGE_TAG_REGEX);
+    const currentImageCount = imageTags ? imageTags.length : 0;
+    return currentImageCount < this.initialImageCount;
+  }
+
+  get isUpdateDisabled(): boolean {
+    return this.startedEditing && this.isImageRemoved();
+  }
+
+  public getImageTagRegex(): RegExp {
+    return this.IMAGE_TAG_REGEX;
+  }
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: StephenYu2018
body: Why is this a public method if it's not being used in the component's template or outside the component? Also per Sean's [comment](https://github.com/oppia/oppia/pull/21241#discussion_r1877423146), I think it might be better to refactor the HTML parsing functionality for images/videos/etc into a separate service, so other teams can use it.
created_at: 2024-12-16T20:01:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -367,9 +373,30 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     }
   }
 
+  isImageRemoved(): boolean {
+    const imageTags = this.editedContent.html.match(this.IMAGE_TAG_REGEX);
+    const currentImageCount = imageTags ? imageTags.length : 0;
+    return currentImageCount < this.initialImageCount;
+  }
+
+  get isUpdateDisabled(): boolean {
+    return this.startedEditing && this.isImageRemoved();
+  }
+
+  public getImageTagRegex(): RegExp {
+    return this.IMAGE_TAG_REGEX;
+  }
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: TARishabh
body: @StephenYu2018 , thanks for pointing that out! The `getImageTagRegex` method is currently public because it's being accessed in the frontend test cases.

That said, I agree with Sean's suggestion to refactor the HTML parsing functionality into a separate HtmlParsingService. Once the refactor is complete, I will update the test cases to use the new service.
created_at: 2024-12-17T08:23:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -367,9 +373,30 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     }
   }
 
+  isImageRemoved(): boolean {
+    const imageTags = this.editedContent.html.match(this.IMAGE_TAG_REGEX);
+    const currentImageCount = imageTags ? imageTags.length : 0;
+    return currentImageCount < this.initialImageCount;
+  }
+
+  get isUpdateDisabled(): boolean {
+    return this.startedEditing && this.isImageRemoved();
+  }
+
+  public getImageTagRegex(): RegExp {
+    return this.IMAGE_TAG_REGEX;
+  }
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: seanlip
body: Just a general note, you shouldn't make a function public just for the purposes of tests. Test the stuff within the private function through the public API, instead.
created_at: 2024-12-17T08:28:47Z
author_association: MEMBER
diff_hunk: @@ -367,9 +373,30 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     }
   }
 
+  isImageRemoved(): boolean {
+    const imageTags = this.editedContent.html.match(this.IMAGE_TAG_REGEX);
+    const currentImageCount = imageTags ? imageTags.length : 0;
+    return currentImageCount < this.initialImageCount;
+  }
+
+  get isUpdateDisabled(): boolean {
+    return this.startedEditing && this.isImageRemoved();
+  }
+
+  public getImageTagRegex(): RegExp {
+    return this.IMAGE_TAG_REGEX;
+  }
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: TARishabh
body: Thanks @seanlip ! I'll make the changes.
created_at: 2024-12-23T03:39:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2679,4 +2725,125 @@ describe('Translation Suggestion Review Modal Component', function () {
 
     expect(result).toEqual(expectedHtmlString);
   });
+
+  describe('Translation Suggestion Review Modal - Image Count Feature', () => {
+    const htmlWithImage = `
+      <p>Content with image</p>
+      <oppia-noninteractive-image
+        alt-with-value="&amp;quot;Image description&amp;quot;"
+        filepath-with-value="&amp;quot;img_20241109_030945_oc195e5356_height_350_width_450.svg&amp;quot;">
+      </oppia-noninteractive-image>
+    `;
+    const htmlWithoutImage = '<p>Content without image</p>';
+
+    beforeEach(() => {
+      component.initialSuggestionId = 'suggestion_1';
+      component.suggestionIdToContribution = {
+        suggestion_1: {
+          suggestion: {
+            author_name: 'author_name',
+            language_code: 'language_code',
+            last_updated_msecs: 1559074000000,
+            status: 'status',
+            suggestion_id: 'suggestion_1',
+            target_id: '1',
+            target_type: 'target_type',
+            suggestion_type: 'translate_content',
+            change_cmd: {
+              content_id: 'hint_1',
+              content_html: '<p>content</p>',
+              translation_html: htmlWithImage,
+              state_name: 'StateName',
+              cmd: 'edit_state_property',
+              data_format: 'html',
+              language_code: 'language_code',
+            },
+            exploration_content_html: '<p>content</p>',
+          },
+          details: {
+            topic_name: 'topic_1',
+            story_title: 'story_1',
+            chapter_title: 'chapter_1',
+          },
+        },
+        suggestion_2: {
+          suggestion: {
+            author_name: 'author_name',
+            language_code: 'language_code',
+            last_updated_msecs: 1559074000000,
+            status: 'status',
+            suggestion_id: 'suggestion_2',
+            target_id: '2',
+            target_type: 'target_type',
+            suggestion_type: 'translate_content',
+            change_cmd: {
+              content_id: 'hint_2',
+              content_html: '<p>content</p>',
+              translation_html: htmlWithoutImage,
+              state_name: 'StateName',
+              cmd: 'edit_state_property',
+              data_format: 'html',
+              language_code: 'language_code',
+            },
+            exploration_content_html: '<p>content</p>',
+          },
+          details: {
+            topic_name: 'topic_2',
+            story_title: 'story_2',
+            chapter_title: 'chapter_2',
+          },
+        },
+      };
+    });
+
+    it('should initialize with correct image count', () => {
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts
user: login: Vir-8
body: Should we add pre-checks for all of these unit tests?
created_at: 2024-12-31T11:48:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2164,6 +2173,14 @@ def update_translation_suggestion(
             'Expected SuggestionTranslateContent suggestion but found: %s.'
             % type(suggestion).__name__
         )
+    original_html = suggestion.change_cmd.translation_html
+    original_image_count = count_images(original_html)
+    updated_image_count = count_images(translation_html)
+
+    if updated_image_count < original_image_count:
path: core/domain/suggestion_services.py
user: login: chris7716
body: Shouldn't we check whether `updated_image_count == original_image_count`? How could it be updated_image_count > original_image_count

EDIT: I see. You are referring `original_image_count` as the number of images available in the previous translation. I guess we should use the image count in the original text here. And then we should check if `updated_image_count == original_image_count`?
created_at: 2025-01-02T04:38:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -245,8 +249,11 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     this.isLastItem = this.remainingContributionIds.length === 0;
     this.allContributions = this.suggestionIdToContribution;
     this.allContributions[this.activeSuggestionId] = this.activeContribution;
-
     this.refreshActiveContributionState();
+    this.initialImageCount = this.htmlParsingService.countImageTags(
+      this.translationHtml
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: chris7716
body: Shouldn't we consider the number of images in the original content instead of considering the previous translation?
created_at: 2025-01-02T04:46:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2164,6 +2173,14 @@ def update_translation_suggestion(
             'Expected SuggestionTranslateContent suggestion but found: %s.'
             % type(suggestion).__name__
         )
+    original_html = suggestion.change_cmd.translation_html
+    original_image_count = count_images(original_html)
+    updated_image_count = count_images(translation_html)
+
+    if updated_image_count < original_image_count:
path: core/domain/suggestion_services.py
user: login: TARishabh
body: > Shouldn't we check whether `updated_image_count == original_image_count`? How could it be updated_image_count > original_image_count
> 
> EDIT: I see. You are referring `original_image_count` as the number of images available in the previous translation. I guess we should use the image count in the original text here. And then we should check if `updated_image_count == original_image_count`?

Hi @chris7716 ,

I had a quick question regarding the image validation logic. If the original text has no images and the translator adds an image in the updated translation, should we allow it? Currently, the validation logic doesn't restrict adding images, but I wanted to confirm if maintaining parity with the original content (i.e., disallowing added images) is a requirement.

Thanks for clarifying!
created_at: 2025-01-03T18:02:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2164,6 +2173,14 @@ def update_translation_suggestion(
             'Expected SuggestionTranslateContent suggestion but found: %s.'
             % type(suggestion).__name__
         )
+    original_html = suggestion.change_cmd.translation_html
+    original_image_count = count_images(original_html)
+    updated_image_count = count_images(translation_html)
+
+    if updated_image_count < original_image_count:
path: core/domain/suggestion_services.py
user: login: seanlip
body: Yes, it is a requirement.
created_at: 2025-01-04T03:05:53Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,58 @@
+// Copyright 2023 The Oppia Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS-IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * @fileoverview Service for handling HTML parsing
+ * operations focused on image tag detection and comparison.
+ */
+
+import {Injectable} from '@angular/core';
+
+@Injectable({
+  providedIn: 'root',
+})
+export class HtmlParsingService {
+  private readonly IMAGE_TAG_REGEX =
+    /<oppia-noninteractive-image\b[^>]*?filepath-with-value=/g;
+
+  constructor() {}
+
+  /**
+   * Counts the number of <oppia-noninteractive-image> tags in the provided HTML string.
+   * @param {string} htmlString - The HTML string to analyze.
+   * @returns {number} The number of image tags found.
+   */
+  countImageTags(htmlString: string): number {
+    if (!htmlString || typeof htmlString !== 'string') {
+      return 0;
+    }
+
+    const imageTags = htmlString.match(this.IMAGE_TAG_REGEX);
+    return imageTags ? imageTags.length : 0;
+  }
+
+  /**
+   * Compares the original and updated HTML strings to determine if the number
+   * of images is mismatched.
+   * @param {string} originalHtml - The original HTML string with image tags.
+   * @param {string} updatedHtml - The updated (edited) HTML string with image tags.
+   * @returns {boolean} True if the number of image tags in the updated HTML
+   * does not match the original HTML.
+   */
+  isImageCountMismatched(originalHtml: string, updatedHtml: string): boolean {
path: core/templates/services/html-parsing.service.ts
user: login: seanlip
body: ~~This has nothing to do with an "html parsing service". The logic here should stay outside this service, or the service should be renamed.~~

Actually, scratch that ... on second thoughts I think it's probably reasonable since this function compares two HTML strings. Feel free to ignore this.
created_at: 2025-01-11T09:15:23Z
author_association: MEMBER
diff_hunk: @@ -2164,6 +2173,14 @@ def update_translation_suggestion(
             'Expected SuggestionTranslateContent suggestion but found: %s.'
             % type(suggestion).__name__
         )
+    original_html = suggestion.change_cmd.translation_html
+    original_image_count = count_images(original_html)
+    updated_image_count = count_images(translation_html)
+
+    if updated_image_count < original_image_count:
path: core/domain/suggestion_services.py
user: login: TARishabh
body: @seanlip I have made the changes. Thanks !
created_at: 2025-02-02T19:41:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -245,8 +249,11 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     this.isLastItem = this.remainingContributionIds.length === 0;
     this.allContributions = this.suggestionIdToContribution;
     this.allContributions[this.activeSuggestionId] = this.activeContribution;
-
     this.refreshActiveContributionState();
+    this.initialImageCount = this.htmlParsingService.countImageTags(
+      this.translationHtml
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: TARishabh
body: @chris7716 I have made the changes to count the number of components (images, concept cards, math formulae) in original content. Thanks !
created_at: 2025-02-02T19:43:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -99,6 +101,8 @@
 
 RECENT_REVIEW_OUTCOMES_LIMIT: Final = 100
 
+IMAGE_TAG_REGEX = r'<oppia-noninteractive-image\b[^>]*?filepath-with-value='
path: core/domain/suggestion_services.py
user: login: seanlip
body: Don't think you should do this -- we have backend libs for parsing html like bs4.BeautifulSoup. See also functions in html_cleaner.py.

Use those instead. In general regex checks are fragile.
created_at: 2025-02-04T17:55:37Z
author_association: MEMBER
diff_hunk: @@ -2164,6 +2173,18 @@ def update_translation_suggestion(
             'Expected SuggestionTranslateContent suggestion but found: %s.'
             % type(suggestion).__name__
         )
+    original_text_html = suggestion.change_cmd.content_html
+    original_image_count = count_images(original_text_html)
path: core/domain/suggestion_services.py
user: login: seanlip
body: Why are we looking only at images and not the overall counts of the RTE components of different types?
created_at: 2025-02-04T17:56:52Z
author_association: MEMBER
diff_hunk: @@ -1034,6 +1034,286 @@ def test_wrong_suggestion_raise_error_while_updating_translation_suggestion(
                 suggestion.suggestion_id, 'test_translation'
             )
 
+    def create_translation_suggestion(
+            self, content_html: str, translation_html: str
+        ) -> suggestion_registry.SuggestionTranslateContent:
+
path: core/domain/suggestion_services_test.py
user: login: seanlip
body: Drop this blank line
created_at: 2025-02-04T17:57:37Z
author_association: MEMBER
diff_hunk: @@ -26,103 +29,100 @@ <h4 class="oppia-translation-modal-author-header">
   </div>
 </div>
 
+
+<div *ngIf="hasIncompleteTranslationError && startedEditing" tabindex="0" class="alert alert-danger oppia-units-input-error">
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.html
user: login: seanlip
body: Why is this "oppia-units-input-error" -- what are "units" in this context?
created_at: 2025-02-04T17:58:22Z
author_association: MEMBER
diff_hunk: @@ -26,103 +29,100 @@ <h4 class="oppia-translation-modal-author-header">
   </div>
 </div>
 
+
+<div *ngIf="hasIncompleteTranslationError && startedEditing" tabindex="0" class="alert alert-danger oppia-units-input-error">
+  <strong>Warning:</strong> Please ensure all components (images, math formulas, concept cards) in your translation match
+  the original content to proceed.
+</div>
+
 <div class="modal-body">
   <div *ngIf="status == 'rejected'" class="translation-suggestion-review-message alert alert-danger">
-    <p>See your submitted translation below. Please submit a new translation to address the requested revisions.</p>
+    <p>
+      See your submitted translation below. Please submit a new translation to
+      address the requested revisions.
+    </p>
   </div>
   <section class="oppia-suggestion-review-container">
     <div class="oppia-suggestion-review-panel-container float-left">
       <div class="oppia-suggestion-review-panel-header-container">
         <strong>Content to translate:</strong>
       </div>
-      <div class="oppia-suggestion-review-panel"
-           [ngClass]="isContentExpanded ? 'oppia-content-expanded' : 'oppia-content-collapsed'"
-           #contentContainer>
+      <div class="oppia-suggestion-review-panel" [ngClass]="
+          isContentExpanded
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.html
user: login: seanlip
body: Why are all these changes being made, that have nothing to do with the core of this PR (AFAICT)?

Please drop all irrelevant changes, they make the PR harder to review.
created_at: 2025-02-04T17:58:46Z
author_association: MEMBER
diff_hunk: @@ -163,6 +165,8 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
   hasQueuedSuggestion: boolean = false;
   currentSnackbarRef?: MatSnackBarRef<UndoSnackbarComponent>;
   isUndoFeatureEnabled: boolean = false;
+  hasIncompleteTranslationError: boolean = false;
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: seanlip
body: We try not to start variables with verbs. Perhaps something like incompleteTranslationErrorIsShown?
created_at: 2025-02-04T17:59:54Z
author_association: MEMBER
diff_hunk: @@ -367,9 +391,54 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     }
   }
 
+  isComponentsMismatched(): boolean {
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: seanlip
body: Seems to be a bunch of copy/paste between here and above, please don't repeat code.
created_at: 2025-02-04T18:00:44Z
author_association: MEMBER
diff_hunk: @@ -2679,4 +2710,442 @@ describe('Translation Suggestion Review Modal Component', function () {
 
     expect(result).toEqual(expectedHtmlString);
   });
+
+  describe('Translation Suggestion Review Modal - Component Validation', () => {
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts
user: login: seanlip
body: Do you have a test to verify that we're looking at the types as well, not just the number of components? E.g. comparing an image component in one html to a link component in another?
created_at: 2025-02-04T18:03:37Z
author_association: MEMBER
diff_hunk: @@ -2679,4 +2710,442 @@ describe('Translation Suggestion Review Modal Component', function () {
 
     expect(result).toEqual(expectedHtmlString);
   });
+
+  describe('Translation Suggestion Review Modal - Component Validation', () => {
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts
user: login: TARishabh
body: Yes, I've added a test, in latest commit to verify component type mismatches (e.g., image vs. math). Let me know if further refinements are needed.
created_at: 2025-02-15T08:11:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -367,9 +391,54 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     }
   }
 
+  isComponentsMismatched(): boolean {
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: TARishabh
body: I’ve moved the validation logic to the translation-validation service and reused it in translation-suggestion-review-modal.component.ts and translation-modal.component.ts to avoid duplication
created_at: 2025-02-15T08:13:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -163,6 +165,8 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
   hasQueuedSuggestion: boolean = false;
   currentSnackbarRef?: MatSnackBarRef<UndoSnackbarComponent>;
   isUndoFeatureEnabled: boolean = false;
+  hasIncompleteTranslationError: boolean = false;
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: TARishabh
body: Got it, I've renamed it to incompleteTranslationErrorIsShown to follow the guideline. Just curious though—some variables above, like hasQueuedSuggestion and isUndoFeatureEnabled, do start with verbs. Is there a specific convention for when it's okay to use verbs in variable names?
created_at: 2025-02-15T08:17:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,103 +29,100 @@ <h4 class="oppia-translation-modal-author-header">
   </div>
 </div>
 
+
+<div *ngIf="hasIncompleteTranslationError && startedEditing" tabindex="0" class="alert alert-danger oppia-units-input-error">
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.html
user: login: TARishabh
body: The oppia-units-input-error class is already used in customize-interaction-modal.component.html and number-with-units-interaction.component.html, specifically for handling error states related to unit inputs. Given that oppia-input-error is also used elsewhere, should I standardize on one class name, or does oppia-units-input-error serve a specific distinction for unit-based interactions?
created_at: 2025-02-15T08:29:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,103 +29,100 @@ <h4 class="oppia-translation-modal-author-header">
   </div>
 </div>
 
+
+<div *ngIf="hasIncompleteTranslationError && startedEditing" tabindex="0" class="alert alert-danger oppia-units-input-error">
+  <strong>Warning:</strong> Please ensure all components (images, math formulas, concept cards) in your translation match
+  the original content to proceed.
+</div>
+
 <div class="modal-body">
   <div *ngIf="status == 'rejected'" class="translation-suggestion-review-message alert alert-danger">
-    <p>See your submitted translation below. Please submit a new translation to address the requested revisions.</p>
+    <p>
+      See your submitted translation below. Please submit a new translation to
+      address the requested revisions.
+    </p>
   </div>
   <section class="oppia-suggestion-review-container">
     <div class="oppia-suggestion-review-panel-container float-left">
       <div class="oppia-suggestion-review-panel-header-container">
         <strong>Content to translate:</strong>
       </div>
-      <div class="oppia-suggestion-review-panel"
-           [ngClass]="isContentExpanded ? 'oppia-content-expanded' : 'oppia-content-collapsed'"
-           #contentContainer>
+      <div class="oppia-suggestion-review-panel" [ngClass]="
+          isContentExpanded
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.html
user: login: TARishabh
body: Apologies for the unrelated changes! It looks like my formatter might have applied some modifications automatically. I've reverted the irrelevant changes to keep the PR focused.
created_at: 2025-02-15T08:31:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -99,6 +101,8 @@
 
 RECENT_REVIEW_OUTCOMES_LIMIT: Final = 100
 
+IMAGE_TAG_REGEX = r'<oppia-noninteractive-image\b[^>]*?filepath-with-value='
path: core/domain/suggestion_services.py
user: login: TARishabh
body: I've updated the code to use BeautifulSoup to parse html. Thanks !
created_at: 2025-02-17T13:39:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2164,6 +2173,18 @@ def update_translation_suggestion(
             'Expected SuggestionTranslateContent suggestion but found: %s.'
             % type(suggestion).__name__
         )
+    original_text_html = suggestion.change_cmd.content_html
+    original_image_count = count_images(original_text_html)
path: core/domain/suggestion_services.py
user: login: TARishabh
body: I've updated the implementation to count all RTE components, not just images. The check now ensures that all component types (images, math, concept cards, and videos) remain consistent in the updated translation.
created_at: 2025-02-17T13:42:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1034,6 +1034,286 @@ def test_wrong_suggestion_raise_error_while_updating_translation_suggestion(
                 suggestion.suggestion_id, 'test_translation'
             )
 
+    def create_translation_suggestion(
+            self, content_html: str, translation_html: str
+        ) -> suggestion_registry.SuggestionTranslateContent:
+
path: core/domain/suggestion_services_test.py
user: login: TARishabh
body: Got it 
created_at: 2025-02-17T13:43:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2679,4 +2725,125 @@ describe('Translation Suggestion Review Modal Component', function () {
 
     expect(result).toEqual(expectedHtmlString);
   });
+
+  describe('Translation Suggestion Review Modal - Image Count Feature', () => {
+    const htmlWithImage = `
+      <p>Content with image</p>
+      <oppia-noninteractive-image
+        alt-with-value="&amp;quot;Image description&amp;quot;"
+        filepath-with-value="&amp;quot;img_20241109_030945_oc195e5356_height_350_width_450.svg&amp;quot;">
+      </oppia-noninteractive-image>
+    `;
+    const htmlWithoutImage = '<p>Content without image</p>';
+
+    beforeEach(() => {
+      component.initialSuggestionId = 'suggestion_1';
+      component.suggestionIdToContribution = {
+        suggestion_1: {
+          suggestion: {
+            author_name: 'author_name',
+            language_code: 'language_code',
+            last_updated_msecs: 1559074000000,
+            status: 'status',
+            suggestion_id: 'suggestion_1',
+            target_id: '1',
+            target_type: 'target_type',
+            suggestion_type: 'translate_content',
+            change_cmd: {
+              content_id: 'hint_1',
+              content_html: '<p>content</p>',
+              translation_html: htmlWithImage,
+              state_name: 'StateName',
+              cmd: 'edit_state_property',
+              data_format: 'html',
+              language_code: 'language_code',
+            },
+            exploration_content_html: '<p>content</p>',
+          },
+          details: {
+            topic_name: 'topic_1',
+            story_title: 'story_1',
+            chapter_title: 'chapter_1',
+          },
+        },
+        suggestion_2: {
+          suggestion: {
+            author_name: 'author_name',
+            language_code: 'language_code',
+            last_updated_msecs: 1559074000000,
+            status: 'status',
+            suggestion_id: 'suggestion_2',
+            target_id: '2',
+            target_type: 'target_type',
+            suggestion_type: 'translate_content',
+            change_cmd: {
+              content_id: 'hint_2',
+              content_html: '<p>content</p>',
+              translation_html: htmlWithoutImage,
+              state_name: 'StateName',
+              cmd: 'edit_state_property',
+              data_format: 'html',
+              language_code: 'language_code',
+            },
+            exploration_content_html: '<p>content</p>',
+          },
+          details: {
+            topic_name: 'topic_2',
+            story_title: 'story_2',
+            chapter_title: 'chapter_2',
+          },
+        },
+      };
+    });
+
+    it('should initialize with correct image count', () => {
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts
user: login: TARishabh
body: Added the pre-checks ! Thanks !
created_at: 2025-02-21T05:35:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2142,28 +2145,70 @@ def _update_suggestion_counts_in_community_contribution_stats(
         suggestions, amount)
 
 
+def count_rte_components(html_content: str) -> Dict[str, int]:
+    """Counts the number of each RTE component in the provided HTML content.
+
+    Args:
+        html_content: str. The HTML content to analyze.
+
+    Returns:
+        Dict[str, int]. A dictionary where keys are RTE component names
+        (e.g., 'oppia-noninteractive-image') and values are their counts.
+    """
+    soup = bs4.BeautifulSoup(html_content, 'html.parser')
+    component_counts = {}
+
+    rte_tags = [
path: core/domain/suggestion_services.py
user: login: seanlip
body: Don't we have a list of existing noninteractive RTE tags? You should use that list rather than creating a new one from scratch here. Otherwise, when a new tag is added/removed in the future, devs will not know to update this list.
created_at: 2025-02-23T04:23:39Z
author_association: MEMBER
diff_hunk: @@ -2142,28 +2145,70 @@ def _update_suggestion_counts_in_community_contribution_stats(
         suggestions, amount)
 
 
+def count_rte_components(html_content: str) -> Dict[str, int]:
+    """Counts the number of each RTE component in the provided HTML content.
+
+    Args:
+        html_content: str. The HTML content to analyze.
+
+    Returns:
+        Dict[str, int]. A dictionary where keys are RTE component names
+        (e.g., 'oppia-noninteractive-image') and values are their counts.
+    """
+    soup = bs4.BeautifulSoup(html_content, 'html.parser')
+    component_counts = {}
+
+    rte_tags = [
+        'oppia-noninteractive-image',
+        'oppia-noninteractive-math',
+        'oppia-noninteractive-concept-card',
+        'oppia-noninteractive-video'
+    ]
+
+    for tag in rte_tags:
+        component_counts[tag] = len(soup.find_all(tag))
+
+    return component_counts
+
+
 def update_translation_suggestion(
-    suggestion_id: str, translation_html: str
+    suggestion_id: str,
+    translation_html: str
 ) -> None:
-    """Updates the translation_html of a suggestion with the given
-    suggestion_id.
+    """Updates a translation suggestion while ensuring RTE components
+    remain unchanged.
 
     Args:
-        suggestion_id: str. The id of the suggestion to be updated.
-        translation_html: str. The new translation_html string.
+        suggestion_id: str. The ID of the suggestion being updated.
+        translation_html: str. The new translated HTML content.
 
     Raises:
-        Exception. Expected SuggestionTranslateContent suggestion but found
-            different suggestion.
+        InvalidInputException. Raised if the RTE component counts in
path: core/domain/suggestion_services.py
user: login: seanlip
body: The RTE component counts in ...

(drop "Raised if")

Similarly below.


created_at: 2025-02-23T04:24:37Z
author_association: MEMBER
diff_hunk: @@ -2142,28 +2145,70 @@ def _update_suggestion_counts_in_community_contribution_stats(
         suggestions, amount)
 
 
+def count_rte_components(html_content: str) -> Dict[str, int]:
+    """Counts the number of each RTE component in the provided HTML content.
+
+    Args:
+        html_content: str. The HTML content to analyze.
+
+    Returns:
+        Dict[str, int]. A dictionary where keys are RTE component names
+        (e.g., 'oppia-noninteractive-image') and values are their counts.
+    """
+    soup = bs4.BeautifulSoup(html_content, 'html.parser')
+    component_counts = {}
+
+    rte_tags = [
+        'oppia-noninteractive-image',
+        'oppia-noninteractive-math',
+        'oppia-noninteractive-concept-card',
+        'oppia-noninteractive-video'
+    ]
+
+    for tag in rte_tags:
+        component_counts[tag] = len(soup.find_all(tag))
+
+    return component_counts
+
+
 def update_translation_suggestion(
-    suggestion_id: str, translation_html: str
+    suggestion_id: str,
+    translation_html: str
 ) -> None:
-    """Updates the translation_html of a suggestion with the given
-    suggestion_id.
+    """Updates a translation suggestion while ensuring RTE components
+    remain unchanged.
 
     Args:
-        suggestion_id: str. The id of the suggestion to be updated.
-        translation_html: str. The new translation_html string.
+        suggestion_id: str. The ID of the suggestion being updated.
+        translation_html: str. The new translated HTML content.
 
     Raises:
-        Exception. Expected SuggestionTranslateContent suggestion but found
-            different suggestion.
+        InvalidInputException. Raised if the RTE component counts in
+            the updated translation do not match the original content.
+        Exception. Raised if the suggestion is not of type
+            SuggestionTranslateContent.
     """
     suggestion = get_suggestion_by_id(suggestion_id)
+
     if not isinstance(
         suggestion, suggestion_registry.SuggestionTranslateContent
     ):
         raise Exception(
             'Expected SuggestionTranslateContent suggestion but found: %s.'
             % type(suggestion).__name__
         )
+
+    original_text_html = suggestion.change_cmd.content_html
+    original_rte_counts = count_rte_components(original_text_html)
+    updated_rte_counts = count_rte_components(translation_html)
+
+    if original_rte_counts != updated_rte_counts:
+        raise utils.InvalidInputException(
path: core/domain/suggestion_services.py
user: login: seanlip
body: Please do clearer error handling, so that it's easier to debug errors. E.g. 

"Original text has 1 image component, but translation has 2 image components."

or, if there are multiple:

"Original text has 1 image component, but translation has 2 image components. Original text has 3 math components, but translation has 0 math component."

Consider including the original and translated texts as well in the error message (possibly truncated to a max limit).
created_at: 2025-02-23T04:28:59Z
author_association: MEMBER
diff_hunk: @@ -455,138 +436,13 @@ export class TranslationModalComponent {
     );
   }
 
-  getElementAttributeTexts(
-    elements: HTMLCollectionOf<Element>,
-    type: string
-  ): string[] {
-    const textWrapperLength = 6;
-    const attributes = Array.from(elements, function (element: Element) {
-      // A sample element would be as <oppia-noninteractive-image alt-with-value
-      // ="&amp;quot;Image description&amp;quot;" caption-with-value=
-      // "&amp;quot;Image caption&amp;quot;" filepath-with-value="&amp;quot;
-      // img_2021029_210552_zbmdt94_height_54_width_490.png&amp;quot;">
-      // </oppia-noninteractive-image>
-      if (element.localName === 'oppia-noninteractive-image') {
-        const attribute = element.attributes[
-          type as keyof NamedNodeMap
-        ] as Attr;
-        const attributeValue = attribute.value;
-        return attributeValue.substring(
-          textWrapperLength,
-          attributeValue.length - textWrapperLength
-        );
-      }
-    });
-    // Typecasted to string[] because Array.from() returns
-    // (string | undefined)[] and we need to remove the undefined elements.
-    return attributes.filter(attributeValues => attributeValues) as string[];
-  }
-
-  getImageAttributeTexts(
-    htmlElements: HTMLCollectionOf<Element>
-  ): ImageDetails {
-    return {
-      filePaths: this.getElementAttributeTexts(
-        htmlElements,
-        'filepath-with-value'
-      ),
-      alts: this.getElementAttributeTexts(htmlElements, 'alt-with-value'),
-      descriptions: this.getElementAttributeTexts(
-        htmlElements,
-        'caption-with-value'
-      ),
-    };
-  }
-
-  hasSomeDuplicateElements(
-    originalElements: string[],
-    translatedElements: string[]
-  ): boolean {
-    // This regular expression matches a number, optionally negative, with an
-    // optional decimal number followed by zero or more operators (including
-    // equals sign) and number pairs. It also allows for whitespace between
-    // numbers and operators. Examples 1+1=2 1+1 1*1=1.
-    const mathEquationRegex = new RegExp(
-      /(?:(?:^|[-+_*/=])(?:\s*-?\d+(\.\d+)?(?:[eE][+-]?\d+)?\s*))+$/
-    );
-    if (originalElements.length === 0) {
-      return false;
-    }
-    const hasMatchingTranslatedElement = (element: string) =>
-      translatedElements.includes(element) &&
-      originalElements.length > 0 &&
-      !mathEquationRegex.test(element);
-    return originalElements.some(hasMatchingTranslatedElement);
-  }
-
-  isTranslationCompleted(
-    originalElements: HTMLCollectionOf<Element>,
-    translatedElements: HTMLCollectionOf<Element>
-  ): boolean {
-    // Checks if there are custom tags present in the original content but not
-    // in the translated content.
-    const filteredOriginalElements = Array.from(originalElements, el =>
-      el.tagName.toLowerCase()
-    )
-      .filter(tagName =>
-        this.ALLOWED_CUSTOM_TAGS_IN_TRANSLATION_SUGGESTION.includes(tagName)
-      )
-      .sort();
-    const filteredTranslatedElements = Array.from(translatedElements, el =>
-      el.tagName.toLowerCase()
-    )
-      .filter(tagName =>
-        this.ALLOWED_CUSTOM_TAGS_IN_TRANSLATION_SUGGESTION.includes(tagName)
-      )
-      .sort();
-    return isEqual(filteredOriginalElements, filteredTranslatedElements);
-  }
-
-  validateTranslation(
-    textToTranslate: HTMLCollectionOf<Element>,
-    translatedText: HTMLCollectionOf<Element>
-  ): TranslationError {
-    const translatedElements: ImageDetails =
-      this.getImageAttributeTexts(translatedText);
-    const originalElements: ImageDetails =
-      this.getImageAttributeTexts(textToTranslate);
-
-    const hasDuplicateAltTexts = this.hasSomeDuplicateElements(
-      originalElements.alts,
-      translatedElements.alts
-    );
-    const hasDuplicateDescriptions = this.hasSomeDuplicateElements(
-      originalElements.descriptions,
-      translatedElements.descriptions
-    );
-    const hasUntranslatedElements = !this.isTranslationCompleted(
-      textToTranslate,
-      translatedText
-    );
-
-    return new TranslationError(
-      hasDuplicateAltTexts,
-      hasDuplicateDescriptions,
-      hasUntranslatedElements
-    );
-  }
-
   translatedTextCanBeSubmitted(): boolean {
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-modal.component.ts
user: login: seanlip
body: Probably better to rename to canTranslatedTextBeSubmitted() following the naming guidelines, but optional since this isn't something you've changed in the PR.
created_at: 2025-02-23T04:30:06Z
author_association: MEMBER
diff_hunk: @@ -105,7 +111,8 @@ <h4 class="oppia-translation-modal-author-header">
         </button>
         <button class="btn btn-success ml-2"
                 *ngIf="startedEditing"
-                (click)="updateSuggestion()">
+                (click)="updateSuggestion()"
+                [disabled]="isUpdateDisabled">
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.html
user: login: seanlip
body: updateIsDisabled
created_at: 2025-02-23T04:30:41Z
author_association: MEMBER
diff_hunk: @@ -367,9 +379,45 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     }
   }
 
+  isComponentsMismatched(): boolean {
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: seanlip
body: areComponentsMismatched
created_at: 2025-02-23T04:31:18Z
author_association: MEMBER
diff_hunk: @@ -367,9 +379,45 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     }
   }
 
+  isComponentsMismatched(): boolean {
+    const translationError =
+      this.translationValidationService.validateTranslationFromHtmlStrings(
+        this.activeSuggestion.change_cmd.content_html as string,
+        this.editedContent.html
+      );
+
+    this.incompleteTranslationErrorIsShown =
+      translationError.hasUntranslatedElements;
+    return translationError.hasUntranslatedElements;
+  }
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (changes.editedContent && this.editedContent) {
+      this.isComponentsMismatched();
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: seanlip
body: You don't seem to do anything with the result of this call?
created_at: 2025-02-23T04:31:49Z
author_association: MEMBER
diff_hunk: @@ -26,103 +29,100 @@ <h4 class="oppia-translation-modal-author-header">
   </div>
 </div>
 
+
+<div *ngIf="hasIncompleteTranslationError && startedEditing" tabindex="0" class="alert alert-danger oppia-units-input-error">
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.html
user: login: seanlip
body: I don't know. What does oppia-units-input-error do differently? If it doesn't do anything differently then standardizing on oppia-input-error is fine. If it does, then OK to keep the separation.
created_at: 2025-02-23T04:34:11Z
author_association: MEMBER
diff_hunk: @@ -163,6 +165,8 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
   hasQueuedSuggestion: boolean = false;
   currentSnackbarRef?: MatSnackBarRef<UndoSnackbarComponent>;
   isUndoFeatureEnabled: boolean = false;
+  hasIncompleteTranslationError: boolean = false;
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: seanlip
body: Those are incorrectly named. They should be converted to nouns, ideally.
created_at: 2025-02-23T04:34:43Z
author_association: MEMBER
diff_hunk: @@ -2142,28 +2145,70 @@ def _update_suggestion_counts_in_community_contribution_stats(
         suggestions, amount)
 
 
+def count_rte_components(html_content: str) -> Dict[str, int]:
+    """Counts the number of each RTE component in the provided HTML content.
+
+    Args:
+        html_content: str. The HTML content to analyze.
+
+    Returns:
+        Dict[str, int]. A dictionary where keys are RTE component names
+        (e.g., 'oppia-noninteractive-image') and values are their counts.
+    """
+    soup = bs4.BeautifulSoup(html_content, 'html.parser')
+    component_counts = {}
+
+    rte_tags = [
path: core/domain/suggestion_services.py
user: login: TARishabh
body: @seanlip Thanks for pointing that out ! While addressing the issue, I noticed a hardcoded list of RTE components in the translation-modal.component.ts file, defined as:

```
ALLOWED_CUSTOM_TAGS_IN_TRANSLATION_SUGGESTION = [
  'oppia-noninteractive-image',
  'oppia-noninteractive-link',
  'oppia-noninteractive-math',
  'oppia-noninteractive-skillreview',
];
```

This list includes the tags used in translation suggestions, which seems related to your comment about the "list of existing non-interactive RTE tags."

When you mentioned "list of existing non-interactive RTE tags," I assumed you were referring to both inline and block tags, like:

```
INLINE_COMPONENT_TAG_NAMES: List[str] = (
    rte_component_registry.Registry.get_inline_component_tag_names())

BLOCK_COMPONENT_TAG_NAMES: List[str] = (
    rte_component_registry.Registry.get_block_component_tag_names())
```

Could you confirm if you're referring to both inline and block tags, or only the ones in the hardcoded array? Thanks !
created_at: 2025-02-25T07:47:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2142,28 +2145,70 @@ def _update_suggestion_counts_in_community_contribution_stats(
         suggestions, amount)
 
 
+def count_rte_components(html_content: str) -> Dict[str, int]:
+    """Counts the number of each RTE component in the provided HTML content.
+
+    Args:
+        html_content: str. The HTML content to analyze.
+
+    Returns:
+        Dict[str, int]. A dictionary where keys are RTE component names
+        (e.g., 'oppia-noninteractive-image') and values are their counts.
+    """
+    soup = bs4.BeautifulSoup(html_content, 'html.parser')
+    component_counts = {}
+
+    rte_tags = [
+        'oppia-noninteractive-image',
+        'oppia-noninteractive-math',
+        'oppia-noninteractive-concept-card',
+        'oppia-noninteractive-video'
+    ]
+
+    for tag in rte_tags:
+        component_counts[tag] = len(soup.find_all(tag))
+
+    return component_counts
+
+
 def update_translation_suggestion(
-    suggestion_id: str, translation_html: str
+    suggestion_id: str,
+    translation_html: str
 ) -> None:
-    """Updates the translation_html of a suggestion with the given
-    suggestion_id.
+    """Updates a translation suggestion while ensuring RTE components
+    remain unchanged.
 
     Args:
-        suggestion_id: str. The id of the suggestion to be updated.
-        translation_html: str. The new translation_html string.
+        suggestion_id: str. The ID of the suggestion being updated.
+        translation_html: str. The new translated HTML content.
 
     Raises:
-        Exception. Expected SuggestionTranslateContent suggestion but found
-            different suggestion.
+        InvalidInputException. Raised if the RTE component counts in
path: core/domain/suggestion_services.py
user: login: TARishabh
body: Updated as suggested, removed "Raised if".
created_at: 2025-02-25T07:48:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2142,28 +2145,70 @@ def _update_suggestion_counts_in_community_contribution_stats(
         suggestions, amount)
 
 
+def count_rte_components(html_content: str) -> Dict[str, int]:
+    """Counts the number of each RTE component in the provided HTML content.
+
+    Args:
+        html_content: str. The HTML content to analyze.
+
+    Returns:
+        Dict[str, int]. A dictionary where keys are RTE component names
+        (e.g., 'oppia-noninteractive-image') and values are their counts.
+    """
+    soup = bs4.BeautifulSoup(html_content, 'html.parser')
+    component_counts = {}
+
+    rte_tags = [
+        'oppia-noninteractive-image',
+        'oppia-noninteractive-math',
+        'oppia-noninteractive-concept-card',
+        'oppia-noninteractive-video'
+    ]
+
+    for tag in rte_tags:
+        component_counts[tag] = len(soup.find_all(tag))
+
+    return component_counts
+
+
 def update_translation_suggestion(
-    suggestion_id: str, translation_html: str
+    suggestion_id: str,
+    translation_html: str
 ) -> None:
-    """Updates the translation_html of a suggestion with the given
-    suggestion_id.
+    """Updates a translation suggestion while ensuring RTE components
+    remain unchanged.
 
     Args:
-        suggestion_id: str. The id of the suggestion to be updated.
-        translation_html: str. The new translation_html string.
+        suggestion_id: str. The ID of the suggestion being updated.
+        translation_html: str. The new translated HTML content.
 
     Raises:
-        Exception. Expected SuggestionTranslateContent suggestion but found
-            different suggestion.
+        InvalidInputException. Raised if the RTE component counts in
+            the updated translation do not match the original content.
+        Exception. Raised if the suggestion is not of type
+            SuggestionTranslateContent.
     """
     suggestion = get_suggestion_by_id(suggestion_id)
+
     if not isinstance(
         suggestion, suggestion_registry.SuggestionTranslateContent
     ):
         raise Exception(
             'Expected SuggestionTranslateContent suggestion but found: %s.'
             % type(suggestion).__name__
         )
+
+    original_text_html = suggestion.change_cmd.content_html
+    original_rte_counts = count_rte_components(original_text_html)
+    updated_rte_counts = count_rte_components(translation_html)
+
+    if original_rte_counts != updated_rte_counts:
+        raise utils.InvalidInputException(
path: core/domain/suggestion_services.py
user: login: TARishabh
body: Updated the error handling to provide clearer messages with specific component counts and included original/translated texts (truncated for readability) as suggested. 
created_at: 2025-02-25T07:49:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -455,138 +436,13 @@ export class TranslationModalComponent {
     );
   }
 
-  getElementAttributeTexts(
-    elements: HTMLCollectionOf<Element>,
-    type: string
-  ): string[] {
-    const textWrapperLength = 6;
-    const attributes = Array.from(elements, function (element: Element) {
-      // A sample element would be as <oppia-noninteractive-image alt-with-value
-      // ="&amp;quot;Image description&amp;quot;" caption-with-value=
-      // "&amp;quot;Image caption&amp;quot;" filepath-with-value="&amp;quot;
-      // img_2021029_210552_zbmdt94_height_54_width_490.png&amp;quot;">
-      // </oppia-noninteractive-image>
-      if (element.localName === 'oppia-noninteractive-image') {
-        const attribute = element.attributes[
-          type as keyof NamedNodeMap
-        ] as Attr;
-        const attributeValue = attribute.value;
-        return attributeValue.substring(
-          textWrapperLength,
-          attributeValue.length - textWrapperLength
-        );
-      }
-    });
-    // Typecasted to string[] because Array.from() returns
-    // (string | undefined)[] and we need to remove the undefined elements.
-    return attributes.filter(attributeValues => attributeValues) as string[];
-  }
-
-  getImageAttributeTexts(
-    htmlElements: HTMLCollectionOf<Element>
-  ): ImageDetails {
-    return {
-      filePaths: this.getElementAttributeTexts(
-        htmlElements,
-        'filepath-with-value'
-      ),
-      alts: this.getElementAttributeTexts(htmlElements, 'alt-with-value'),
-      descriptions: this.getElementAttributeTexts(
-        htmlElements,
-        'caption-with-value'
-      ),
-    };
-  }
-
-  hasSomeDuplicateElements(
-    originalElements: string[],
-    translatedElements: string[]
-  ): boolean {
-    // This regular expression matches a number, optionally negative, with an
-    // optional decimal number followed by zero or more operators (including
-    // equals sign) and number pairs. It also allows for whitespace between
-    // numbers and operators. Examples 1+1=2 1+1 1*1=1.
-    const mathEquationRegex = new RegExp(
-      /(?:(?:^|[-+_*/=])(?:\s*-?\d+(\.\d+)?(?:[eE][+-]?\d+)?\s*))+$/
-    );
-    if (originalElements.length === 0) {
-      return false;
-    }
-    const hasMatchingTranslatedElement = (element: string) =>
-      translatedElements.includes(element) &&
-      originalElements.length > 0 &&
-      !mathEquationRegex.test(element);
-    return originalElements.some(hasMatchingTranslatedElement);
-  }
-
-  isTranslationCompleted(
-    originalElements: HTMLCollectionOf<Element>,
-    translatedElements: HTMLCollectionOf<Element>
-  ): boolean {
-    // Checks if there are custom tags present in the original content but not
-    // in the translated content.
-    const filteredOriginalElements = Array.from(originalElements, el =>
-      el.tagName.toLowerCase()
-    )
-      .filter(tagName =>
-        this.ALLOWED_CUSTOM_TAGS_IN_TRANSLATION_SUGGESTION.includes(tagName)
-      )
-      .sort();
-    const filteredTranslatedElements = Array.from(translatedElements, el =>
-      el.tagName.toLowerCase()
-    )
-      .filter(tagName =>
-        this.ALLOWED_CUSTOM_TAGS_IN_TRANSLATION_SUGGESTION.includes(tagName)
-      )
-      .sort();
-    return isEqual(filteredOriginalElements, filteredTranslatedElements);
-  }
-
-  validateTranslation(
-    textToTranslate: HTMLCollectionOf<Element>,
-    translatedText: HTMLCollectionOf<Element>
-  ): TranslationError {
-    const translatedElements: ImageDetails =
-      this.getImageAttributeTexts(translatedText);
-    const originalElements: ImageDetails =
-      this.getImageAttributeTexts(textToTranslate);
-
-    const hasDuplicateAltTexts = this.hasSomeDuplicateElements(
-      originalElements.alts,
-      translatedElements.alts
-    );
-    const hasDuplicateDescriptions = this.hasSomeDuplicateElements(
-      originalElements.descriptions,
-      translatedElements.descriptions
-    );
-    const hasUntranslatedElements = !this.isTranslationCompleted(
-      textToTranslate,
-      translatedText
-    );
-
-    return new TranslationError(
-      hasDuplicateAltTexts,
-      hasDuplicateDescriptions,
-      hasUntranslatedElements
-    );
-  }
-
   translatedTextCanBeSubmitted(): boolean {
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-modal.component.ts
user: login: TARishabh
body: I've renamed it to canTranslatedTextBeSubmitted() as per the naming guidelines.
created_at: 2025-02-25T07:50:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -105,7 +111,8 @@ <h4 class="oppia-translation-modal-author-header">
         </button>
         <button class="btn btn-success ml-2"
                 *ngIf="startedEditing"
-                (click)="updateSuggestion()">
+                (click)="updateSuggestion()"
+                [disabled]="isUpdateDisabled">
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.html
user: login: TARishabh
body: Renamed to updateIsDisabled.
created_at: 2025-02-25T07:51:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -367,9 +379,45 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     }
   }
 
+  isComponentsMismatched(): boolean {
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: TARishabh
body: Renamed to areComponentsMismatched.
created_at: 2025-02-25T07:51:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -367,9 +379,45 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
     }
   }
 
+  isComponentsMismatched(): boolean {
+    const translationError =
+      this.translationValidationService.validateTranslationFromHtmlStrings(
+        this.activeSuggestion.change_cmd.content_html as string,
+        this.editedContent.html
+      );
+
+    this.incompleteTranslationErrorIsShown =
+      translationError.hasUntranslatedElements;
+    return translationError.hasUntranslatedElements;
+  }
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (changes.editedContent && this.editedContent) {
+      this.isComponentsMismatched();
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: TARishabh
body: You're right! The result of areComponentsMismatched() wasn't being used. I've refactored it to explicitly check the return value now.
created_at: 2025-02-25T07:53:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2142,28 +2145,70 @@ def _update_suggestion_counts_in_community_contribution_stats(
         suggestions, amount)
 
 
+def count_rte_components(html_content: str) -> Dict[str, int]:
+    """Counts the number of each RTE component in the provided HTML content.
+
+    Args:
+        html_content: str. The HTML content to analyze.
+
+    Returns:
+        Dict[str, int]. A dictionary where keys are RTE component names
+        (e.g., 'oppia-noninteractive-image') and values are their counts.
+    """
+    soup = bs4.BeautifulSoup(html_content, 'html.parser')
+    component_counts = {}
+
+    rte_tags = [
path: core/domain/suggestion_services.py
user: login: seanlip
body: Combining both inline + block components should be fine. You just need to get this data from an existing source of truth rather than create your own one here. Maybe do a check on the registry to confirm that there's a test that ensures that every component is either inline or block -- or better still, create a new similar list that consists of get_all_component_tag_names() since this technically has nothing to do with the inline/block distinction. 
created_at: 2025-02-25T08:01:30Z
author_association: MEMBER
diff_hunk: @@ -2142,28 +2145,70 @@ def _update_suggestion_counts_in_community_contribution_stats(
         suggestions, amount)
 
 
+def count_rte_components(html_content: str) -> Dict[str, int]:
+    """Counts the number of each RTE component in the provided HTML content.
+
+    Args:
+        html_content: str. The HTML content to analyze.
+
+    Returns:
+        Dict[str, int]. A dictionary where keys are RTE component names
+        (e.g., 'oppia-noninteractive-image') and values are their counts.
+    """
+    soup = bs4.BeautifulSoup(html_content, 'html.parser')
+    component_counts = {}
+
+    rte_tags = [
path: core/domain/suggestion_services.py
user: login: TARishabh
body: Thanks for the clarification! I'll refactor the code to fetch component tags using get_all_component_tag_names() and ensure the necessary tests are in place.
created_at: 2025-02-25T08:48:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2202,11 +2200,34 @@ def update_translation_suggestion(
     original_rte_counts = count_rte_components(original_text_html)
     updated_rte_counts = count_rte_components(translation_html)
 
-    if original_rte_counts != updated_rte_counts:
+    # We use a sorted approach to compare component counts because it ensures
+    # consistency in comparison regardless of the order components appear.
+
+    all_components = sorted(
path: core/domain/suggestion_services.py
user: login: seanlip
body: all_component_names (these are the component names, not the components themselves)

Should you strip "oppia-noninteractive-" from the component names at the end?
created_at: 2025-02-28T18:32:34Z
author_association: MEMBER
diff_hunk: @@ -2202,11 +2200,34 @@ def update_translation_suggestion(
     original_rte_counts = count_rte_components(original_text_html)
     updated_rte_counts = count_rte_components(translation_html)
 
-    if original_rte_counts != updated_rte_counts:
+    # We use a sorted approach to compare component counts because it ensures
+    # consistency in comparison regardless of the order components appear.
+
+    all_components = sorted(
+        set(list(original_rte_counts.keys()) + list(updated_rte_counts.keys()))
+    )
+
+    discrepancies = []
+    max_error_text_length = 200
+
+    for component in all_components:
path: core/domain/suggestion_services.py
user: login: seanlip
body: component --> component_name
created_at: 2025-02-28T18:32:41Z
author_association: MEMBER
diff_hunk: @@ -2202,11 +2200,34 @@ def update_translation_suggestion(
     original_rte_counts = count_rte_components(original_text_html)
     updated_rte_counts = count_rte_components(translation_html)
 
-    if original_rte_counts != updated_rte_counts:
+    # We use a sorted approach to compare component counts because it ensures
+    # consistency in comparison regardless of the order components appear.
+
+    all_components = sorted(
+        set(list(original_rte_counts.keys()) + list(updated_rte_counts.keys()))
+    )
+
+    discrepancies = []
+    max_error_text_length = 200
+
+    for component in all_components:
+        original_count = original_rte_counts.get(component, 0)
+        updated_count = updated_rte_counts.get(component, 0)
+
+        if original_count != updated_count:
+            discrepancies.append(
path: core/domain/suggestion_services.py
user: login: seanlip
body: Looking at this more, I suggest you catalog the discrepancies, then construct the error message at the very end.

This makes it easier to make your message shorter while still conveying all the information. For example: "Components in original text: 1 image, 2 math. Components in translated text: 2 image, 0 math." -- then you don't need truncation.
created_at: 2025-02-28T18:35:17Z
author_association: MEMBER
diff_hunk: @@ -2202,11 +2200,34 @@ def update_translation_suggestion(
     original_rte_counts = count_rte_components(original_text_html)
     updated_rte_counts = count_rte_components(translation_html)
 
-    if original_rte_counts != updated_rte_counts:
+    # We use a sorted approach to compare component counts because it ensures
+    # consistency in comparison regardless of the order components appear.
+
+    all_components = sorted(
+        set(list(original_rte_counts.keys()) + list(updated_rte_counts.keys()))
+    )
+
+    discrepancies = []
+    max_error_text_length = 200
+
+    for component in all_components:
+        original_count = original_rte_counts.get(component, 0)
+        updated_count = updated_rte_counts.get(component, 0)
+
+        if original_count != updated_count:
+            discrepancies.append(
+                f'Original text has {original_count} {component} '
+                f'component(s), but translation has {updated_count}.'
+            )
+
+    if discrepancies:
+        original_text_preview = original_text_html[:max_error_text_length]
+        translation_text_preview = translation_html[:max_error_text_length]
+
         raise utils.InvalidInputException(
-            'The number of RTE components (images, math, concept cards, '
-            'videos) in the updated translation must match the original '
-            'content.'
+            f'{" ".join(discrepancies)}\n'
+            f'Original text preview: {original_text_preview}\n'
path: core/domain/suggestion_services.py
user: login: seanlip
body: If you just do this, you will likely show exactly the same thing for the original and translated texts, since texts can be quite long. Maybe you could consider replacing the common prefix of both with "..." (while leaving a few characters before the strings start differing), and then truncating.
created_at: 2025-02-28T18:38:25Z
author_association: MEMBER
diff_hunk: @@ -393,24 +393,24 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
 
   ngOnChanges(changes: SimpleChanges): void {
     if (changes.editedContent && this.editedContent) {
-      this.isComponentsMismatched();
+      const componentMismatch = this.areComponentsMismatched();
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: seanlip
body: componentsAreMismatched

Your current name suggests that this stores some representation of a specific mismatch.
created_at: 2025-02-28T18:39:52Z
author_association: MEMBER
diff_hunk: @@ -393,24 +393,24 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
 
   ngOnChanges(changes: SimpleChanges): void {
     if (changes.editedContent && this.editedContent) {
-      this.isComponentsMismatched();
+      const componentMismatch = this.areComponentsMismatched();
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: TARishabh
body: Yes, the previous name did not store a mismatch representation; it was only checking for mismatches. I’ll rename the function to componentsAreMismatched for clarity
created_at: 2025-03-03T11:24:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2202,11 +2200,34 @@ def update_translation_suggestion(
     original_rte_counts = count_rte_components(original_text_html)
     updated_rte_counts = count_rte_components(translation_html)
 
-    if original_rte_counts != updated_rte_counts:
+    # We use a sorted approach to compare component counts because it ensures
+    # consistency in comparison regardless of the order components appear.
+
+    all_components = sorted(
path: core/domain/suggestion_services.py
user: login: TARishabh
body: Thanks ! Renamed it to all_component_names and also updated the code to strip 'oppia-noninteractive-' from component names for better readabiity.
created_at: 2025-03-03T12:02:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2202,11 +2200,34 @@ def update_translation_suggestion(
     original_rte_counts = count_rte_components(original_text_html)
     updated_rte_counts = count_rte_components(translation_html)
 
-    if original_rte_counts != updated_rte_counts:
+    # We use a sorted approach to compare component counts because it ensures
+    # consistency in comparison regardless of the order components appear.
+
+    all_components = sorted(
+        set(list(original_rte_counts.keys()) + list(updated_rte_counts.keys()))
+    )
+
+    discrepancies = []
+    max_error_text_length = 200
+
+    for component in all_components:
path: core/domain/suggestion_services.py
user: login: TARishabh
body: Renamed to component_name
created_at: 2025-03-03T12:03:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2202,11 +2200,34 @@ def update_translation_suggestion(
     original_rte_counts = count_rte_components(original_text_html)
     updated_rte_counts = count_rte_components(translation_html)
 
-    if original_rte_counts != updated_rte_counts:
+    # We use a sorted approach to compare component counts because it ensures
+    # consistency in comparison regardless of the order components appear.
+
+    all_components = sorted(
+        set(list(original_rte_counts.keys()) + list(updated_rte_counts.keys()))
+    )
+
+    discrepancies = []
+    max_error_text_length = 200
+
+    for component in all_components:
+        original_count = original_rte_counts.get(component, 0)
+        updated_count = updated_rte_counts.get(component, 0)
+
+        if original_count != updated_count:
+            discrepancies.append(
path: core/domain/suggestion_services.py
user: login: TARishabh
body: That makes sense! I’ve updated the implementation to first catalog the discrepancies and then construct the error message at the end.
created_at: 2025-03-03T12:05:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2202,11 +2200,34 @@ def update_translation_suggestion(
     original_rte_counts = count_rte_components(original_text_html)
     updated_rte_counts = count_rte_components(translation_html)
 
-    if original_rte_counts != updated_rte_counts:
+    # We use a sorted approach to compare component counts because it ensures
+    # consistency in comparison regardless of the order components appear.
+
+    all_components = sorted(
+        set(list(original_rte_counts.keys()) + list(updated_rte_counts.keys()))
+    )
+
+    discrepancies = []
+    max_error_text_length = 200
+
+    for component in all_components:
+        original_count = original_rte_counts.get(component, 0)
+        updated_count = updated_rte_counts.get(component, 0)
+
+        if original_count != updated_count:
+            discrepancies.append(
+                f'Original text has {original_count} {component} '
+                f'component(s), but translation has {updated_count}.'
+            )
+
+    if discrepancies:
+        original_text_preview = original_text_html[:max_error_text_length]
+        translation_text_preview = translation_html[:max_error_text_length]
+
         raise utils.InvalidInputException(
-            'The number of RTE components (images, math, concept cards, '
-            'videos) in the updated translation must match the original '
-            'content.'
+            f'{" ".join(discrepancies)}\n'
+            f'Original text preview: {original_text_preview}\n'
path: core/domain/suggestion_services.py
user: login: TARishabh
body: Updated the implementation to replace the common prefix with '...' while keeping a few characters before the difference for better readability.
created_at: 2025-03-03T12:06:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1304,6 +1289,22 @@ def test_update_translation_suggestion_error_when_component_types_mismatch(
             translation_html_with_image
         )
 
+    def test_highlight_differences_identical_strings(self) -> None:
+        original = 'This is a test string.'
+        updated = 'This is a test string.'
+        max_length = 50
+
+        truncated_original, truncated_updated = (
+            suggestion_services.highlight_differences(
+                original,
+                updated,
+                max_length
+            )
+        )
+
+        self.assertEqual(truncated_original, original[:max_length])
path: core/domain/suggestion_services_test.py
user: login: seanlip
body: The expected values in tests should be hardcoded, not computed using logic.

Also, I think you have way too few tests for the new functions that you introduced. (I'm not actually sure if they should be new functions at all TBH, but if you do want them to be part of the public API of the module then you need to test their behaviours thoroughly.)
created_at: 2025-03-03T17:53:05Z
author_association: MEMBER
diff_hunk: @@ -2882,7 +2882,7 @@ describe('Translation Suggestion Review Modal Component', function () {
       expect(component.editedContent).toBeDefined();
       expect(component.editedContent.html).toBe(htmlWithoutComponents);
 
-      expect(component.areComponentsMismatched()).toBeTrue();
+      expect(component.componentsAreMismatched()).toBeTrue();
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts
user: login: seanlip
body: This is wrong. Method names should start with verbs. (I think I have mentioned this before.)

My original comment was on the line that declared `componentMismatch`.
created_at: 2025-03-03T17:54:51Z
author_association: MEMBER
diff_hunk: @@ -1304,6 +1289,22 @@ def test_update_translation_suggestion_error_when_component_types_mismatch(
             translation_html_with_image
         )
 
+    def test_highlight_differences_identical_strings(self) -> None:
+        original = 'This is a test string.'
+        updated = 'This is a test string.'
+        max_length = 50
+
+        truncated_original, truncated_updated = (
+            suggestion_services.highlight_differences(
+                original,
+                updated,
+                max_length
+            )
+        )
+
+        self.assertEqual(truncated_original, original[:max_length])
path: core/domain/suggestion_services_test.py
user: login: TARishabh
body: I see your point. strip_prefix is just a one-line function, so I can merge it directly into update_translation_suggestion. However, highlight_differences is purely for formatting and doesn't affect the core logic, which is why I kept it separate for clarity. If you’d prefer, I can merge that as well. Let me know what you think! Thanks!
created_at: 2025-03-04T10:15:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2882,7 +2882,7 @@ describe('Translation Suggestion Review Modal Component', function () {
       expect(component.editedContent).toBeDefined();
       expect(component.editedContent.html).toBe(htmlWithoutComponents);
 
-      expect(component.areComponentsMismatched()).toBeTrue();
+      expect(component.componentsAreMismatched()).toBeTrue();
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts
user: login: TARishabh
body: Thanks for the clarification, Sean! Actually, I had asked a related question earlier in the same PR [link](https://github.com/oppia/oppia/pull/21241#discussion_r1957066774) but at that time, I mistakenly framed it around variables instead of methods. In that discussion, you mentioned that variables should ideally be nouns, but I don’t think we explicitly covered method naming conventions. Maybe you just didn’t get a chance to reply to that part.

Now I understand the pattern: methods should start with verbs (since they perform actions), while variables should be nouns (since they store values). Thanks again for the guidance!
created_at: 2025-03-04T10:25:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1304,6 +1289,22 @@ def test_update_translation_suggestion_error_when_component_types_mismatch(
             translation_html_with_image
         )
 
+    def test_highlight_differences_identical_strings(self) -> None:
+        original = 'This is a test string.'
+        updated = 'This is a test string.'
+        max_length = 50
+
+        truncated_original, truncated_updated = (
+            suggestion_services.highlight_differences(
+                original,
+                updated,
+                max_length
+            )
+        )
+
+        self.assertEqual(truncated_original, original[:max_length])
path: core/domain/suggestion_services_test.py
user: login: TARishabh
body: @chris7716 @masterboy376
created_at: 2025-03-04T17:14:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2882,7 +2882,7 @@ describe('Translation Suggestion Review Modal Component', function () {
       expect(component.editedContent).toBeDefined();
       expect(component.editedContent.html).toBe(htmlWithoutComponents);
 
-      expect(component.areComponentsMismatched()).toBeTrue();
+      expect(component.componentsAreMismatched()).toBeTrue();
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.spec.ts
user: login: seanlip
body: @TARishabh Yes, that's correct. I recommend reading our [coding style guide](https://github.com/oppia/oppia/wiki/Coding-style-guide#javascript); all this is already documented there.
created_at: 2025-03-04T17:20:20Z
author_association: MEMBER
diff_hunk: @@ -1304,6 +1289,22 @@ def test_update_translation_suggestion_error_when_component_types_mismatch(
             translation_html_with_image
         )
 
+    def test_highlight_differences_identical_strings(self) -> None:
+        original = 'This is a test string.'
+        updated = 'This is a test string.'
+        max_length = 50
+
+        truncated_original, truncated_updated = (
+            suggestion_services.highlight_differences(
+                original,
+                updated,
+                max_length
+            )
+        )
+
+        self.assertEqual(truncated_original, original[:max_length])
path: core/domain/suggestion_services_test.py
user: login: seanlip
body: No strong opinion, just ensure that if functions are part of the public API of the module, they have proper test coverage. I think it's probably fine to inline it since it's specific to this method. But I can see the reasons for keeping it separate, too. So, your call.

A third alternative is to prefix the function name with `_` to indicate that it's private. Then it's not part of the public API, though you should probably test it at least indirectly with the general cases that would occur in practice.
created_at: 2025-03-04T17:58:29Z
author_association: MEMBER
diff_hunk: @@ -1304,6 +1289,22 @@ def test_update_translation_suggestion_error_when_component_types_mismatch(
             translation_html_with_image
         )
 
+    def test_highlight_differences_identical_strings(self) -> None:
+        original = 'This is a test string.'
+        updated = 'This is a test string.'
+        max_length = 50
+
+        truncated_original, truncated_updated = (
+            suggestion_services.highlight_differences(
+                original,
+                updated,
+                max_length
+            )
+        )
+
+        self.assertEqual(truncated_original, original[:max_length])
path: core/domain/suggestion_services_test.py
user: login: TARishabh
body: Sorry for the late reply. Yes, I've added additional test cases to verify the functionality of the _highlight_differences method. Since it's now a private method, its functionality is tested indirectly through the update_translation_suggestion method, ensuring it works as intended when invoked through the public API.
created_at: 2025-03-15T09:29:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2180,13 +2181,12 @@ def highlight_differences(
     )
     start_index = max(0, diff_index - 10)
 
-    truncated_original = (
-        '...' if start_index > 0 else ''
-    ) + original[start_index: start_index + max_length]
+    prefix = '...' if start_index > 0 or min_length > max_length else ''
path: core/domain/suggestion_services.py
user: login: seanlip
body: Why "or min_length > max_length"? If you have two long strings, but start at the beginning with the diff, then why the "..."?
created_at: 2025-03-21T13:11:08Z
author_association: MEMBER
diff_hunk: @@ -2279,15 +2279,23 @@ def update_translation_suggestion(
             f'Components in translated text: {", ".join(updated_summary)}.'
         )
 
-        original_text_preview, translation_text_preview = highlight_differences(
+        original_text_preview, translation_text_preview = (
+            _highlight_differences(
             original_text_html, translation_html)
+        )
 
         raise utils.InvalidInputException(
             f'{original_summary_text} {updated_summary_text}\n'
             f'Original text preview: {original_text_preview}\n'
             f'Translated text preview: {translation_text_preview}'
         )
 
+    if len(translation_html) > MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION:
+        _, truncated_translation = _highlight_differences(
+            original_text_html, translation_html
+        )
+        translation_html = truncated_translation
path: core/domain/suggestion_services.py
user: login: seanlip
body: This is confusing. In general, a variable named `translation_html` represents the full translation HTML. Why are you setting it to a truncated string?
created_at: 2025-03-21T13:12:23Z
author_association: MEMBER
diff_hunk: @@ -393,24 +393,24 @@ export class TranslationSuggestionReviewModalComponent implements OnInit {
 
   ngOnChanges(changes: SimpleChanges): void {
     if (changes.editedContent && this.editedContent) {
-      this.isComponentsMismatched();
+      const componentMismatch = this.areComponentsMismatched();
path: core/templates/pages/contributor-dashboard-page/modal-templates/translation-suggestion-review-modal.component.ts
user: login: seanlip
body: This is still wrong. Please change componentMismatch --> componentsAreMismatched
created_at: 2025-03-21T13:13:05Z
author_association: MEMBER
diff_hunk: @@ -2180,13 +2181,12 @@ def highlight_differences(
     )
     start_index = max(0, diff_index - 10)
 
-    truncated_original = (
-        '...' if start_index > 0 else ''
-    ) + original[start_index: start_index + max_length]
+    prefix = '...' if start_index > 0 or min_length > max_length else ''
path: core/domain/suggestion_services.py
user: login: TARishabh
body: Okay, the thing i understood from your comment is that current condition `start_index > 0 or min_length > max_length` is incorrect. because, the '...' prefix should only be added when we're showing a middle portion of the strings (start_index > 0), not when we're just truncating long strings from the beginning, right? I'll update the code to use just start_index > 0 as the condition. Thanks !

created_at: 2025-03-21T19:12:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2279,15 +2279,23 @@ def update_translation_suggestion(
             f'Components in translated text: {", ".join(updated_summary)}.'
         )
 
-        original_text_preview, translation_text_preview = highlight_differences(
+        original_text_preview, translation_text_preview = (
+            _highlight_differences(
             original_text_html, translation_html)
+        )
 
         raise utils.InvalidInputException(
             f'{original_summary_text} {updated_summary_text}\n'
             f'Original text preview: {original_text_preview}\n'
             f'Translated text preview: {translation_text_preview}'
         )
 
+    if len(translation_html) > MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION:
+        _, truncated_translation = _highlight_differences(
+            original_text_html, translation_html
+        )
+        translation_html = truncated_translation
path: core/domain/suggestion_services.py
user: login: TARishabh
body: Understood, making it more clear. The variable name is misleading because developers who see this variable might expect the full HTML when they see this variable. I've updated the code to use a more explicit variable name and added a comment explaining the truncation logic.
created_at: 2025-03-21T19:27:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2291,15 +2291,19 @@ def update_translation_suggestion(
         )
 
     if len(translation_html) > MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION:
-        _, truncated_translation = _highlight_differences(
+        # Get a truncated version of the translation HTML if it
+        # exceeds the maximum length.
+        _, truncated_translation_html = _highlight_differences(
             original_text_html, translation_html
         )
-        translation_html = truncated_translation
+        final_translation_html = truncated_translation_html
path: core/domain/suggestion_services.py
user: login: seanlip
body: The name is still misleading because it indicates that the truncated html is the "final" translation html.

I am also getting somewhat confused. The truncation is only for the error message to show to the user (see [comment](https://github.com/oppia/oppia/pull/21241#discussion_r1966667783)). Why are we storing the truncation in the change_cmd and so on? That seems like completely the wrong way to go since it overwrites the actual translation with a truncated one.
created_at: 2025-03-23T17:32:58Z
author_association: MEMBER
diff_hunk: @@ -2291,15 +2291,19 @@ def update_translation_suggestion(
         )
 
     if len(translation_html) > MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION:
-        _, truncated_translation = _highlight_differences(
+        # Get a truncated version of the translation HTML if it
+        # exceeds the maximum length.
+        _, truncated_translation_html = _highlight_differences(
             original_text_html, translation_html
         )
-        translation_html = truncated_translation
+        final_translation_html = truncated_translation_html
path: core/domain/suggestion_services.py
user: login: TARishabh
body: Now I understood clearly. Thanks for pointing this out. You are absolutely right that the initial naming could be misleading, suggesting the truncated HTML was the final version. I agree that storing the truncated HTML in change_cmd would be incorrect and would overwrite the actual translation.

Just to clarify the current implementation: the truncation performed by _highlight_differences is solely for display within the error message presented to the user. The suggestion.change_cmd.translation_html is indeed being updated with the full, untruncated translation_html.
created_at: 2025-03-25T10:51:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2172,21 +2174,36 @@ def _highlight_differences(
     Returns:
         tuple. A pair of truncated strings highlighting where they differ.
     """
+    # If the strings are identical, simply return truncated versions.
     if original == updated:
         return original[:max_length], updated[:max_length]
 
     min_length = min(len(original), len(updated))
-    diff_index = next(
-        (i for i in range(min_length) if original[i] != updated[i]), min_length
-    )
+
+    # Find the first index where the strings differ.
+    diff_index = min_length
+    for i in range(min_length):
+        if original[i] != updated[i]:
+            diff_index = i
+            break
+    # Calculate start_index with 10 characters before the first difference.
     start_index = max(0, diff_index - 10)
+    # Add the '...' prefix if truncation happens and it's not
path: core/domain/suggestion_services.py
user: login: seanlip
body: Why is this comment here?

Try to avoid these issues by self-reviewing your PRs before you send them to reviewers.

EDIT: sorry, ignore that, it looks like you didn't send them to reviewers. I'm not sure why this ended up in my queue, apologies!
created_at: 2025-03-26T18:59:42Z
author_association: MEMBER
diff_hunk: @@ -2159,8 +2159,10 @@ def _strip_prefix(component_name: str) -> str:
     return component_name.removeprefix('oppia-noninteractive-')
 
 
-def _highlight_differences(
-    original: str, updated: str, max_length: int = 200
+def highlight_differences(
+    original: str,
+    updated: str,
+    max_length: int = MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION
path: core/domain/suggestion_services.py
user: login: seanlip
body: max_length sounds like the opposite of min_length below. Try to find a name that doesn't cause both of those to seem like they're similar things.
created_at: 2025-03-26T19:01:33Z
author_association: MEMBER
diff_hunk: @@ -2172,21 +2174,36 @@ def _highlight_differences(
     Returns:
         tuple. A pair of truncated strings highlighting where they differ.
     """
+    # If the strings are identical, simply return truncated versions.
     if original == updated:
         return original[:max_length], updated[:max_length]
 
     min_length = min(len(original), len(updated))
-    diff_index = next(
-        (i for i in range(min_length) if original[i] != updated[i]), min_length
-    )
+
+    # Find the first index where the strings differ.
+    diff_index = min_length
+    for i in range(min_length):
+        if original[i] != updated[i]:
+            diff_index = i
+            break
+    # Calculate start_index with 10 characters before the first difference.
     start_index = max(0, diff_index - 10)
+    # Add the '...' prefix if truncation happens and it's not
path: core/domain/suggestion_services.py
user: login: TARishabh
body: Apologies for the confusion. Initially, I was adding a prefix to a variable, but I later adjusted the logic since the prefix was not being used and could be handled differently. I appreciate your understanding and will be more mindful in the future. Once again, my apologies.
created_at: 2025-03-26T19:20:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2159,8 +2159,10 @@ def _strip_prefix(component_name: str) -> str:
     return component_name.removeprefix('oppia-noninteractive-')
 
 
-def _highlight_differences(
-    original: str, updated: str, max_length: int = 200
+def highlight_differences(
+    original: str,
+    updated: str,
+    max_length: int = MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION
path: core/domain/suggestion_services.py
user: login: TARishabh
body: I've renamed it to 'truncate_threshold.' Does that work?
created_at: 2025-03-26T19:32:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2160,23 +2160,23 @@ def _strip_prefix(component_name: str) -> str:
 
 
 def highlight_differences(
-    original: str,
-    updated: str,
-    max_length: int = MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION
+        original: str,
+        updated: str,
+        truncate_threshold: int = MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION
path: core/domain/suggestion_services.py
user: login: seanlip
body: "truncate" is a verb, and we don't start variables with verbs. Perhaps truncation_threshold or truncation_limit.
created_at: 2025-04-01T16:31:58Z
author_association: MEMBER
diff_hunk: @@ -1445,7 +1445,7 @@ def test_update_translation_suggestion_no_rte_components(self) -> None:
     def test_update_translation_suggestion_error_with_truncated_text(
         self
     ) -> None:
-        max_length = suggestion_services.MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION
+        truncate_threshold = suggestion_services.MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION # pylint: disable=line-too-long
path: core/domain/suggestion_services_test.py
user: login: seanlip
body: Don't use pylint:disable. You can add a `(` before suggestion_services and then do a line break just after the `(` (indenting the following line).
created_at: 2025-04-01T16:32:48Z
author_association: MEMBER
diff_hunk: @@ -2160,23 +2160,23 @@ def _strip_prefix(component_name: str) -> str:
 
 
 def highlight_differences(
-    original: str,
-    updated: str,
-    max_length: int = MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION
+        original: str,
+        updated: str,
+        truncate_threshold: int = MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION
path: core/domain/suggestion_services.py
user: login: TARishabh
body: Renamed it to truncation_limit.
created_at: 2025-04-06T09:30:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1445,7 +1445,7 @@ def test_update_translation_suggestion_no_rte_components(self) -> None:
     def test_update_translation_suggestion_error_with_truncated_text(
         self
     ) -> None:
-        max_length = suggestion_services.MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION
+        truncate_threshold = suggestion_services.MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION # pylint: disable=line-too-long
path: core/domain/suggestion_services_test.py
user: login: TARishabh
body: Removed  #pylint:disable wherever possible, by using `(` .
created_at: 2025-04-06T09:31:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1445,7 +1445,7 @@ def test_update_translation_suggestion_no_rte_components(self) -> None:
     def test_update_translation_suggestion_error_with_truncated_text(
         self
     ) -> None:
-        max_length = suggestion_services.MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION
+        truncate_threshold = suggestion_services.MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION # pylint: disable=line-too-long
path: core/domain/suggestion_services_test.py
user: login: seanlip
body: Please remove it everywhere, not "wherever possible".
created_at: 2025-04-06T09:39:47Z
author_association: MEMBER
diff_hunk: @@ -1445,7 +1445,7 @@ def test_update_translation_suggestion_no_rte_components(self) -> None:
     def test_update_translation_suggestion_error_with_truncated_text(
         self
     ) -> None:
-        max_length = suggestion_services.MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION
+        truncate_threshold = suggestion_services.MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION # pylint: disable=line-too-long
path: core/domain/suggestion_services_test.py
user: login: TARishabh
body: There are still 2 occurrences remaining:

def test_update_translation_suggestion_error_with_multiple_component_discrepancies( # pylint: disable=line-too-long
(I'm unsure how to split this line to remove the # pylint: disable.)

f'Components in original text: 1 image. Components in translated text: 0 image.\n' # pylint: disable=line-too-long

I’ve removed all other # pylint: disable comments by using parentheses and breaking lines appropriately, but I’m a bit stuck on these two. Would appreciate suggestions on how to handle them.
created_at: 2025-04-06T09:54:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1445,7 +1445,7 @@ def test_update_translation_suggestion_no_rte_components(self) -> None:
     def test_update_translation_suggestion_error_with_truncated_text(
         self
     ) -> None:
-        max_length = suggestion_services.MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION
+        truncate_threshold = suggestion_services.MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION # pylint: disable=line-too-long
path: core/domain/suggestion_services_test.py
user: login: seanlip
body: For the first one, make the name shorter. Maybe drop "component"? Maybe "multiple" --> "two" or "three"?

For the second one, you should be able to split a string across multiple lines in Python.



created_at: 2025-04-06T10:13:25Z
author_association: MEMBER
diff_hunk: @@ -1445,7 +1445,7 @@ def test_update_translation_suggestion_no_rte_components(self) -> None:
     def test_update_translation_suggestion_error_with_truncated_text(
         self
     ) -> None:
-        max_length = suggestion_services.MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION
+        truncate_threshold = suggestion_services.MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION # pylint: disable=line-too-long
path: core/domain/suggestion_services_test.py
user: login: TARishabh
body: Removed all occurances of #pylint:disable for this PR, Thanks !
created_at: 2025-04-06T11:04:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1445,7 +1445,7 @@ def test_update_translation_suggestion_no_rte_components(self) -> None:
     def test_update_translation_suggestion_error_with_truncated_text(
         self
     ) -> None:
-        max_length = suggestion_services.MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION
+        truncate_threshold = suggestion_services.MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION # pylint: disable=line-too-long
path: core/domain/suggestion_services_test.py
user: login: seanlip
body: No, there are still pylint:disable usages in the PR. Please check properly.
created_at: 2025-04-06T11:10:10Z
author_association: MEMBER
diff_hunk: @@ -1445,7 +1445,7 @@ def test_update_translation_suggestion_no_rte_components(self) -> None:
     def test_update_translation_suggestion_error_with_truncated_text(
         self
     ) -> None:
-        max_length = suggestion_services.MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION
+        truncate_threshold = suggestion_services.MAX_CONTENT_LENGTH_WITHOUT_TRUNCATION # pylint: disable=line-too-long
path: core/domain/suggestion_services_test.py
user: login: TARishabh
body: Apologies, one occurance was left. Removed it too. Apologies again.
created_at: 2025-04-06T15:08:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2297,10 +2297,12 @@ def update_translation_suggestion(
 
         # Get truncated versions of both original and translated
         # text for the error message.
-        original_text_preview, translation_text_preview = highlight_differences( # pylint: disable=line-too-long
+        original_text_preview, translation_text_preview = (
+            highlight_differences(
             original_text_html,
path: core/domain/suggestion_services.py
user: login: seanlip
body: This and the next 3 line should be indented by 4, since you are introducing a new indentation scope in `highlight_differences(`.
created_at: 2025-04-08T17:35:35Z
author_association: MEMBER
diff_hunk: @@ -2297,10 +2297,12 @@ def update_translation_suggestion(
 
         # Get truncated versions of both original and translated
         # text for the error message.
-        original_text_preview, translation_text_preview = highlight_differences( # pylint: disable=line-too-long
+        original_text_preview, translation_text_preview = (
+            highlight_differences(
             original_text_html,
path: core/domain/suggestion_services.py
user: login: TARishabh
body: Apologies. Made the suggested changes, Thanks!
created_at: 2025-04-10T20:59:05Z
author_association: CONTRIBUTOR

PR comments:
user: login: oppiabot[bot]
created_at: 2024-11-10T19:10:42Z
body: Assigning @chris7716 for the first pass review of this PR. Thanks!
author_association: NONE
user: login: oppiabot[bot]
created_at: 2024-11-11T00:55:42Z
body: Hi @TARishabh, it looks like some changes were requested on this pull request by @masterboy376. PTAL. Thanks!
author_association: NONE
user: login: oppiabot[bot]
created_at: 2024-11-18T01:05:26Z
body: Hi @TARishabh, I'm going to mark this PR as stale because it hasn't had any updates for 7 days. If no further activity occurs within **4 days**, it will be automatically closed so that others can take up the issue.
If you are still working on this PR, please make a follow-up commit within **4 days** (and submit it for review, if applicable). Please also let us know if you are stuck so we can help you!

author_association: NONE
user: login: Ash-2k3
created_at: 2024-11-20T09:15:19Z
body: Hi @TARishabh, any updates on this PR ? Feel free to ask your doubts/blockers (if any). Thanks! 
author_association: CONTRIBUTOR
user: login: seanlip
created_at: 2024-11-25T10:05:50Z
body: @TARishabh Just to check, do you want a review for this PR yet? If so, please ensure that the list of assignees is updated, per the [PR instructions](https://github.com/oppia/oppia/wiki/Rules-for-making-PRs#step-5-address-review-comments-until-all-reviewers-approve) on the wiki. Thanks!
author_association: MEMBER
user: login: TARishabh
created_at: 2024-11-25T18:57:37Z
body: @seanlip  Thanks for pointing that out. After checking the CI results, it seems that I missed some test cases for the frontend. I'll add those missing tests, and once I complete them, I'll request a review. Thanks !
author_association: CONTRIBUTOR
user: login: TARishabh
created_at: 2024-12-02T05:11:59Z
body: Hey @StephenYu2018 , I've been working on updating the regex and wanted to clarify something.

Should we allow filenames with special characters like > or < (e.g., img>.png), or would it be better to restrict them for simplicity and security reasons? I’m leaning towards allowing common characters but avoiding potentially risky ones (<, >, "), but I wanted to get your input before finalizing the approach.

Thanks!
author_association: CONTRIBUTOR
user: login: seanlip
created_at: 2024-12-02T05:26:02Z
body: > Hey @StephenYu2018 , I've been working on updating the regex and wanted to clarify something.
> 
> Should we allow filenames with special characters like > or < (e.g., img>.png), or would it be better to restrict them for simplicity and security reasons? I’m leaning towards allowing common characters but avoiding potentially risky ones (<, >, "), but I wanted to get your input before finalizing the approach.
> 
> Thanks!

Hi @TARishabh Please don't allow special characters. Restrict to alphanumeric, hyphens and underscore only.

That said, why are you controlling this directly? I thought we autogenerate filenames when saving images.
author_association: MEMBER
user: login: TARishabh
created_at: 2024-12-02T05:51:46Z
body: > > Hey @StephenYu2018 , I've been working on updating the regex and wanted to clarify something.
> > Should we allow filenames with special characters like > or < (e.g., img>.png), or would it be better to restrict them for simplicity and security reasons? I’m leaning towards allowing common characters but avoiding potentially risky ones (<, >, "), but I wanted to get your input before finalizing the approach.
> > Thanks!
> 
> Hi @TARishabh Please don't allow special characters. Restrict to alphanumeric, hyphens and underscore only.
> 
> That said, why are you controlling this directly? I thought we autogenerate filenames when saving images.

Hey @seanlip , got it, thanks for clarifying!

Didn’t realize the filenames were autogenerated, that helps a lot. I’ll adjust the regex to only match alphanumeric characters, hyphens, and underscores like you suggested. I’ll also rethink the approach a bit.

Thanks!
author_association: CONTRIBUTOR
user: login: seanlip
created_at: 2024-12-09T03:56:03Z
body: > > > Hey @StephenYu2018 , I've been working on updating the regex and wanted to clarify something.
> > > Should we allow filenames with special characters like > or < (e.g., img>.png), or would it be better to restrict them for simplicity and security reasons? I’m leaning towards allowing common characters but avoiding potentially risky ones (<, >, "), but I wanted to get your input before finalizing the approach.
> > > Thanks!
> > 
> > 
> > Hi @TARishabh Please don't allow special characters. Restrict to alphanumeric, hyphens and underscore only.
> > That said, why are you controlling this directly? I thought we autogenerate filenames when saving images.
> 
> Hey @seanlip , got it, thanks for clarifying!
> 
> Didn’t realize the filenames were autogenerated, that helps a lot. I’ll adjust the regex to only match alphanumeric characters, hyphens, and underscores like you suggested. I’ll also rethink the approach a bit.
> 
> Thanks!

OK, thanks. I don't think I understand what you are trying to do though. Why are you processing the filenames at all (in the regex)?
author_association: MEMBER
user: login: TARishabh
created_at: 2024-12-09T18:38:11Z
body: Hi @seanlip ! I checked the validate_image_and_filename function, which is used during image uploads, and its tests—it doesn’t explicitly handle <, >, or similar special characters. Since filenames are autogenerated, I think addressing this isn’t necessary for this PR.

For now, I’ll focus on the main feature and raise a separate issue to review and enhance the filename validation logic later. Let me know what you think! Thanks!
author_association: CONTRIBUTOR
user: login: seanlip
created_at: 2024-12-10T06:32:34Z
body: @TARishabh Yeah, sounds good. Feel free to file a separate issue, I see no harm in updating the backend logic to include those cases in validation. Thanks!
author_association: MEMBER
user: login: TARishabh
created_at: 2024-12-12T05:39:04Z
body: @Nik-09  @chris7716 PTAL !

The frontend issues causing failures do not seem related to this PR. Should I link an existing issue and report the details there, or would you recommend creating a new issue for tracking? Please advise. Thanks !

![image](https://github.com/user-attachments/assets/8495922c-101f-44cd-b717-6d4753086ba4)

author_association: CONTRIBUTOR
user: login: oppiabot[bot]
created_at: 2024-12-12T05:42:09Z
body: Unassigning @TARishabh since a re-review was requested. @TARishabh, please make sure you have addressed all review comments. Thanks!
author_association: NONE
user: login: seanlip
created_at: 2024-12-12T05:47:38Z
body: @TARishabh Please see https://github.com/oppia/oppia/wiki/If-CI-checks-fail-on-your-PR for guidance on how to handle CI failures. Thanks.
author_association: MEMBER
user: login: TARishabh
created_at: 2024-12-13T07:59:14Z
body: @seanlip I have reported the failing check in its respective issue. Thanks !
author_association: CONTRIBUTOR
user: login: seanlip
created_at: 2024-12-13T14:34:05Z
body: > @seanlip I have reported the failing check in its respective issue. Thanks !

Thanks! I restarted your test.
author_association: MEMBER
user: login: TARishabh
created_at: 2024-12-15T07:01:45Z
body: @seanlip  I have reported the failing acceptance test by creating a new issue. Thanks !

author_association: CONTRIBUTOR
user: login: oppiabot[bot]
created_at: 2024-12-16T20:05:08Z
body: Hi @TARishabh, it looks like some changes were requested on this pull request by @StephenYu2018. PTAL. Thanks!
author_association: NONE
user: login: oppiabot[bot]
created_at: 2024-12-30T17:31:22Z
body: Assigning @Nik-09, @Vir-8 for code owner reviews. Thanks!
author_association: NONE
user: login: oppiabot[bot]
created_at: 2025-01-02T04:53:19Z
body: Unassigning @chris7716 since the review is done.
author_association: NONE
user: login: masterboy376
created_at: 2025-01-07T17:11:55Z
body: Quick update: the target merge date for this PR is set to 14 Jan
author_association: CONTRIBUTOR
user: login: TARishabh
created_at: 2025-01-10T21:55:53Z
body: Proof of the updated changes:

https://github.com/user-attachments/assets/e4eb2bb6-8dbe-4279-851f-02344b69e08e


author_association: CONTRIBUTOR
user: login: TARishabh
created_at: 2025-01-11T09:00:07Z
body: @seanlip @chris7716 @Vir-8 PTAL !
author_association: CONTRIBUTOR
user: login: oppiabot[bot]
created_at: 2025-01-11T09:03:12Z
body: Unassigning @TARishabh since a re-review was requested. @TARishabh, please make sure you have addressed all review comments. Thanks!
author_association: NONE
user: login: TARishabh
created_at: 2025-01-12T13:37:22Z
body: @seanlip I've attached a video demo showing translations with multiple components. Right now, we've only added validation for images based on the initial ask in [#21087](https://github.com/oppia/oppia/issues/21087). While testing this, I noticed we can still remove concept cards and math formulas during translation review.

I think we should have similar checks for these components too, but that would make this PR quite big. Should I create a new issue for validating concept cards and math components?


https://github.com/user-attachments/assets/4ec7512d-d1c9-45cc-994c-5c9f34e7a447


author_association: CONTRIBUTOR
user: login: seanlip
created_at: 2025-01-13T06:21:19Z
body: @TARishabh Better to include those cases in this PR so we can make sure the approach is properly generalized. If you are using a generalizable approach, the size of the PR should not change that much.

Also a general question: don't we need to handle this already on the submission side? If so, how does that functionality work and is there a reason we aren't reusing it?

author_association: MEMBER
user: login: TARishabh
created_at: 2025-01-13T16:26:52Z
body: @seanlip   After digging into this, I found we already have validation for components (images, concept cards, math) during translation submission. Here's how it works:

When a user tries to submit a translation without copying some components, they get this error:
[Please check Screenshot]
The validation logic is in translatedTextCanBeSubmitted() which uses validateTranslation() and isTranslationCompleted() to check if all components are present.
This raises two questions about our approach:

1. Should we reuse this existing validation logic for the review stage instead of creating new validation in HtmlParsingService? This would give us consistent checks at both submission and review time.
2. If we do reuse it, would HtmlParsingService still be needed? Or should we move all component validation logic into the service to have it centralized?
Let me know which approach you think would be better! Thanks !
![image](https://github.com/user-attachments/assets/fccdc41d-d3d2-402d-846f-a585d647b0d8)

author_association: CONTRIBUTOR
user: login: seanlip
created_at: 2025-01-14T14:41:45Z
body: Hi @TARishabh, please reuse the existing validation logic and augment the existing service if the additional functionality is actually necessary. There is no point implementing this twice and the checks should be consistent for both use cases. Thanks.
author_association: MEMBER
user: login: TARishabh
created_at: 2025-01-15T14:15:43Z
body: @seanlip I see the validation logic is in the translation-modal.component.ts. Since we need this same validation in thetranslation-suggestion-review-modal.component.ts, what's the better approach:

Move the validation logic (validateTranslation, isTranslationCompleted etc.) to a shared service that both flows can use
Or keep it in the submission component and import/reuse from there

Which would be the cleaner architecture?
author_association: CONTRIBUTOR
user: login: seanlip
created_at: 2025-01-15T14:35:05Z
body: The first one.
author_association: MEMBER
user: login: oppiabot[bot]
created_at: 2025-01-22T14:59:13Z
body: Hi @TARishabh, I'm going to mark this PR as stale because it hasn't had any updates for 7 days. If no further activity occurs within **4 days**, it will be automatically closed so that others can take up the issue.
If you are still working on this PR, please make a follow-up commit within **4 days** (and submit it for review, if applicable). Please also let us know if you are stuck so we can help you!

author_association: NONE
user: login: TARishabh
created_at: 2025-02-02T19:23:42Z
body: Here are the proofs for the changes:

https://github.com/user-attachments/assets/1772ef36-6bd8-4e8e-b441-5197e6c9d9d5


https://github.com/user-attachments/assets/062251f5-ff4e-40b3-8d2c-0cf9359d382b

cc - @seanlip @masterboy376 @chris7716 
author_association: CONTRIBUTOR
user: login: TARishabh
created_at: 2025-02-04T05:39:11Z
body: @seanlip @chris7716 PTAL !
author_association: CONTRIBUTOR
user: login: oppiabot[bot]
created_at: 2025-02-04T05:42:16Z
body: Unassigning @TARishabh since a re-review was requested. @TARishabh, please make sure you have addressed all review comments. Thanks!
author_association: NONE
user: login: oppiabot[bot]
created_at: 2025-02-11T19:00:23Z
body: Hi @TARishabh, I'm going to mark this PR as stale because it hasn't had any updates for 7 days. If no further activity occurs within **4 days**, it will be automatically closed so that others can take up the issue.
If you are still working on this PR, please make a follow-up commit within **4 days** (and submit it for review, if applicable). Please also let us know if you are stuck so we can help you!

author_association: NONE
user: login: TARishabh
created_at: 2025-02-17T13:45:14Z
body: @seanlip @chris7716 PTAL !

https://github.com/user-attachments/assets/b5060fd0-718f-40cf-a3f3-52045ba3a765



author_association: CONTRIBUTOR
user: login: oppiabot[bot]
created_at: 2025-02-17T13:48:19Z
body: Unassigning @TARishabh since a re-review was requested. @TARishabh, please make sure you have addressed all review comments. Thanks!
author_association: NONE
user: login: seanlip
created_at: 2025-02-20T15:33:16Z
body: @TARishabh You haven't responded to all my previous comments. Please respond to all reviewers' comments before sending PRs for review -- thanks.
author_association: MEMBER
user: login: TARishabh
created_at: 2025-02-22T09:21:38Z
body: Hey @seanlip , I believe there might be a bug or some misunderstanding. From my end, I have responded to all reviewer comments, including yours (@seanlip ), @chris7716 's, and vir-8's. I also double-checked with @masterboy376 , and couldn't find any unresolved comments.

To clarify, I’m attaching a video as proof. If there are still any comments left to address, could you please help me locate them? Thanks!


https://github.com/user-attachments/assets/825ebd73-7180-4158-af4a-bb2f1b45a389


author_association: CONTRIBUTOR
user: login: seanlip
created_at: 2025-02-22T09:25:29Z
body: Scroll up in this conversation thread. You will see this:

![Screenshot from 2025-02-22 17-25-05](https://github.com/user-attachments/assets/b9ee8946-bcca-4fd1-9bae-0e2ea2be4a41)

author_association: MEMBER
user: login: seanlip
created_at: 2025-02-22T09:29:10Z
body: In the video, your comments have "Pending" next to them. They are not published yet. You will need to publish them using the button at the top right of the Files Changed tab, following the PR contribution guidelines.

To verify that your comments are visible to others, open your PR thread in an incognito tab.
author_association: MEMBER
user: login: masterboy376
created_at: 2025-02-22T09:40:19Z
body: Hi @TARishabh, please publish your responses for following comments:
https://github.com/oppia/oppia/pull/21241#discussion_r1941646034, https://github.com/oppia/oppia/pull/21241#discussion_r1941650891, 
https://github.com/oppia/oppia/pull/21241#discussion_r1941654448,
https://github.com/oppia/oppia/pull/21241#discussion_r1941656644,
https://github.com/oppia/oppia/pull/21241#discussion_r1941658167,
author_association: CONTRIBUTOR
user: login: TARishabh
created_at: 2025-02-22T10:04:14Z
body: Thank you for your guidance @seanlip  @masterboy376 . I verified in incognito mode and confirmed that the comments were not visible initially. However, I have now published them and rechecked in incognito mode, where they are now appearing.

@seanlip , PTAL ! Thanks!
author_association: CONTRIBUTOR
user: login: oppiabot[bot]
created_at: 2025-02-22T10:07:19Z
body: Unassigning @TARishabh since a re-review was requested. @TARishabh, please make sure you have addressed all review comments. Thanks!
author_association: NONE
user: login: masterboy376
created_at: 2025-02-22T10:23:36Z
body: @TARishabh, confirming that your replies are now published. Thanks!
author_association: CONTRIBUTOR
user: login: TARishabh
created_at: 2025-02-24T04:34:44Z
body: > Thanks @TARishabh, took a pass.
> 
> Also one question. What did you mean exactly when you said "I also double-checked with @masterboy376 , and couldn't find any unresolved comments." in [#21241 (comment)](https://github.com/oppia/oppia/pull/21241#issuecomment-2676116315) -- what sort of double-checking did you do, exactly? This is different from what @masterboy376 told me, which was that he sent you the link of unanswered comments and asked you to reply to those and request a re-review.

Hi @seanlip , I’d like to clarify what I meant by "double-checking with @masterboy376 ."

I reached out to @masterboy376 on Google Chat and sent him a screenshot showing that I had replied to your comment. Later, when @masterboy376 sent me a link to an unanswered comment, I noticed that the comment was still in a "pending" state on my side (not yet published). Since I could see the comment, I mistakenly assumed it was already addressed. When Sambhav then asked me to reply to your comment, I got confused about whether he was referring to your original review comments or the message where you mentioned the unanswered ones.

I now realize the misunderstanding and will be more careful going forward. To avoid such confusion in the future, I’ll keep all discussions within the PR thread so that everyone stays in the loop. The only reason I reached out via Google Chat was that responses are usually quicker there.

author_association: CONTRIBUTOR
user: login: seanlip
created_at: 2025-02-25T07:25:16Z
body: @TARishabh OK, thanks for explaining. Let me suggest two things to do in the future:

- Only use group chats, GitHub Discussions, or the PR thread, for communication, rather than 1:1 chats. I think @masterboy376 has already been encouraging you to do this.

- Try to be clearer in your communication. When you say "From my end, I have responded to all reviewer comments ... I also double-checked with @masterboy376 , and couldn't find any unresolved comments.", that suggests that @masterboy376 has confirmed that there are no unresolved comments, which was not the case.

Thanks.
author_association: MEMBER
user: login: TARishabh
created_at: 2025-02-25T08:01:01Z
body: @seanlip 
1. For the oppia-units-input-error comment:

I've standardized on oppia-input-error.

2. For the variable renaming comment:

I noticed several variables starting with verbs in translation-modal.component.ts and translation-suggestion-review-modal.component.ts. Would you prefer me to rename all of them in this PR, or should I create a separate issue for that? Renaming them in this PR might make it unnecessarily long.
author_association: CONTRIBUTOR
user: login: seanlip
created_at: 2025-02-25T08:02:51Z
body: > @seanlip
> 
> 1. For the oppia-units-input-error comment:
> 
> I've standardized on oppia-input-error.
> 
> 2. For the variable renaming comment:
> 
> I noticed several variables starting with verbs in translation-modal.component.ts and translation-suggestion-review-modal.component.ts. Would you prefer me to rename all of them in this PR, or should I create a separate issue for that? Renaming them in this PR might make it unnecessarily long.

1. OK.
2. Up to you. It's not a requirement to rename stuff you didn't touch. If you introduced or touched anything, though, or anything caused you confusion, then I suggest going ahead and updating the naming in this PR.
author_association: MEMBER
user: login: TARishabh
created_at: 2025-02-25T18:31:04Z
body: Hey @seanlip , PTAL! I've made the requested changes and also reported the failing acceptance test. Let me know if anything else needs to be addressed. Thanks!
author_association: CONTRIBUTOR
user: login: TARishabh
created_at: 2025-03-03T14:10:59Z
body: > Almost there!

@seanlip PTAL !
author_association: CONTRIBUTOR
user: login: oppiabot[bot]
created_at: 2025-03-06T16:12:15Z
body: Unassigning @chris7716 since they have already approved the PR.
author_association: NONE
user: login: oppiabot[bot]
created_at: 2025-03-13T16:49:05Z
body: Hi @TARishabh, I'm going to mark this PR as stale because it hasn't had any updates for 7 days. If no further activity occurs within **4 days**, it will be automatically closed so that others can take up the issue.
If you are still working on this PR, please make a follow-up commit within **4 days** (and submit it for review, if applicable). Please also let us know if you are stuck so we can help you!

author_association: NONE
user: login: TARishabh
created_at: 2025-03-19T07:35:12Z
body: Hey @seanlip , PTAL. 
I have reported the failing tests:

The first issue was already filed earlier: [Issue #20622](https://github.com/oppia/oppia/issues/20622)
The second issue was not filed earlier—I searched and created a new issue for it: [Issue #22174](https://github.com/oppia/oppia/issues/22174)
author_association: CONTRIBUTOR
user: login: oppiabot[bot]
created_at: 2025-03-19T07:38:17Z
body: Unassigning @TARishabh since a re-review was requested. @TARishabh, please make sure you have addressed all review comments. Thanks!
author_association: NONE
user: login: TARishabh
created_at: 2025-03-22T05:27:14Z
body: @seanlip PTAL !
author_association: CONTRIBUTOR
user: login: TARishabh
created_at: 2025-04-01T14:23:01Z
body: @seanlip PTAL!
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-01T14:23:21Z
body: Assigned reviewers: @seanlip. Thank you!
author_association: NONE
user: login: TARishabh
created_at: 2025-04-06T12:34:20Z
body: @seanlip PTAL !
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-06T12:35:56Z
body: Assigned reviewers: @seanlip. Thank you!
author_association: NONE
user: login: oppiabot[bot]
created_at: 2025-04-11T02:36:13Z
body: Unassigning @seanlip since they have already approved the PR.
author_association: NONE
user: login: oppiabot[bot]
created_at: 2025-04-11T02:36:17Z
body: Hi @TARishabh, this PR is ready to be merged. Please address any remaining comments prior to merging, and feel free to ask someone to merge your PR once the CI checks pass and you're happy with it. Thanks!
author_association: NONE
user: login: oppiabot[bot]
created_at: 2025-04-11T03:13:41Z
body: Hi @TARishabh. Due to recent changes in the "develop" branch, this PR now has a merge conflict. Please follow this <a href="https://help.github.com/articles/resolving-a-merge-conflict-using-the-command-line/">link</a> if you need help resolving the conflict, so that the PR can be merged. Thanks!
author_association: NONE
user: login: seanlip
created_at: 2025-04-11T03:31:06Z
body: @TARishabh Please resolve the merge conflicts, thanks. 
author_association: MEMBER
user: login: TARishabh
created_at: 2025-04-13T11:02:39Z
body: Apologies @seanlip , the auto-merge was automatically disabled because I resolved the merge conflicts locally. Could you please take a look and enqueue it in the merge queue again? Tagging @Nik-09  and @chris7716 as well for the same request.
author_association: CONTRIBUTOR
user: login: oppiabot[bot]
created_at: 2025-04-13T11:05:43Z
body: Unassigning @TARishabh since a re-review was requested. @TARishabh, please make sure you have addressed all review comments. Thanks!
author_association: NONE
user: login: oppia-github-app[bot]
created_at: 2025-04-13T13:49:19Z
body: Congratulations! This PR has been deployed to the test server. You can test it out at [this link](https://www.oppiatestserver.org/).
author_association: NONE