Need a solution for a bi-directional sync, of embedded (in-line) images in the description/comments/Text fields etc.) and their attachments

Originally asked by Harold Oconitrillo on 14 September 2023 (original question)


Hello support.

Main goal is to be able to create a bi-directional sync. from Azure Devops Board to Jira.

Here the first issues that I have so far:

Context: using Exalate in Script mode From Azure Work item(Outgoing sync ) to Jira Cloud Issue (Incoming sync) ( note: as a start we need Azure to Jira sync. but our main goal is to have bi-directional sync )

To reproduce my situation:

  • From Azure Board create a work item
    • in the Description add some text
      • Add an image by pasting from a print screen
      • Save
    • Reopen the same work item
    • add a Comment
      • Add an new image by pasting from a new print screen
      • Save

Azure DevOps Board - Exalate outbound

// Azure DevOps Board - Exalate outbound

replica.description    = workItem.description	// Note description will contain HTML

replica.attachments    = workItem.attachments

replica.comments       = workItem.comments 		// Note comment will contain HTML

NOTE from the Jira Exalate connection information of the Remote replica entity section about the Attachments

"attachments": [
      {
        "id": "5729801f-8a84-4dd6-adc7-7521854a47d2",
        "filename": "image.png",
        "internal": false,
        "zip": false
      },
      {
        "id": "fbc509af-2794-4766-b347-99a7fd7bd688",
        "filename": "image.png",
        "internal": false,
        "zip": false
      }
    ],

Jira Cloud - Exalate Incoming sync

// Jira Cloud - Exalate Incoming sync

import com.exalate.transform.HtmlToWiki


issue.attachments  = attachmentHelper.mergeAttachments(issue, replica)

HtmlToWiki htwDesc   = new HtmlToWiki(replica.attachments)
String wikiDescriptionText  = htwDesc.transform(replica.description)  // replica as HTML Jira understands wiki
issue.description = wikiDescriptionText 


HtmlToWiki htwComment   = new HtmlToWiki(replica.attachments) //  // replica as HTML Jira understands wiki 
issue.comments = commentHelper.mergeComments(issue, replica,  {
    comment ->    
    comment.body = htwComment.transform(comment.body)
    comment
})

The result in the Jira issue, both the description and comment text fields have the same image (The last image added to the comment, the second image.png).

I did try the suggested solution from : https://community.exalate.com/display/exacom/questions/20124163/exalating-attachments-pasted-images-on-azuredevops-result-in-same-name-files-on-receiving-jira-side

Solution to rename the filename values in replica.attachments but the HtmlToWiki::processImage methode/function base it self on the HTML content only to scans for filename in the Image ‘src’ attribute. Giving me a broken in-line image link on the Jira side.

Since the Field content (HTML) (description and/or comments) in-line images information need/must fit the attachment information in order to resolve the image information to display in the proper context. There is a missing part to the solution.

Hypotheses:

So by renaming the images from azure to Jira …in Jira new filename ….when will do the other sync Jira to Azure inbound script even using the attachmentHelper.mergeAttachments tool … are those images are added to the work item making duplicates because their is different filenames.

Main goal is to be able to create a bi-directional sync. from

  • Azure Devops Board
  • Jira Cloud

Help would be appreciated,


Answer by Javier Pozuelo on 26 September 2023

Hello,

Add the following code on your Outgoing Sync on Jira Cloud

replica.comments = nodeHelper.getHtmlComments(issue)

Incoming Sync Azure DevOps

def processInlineImages = { str ->
    def processUnescapedLtGtTags = {
        def counter = 0
        while (counter < 1000) {
            def matcher = (str =~ /<!-- inline image filename=#(([^#]+)|(([^#]+)#([^#]+)))# -->/)
            if (matcher.size() < 1) {
                break;
            }
            def match = matcher[0]
            if (match.size() < 2) {
                break;
            }
            //log.error("replica.attachments=${replica.attachments}")
            def attId = replica.attachments.find { it.filename?.equals(match[1]) }?.remoteId
            if (!attId) {
                log.error("""Could not find attachment with name ${match[1]},
           known names: ${replica.attachments.filename},
           match: ${replica.attachments.find { it.filename?.equals(match[1]) }}
       """)
                str = str.replace(match[0], """<!-- inline processed image filename=#${match[1]}# -->""".toString())
            } else {
                def tmpStr = str.replace(match[0], """<img src="/secure/attachment/${attId}/${attId}_${match[1]}" />""".toString())
                if (tmpStr == str) {
                    break;
                }
                str = tmpStr
            }
            counter++
        }
        str
    }
    def processLtGtTags = {
        def counter = 0
        while (counter < 1000) {
            def matcher = (str =~ /<!-- inline image filename=#(([^#]+)|(([^#]+)#([^#]+)))# -->/)
            if (matcher.size() < 1) {
                break;
            }
            def match = matcher[0]
            if (match.size() < 2) {
                break;
            }
            def attId = replica.attachments.find { it.filename?.equals(match[1]) }?.remoteId
            if (!attId) {
                log.error("""Could not find attachment with name ${match[1]},
           known names: ${replica.attachments.filename},
           match: ${replica.attachments.find { it.filename?.equals(match[1]) }}
       """)
                str = str.replace(match[0], """<!-- inline processed image filename=#${match[1]}# -->""".toString())
            } else {
                def tmpStr = str.replace(match[0], """<img src="/secure/attachment/${attId}/${attId}_${match[1]}" />""".toString())
                if (tmpStr == str) {
                    break;
                }
                str = tmpStr
            }
            counter++
        }
        str
    }
    def processNoImage = {
        //"<p><img
        // src=\"https://jira.smartodds.co.uk/images/icons/attach/noimage.png\"
        // imagetext=\"Screenshot from 2022-11-18 11-09-25.png|thumbnail\"
        // align=\"absmiddle\"
        // border=\"0\" /></p>"
        def counter = 0
        while (counter < 1000) {
            def matcher = (str =~ /<img src="[^"]+" imagetext="(([^"]+)\|thumbnail)" align="absmiddle" border="0" \/>/)
            if (matcher.size() < 1) {
                break;
            }
            def match = matcher[0]
            if (match.size() < 2) {
                break;
            }
            def filename = match[2]
            def attId = replica.attachments.find { it.filename?.equals(filename) }?.remoteId
            if (!attId) {
                log.error("""Could not find attachment with name ${filename},
           known names: ${replica.attachments.filename},
           match: ${replica.attachments.find { it.filename?.equals(filename) }}
       """)
                str = str.replace(match[0], """<img src="/images/icons/attach/noimage.png" processed imagetext="$filename|thumbnail" align="absmiddle" border="0" />""".toString())
            } else {
                def tmpStr = str.replace(match[0], """<img src="/secure/attachment/${attId}/${attId}_${filename}" />""".toString())
                if (tmpStr == str) {
                    break;
                }
                str = tmpStr
            }
            counter++
        }
        str
    }
    def processImgTagsWithIds = {
        //"<p>TEST DECS23456 </p> \n
        //<p><span class=\"image-wrap\" style=\"\"><img src=\"/rest/api/3/attachment/content/36820\"></span></p> \n
        //<p>TESt </p> \n
        //<p><span class=\"image-wrap\" style=\"\"><img src=\"/rest/api/3/attachment/content/36821\"></span></p> \n
        //<p>and more</p>"
        def counter = 0
        while (counter < 1000) {
            def matcher = (str =~ /<img src="\/rest\/api\/3\/attachment\/content\/(\d+)">/)
            if (matcher.size() < 1) {
                return str
            }
            def match = matcher[0]
            //println("match[1]=$match[1]")
            if (match.size() < 2) { // match[0]=<img src="/rest/api/3/attachment/content/36820"> match[1]=36820
                return str
            }
            def attId = match[1]
            def attachment = replica.attachments.find { (it.remoteId as String) == ( attId as String ) }
            if (!attachment) {
                log.error("""Could not find attachment with id ${attId},
           known ids: ${replica.attachments.remoteId},
           match: ${replica.attachments.find { (it.remoteId as String) == ( attId as String ) }}
       """)
                str = str.replace(match[0], """<img src="/rest/api/3/attachment/content/${attId}" processed />""".toString())
            } else {
                def tmpStr = str.replace(match[0], """<img src="/secure/attachment/${attId}/${attId}_${attachment.filename}" />""".toString())
                if (tmpStr == str) {
                    break;
                }
                str = tmpStr
            }
            counter++
        }
        str
    }
    //log.error("#processimages 0 $str")
    str = processUnescapedLtGtTags()
    //log.error("#processimages 1 $str")
    str = processLtGtTags()
    //log.error("#processimages 2 $str")
    str = processNoImage()
    //log.error("#processimages 3 $str")
    str = processImgTagsWithIds()
    log.error("#processimages $str")
    str
}
             
workItem.comments     = commentHelper.mergeComments(workItem, replica, {
    comment ->
def attrAuthor = comment.author?.displayName ?: "Default-"
    comment.body =  "<b> ${attrAuthor} said:</b> " + comment.body
    comment.body = processInlineImages (comment.body)
comment
})
 
 
 
 
workItem.description = processInlineImages(replica.description)


Kind Regards,

Javier Pozuelo