Multiline sync from ADO to Jira DC

Hi Jillani,

I have used what you have recommended

replica.description = workItem.“Microsoft.VSTS.TCM.ReproSteps”

Thanks
Vimalraj

Hi Jillani,

Below is the finding,

ADO text field with single line feeds/carriage returns do not convert correctly from ADO ->Jira

image

image

On ADO side, if I look at the code using inspector, I see this:

If I add the returns on Jira side and then sync back to ADO side, I see this:

When I add more text on ADO side:

The code looks like this:

…and after the sync, on the Jira side I see this:

Hi @Vimalraj

Thank you for the update and let me have this checked, will get back to you with an update.

Hi @Vimalraj

Can you please try this snippet in the Jira Incoming:

package com.exalate.transform

import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.TextNode
import org.jsoup.select.Elements
import org.jsoup.safety.Whitelist
import org.jsoup.nodes.Node

class HtmlToWiki {
    
    /**
     * This map contains the translation between an html tag and a wiki tag.
     * Note that some tags require a start and stop (like in the case of bold tags)
     */
    static Map<String, List<String>> tagMap = [
        // html tag : [ <starttag>, <stoptag> ]
        "h1"    : ["h1. ", "\n"],
        "h2"    : ["h2. ", "\n"],
        "h3"    : ["h3. ", "\n"],
        "h4"    : ["h4. ", "\n"],
        "h5"    : ["h5. ", "\n"],
        "h6"    : ["h6. ", "\n"],
        "i"     : ["_", "_"],
        "u"     : ["", ""],
        "strike": ["", ""],
        "em"    : ["_", "_"],
        "b"     : ["*", "*"],
        "strong": ["*", "*"],
        "p"     : ["", "\n"],  // Ensuring p tags are converted to line breaks
        "br"    : ["", "\n"],  // Ensuring br tags are converted to line breaks
        "hr"    : ["", "-----"],
        "table" : ["", ""],
        "tbody" : ["", ""],
        "div"   : ["", "\n"],
        "tr"    : ["", ""],
        "td"    : ["", ""],
        "th"    : ["", ""],
        "pre"   : ["{noformat}","{noformat}"],
        "code"  : ["{code}","{code}"]
    ]
    
    // The dummy is to meet the jsoup requirement that relative URL's can only be resolved if a basic URL is provided
    static private String DUMMY = "http://dummy/"

    private int listLevel = 0  // Keep track of indentations
    private boolean ignoreCodeTag = false // Code tags in preformatted clause must be ignored
    private Map<String, String> imageNames = [:]
    private Whitelist safeList
    private Integer imgWidth = 0

    // Constructor is empty
    HtmlToWiki() {
        makeSafeList()
    }

    // Constructor for image attachments
    HtmlToWiki(List imageAttachments) {
        makeSafeList()
        imageAttachments.each {
            imageNames.put(it.remoteIdStr as String, it.filename as String)
        }
    }

    // Set the image width if needed
    void setImgWidth(Integer imageWidth) {
        this.imgWidth = imageWidth
    }

    String transform(String htmlText) {
        if (!htmlText || htmlText == "")
            return ""
        
        Document doc = Jsoup.parse(cleanup(htmlText))
        if (doc == null) {
            throw new Exception("Euh - I really can't parse ${htmlText} the parser returns a null")
        }
        Elements bodyChildren = doc.body().childNodes()
        
        return process(bodyChildren)
    }

    // Ensure only allowable tags are processed, and avoid any type of xss
    private makeSafeList() {
        safeList = Whitelist.basicWithImages()
        String[] fullTagList = tagMap.keySet().toArray(new String[tagMap.size()])
        
        safeList
            .addTags(fullTagList)
            .preserveRelativeLinks(true)
            .addAttributes("span", "style")
    }

    // Clean out the text from unsafe html. Only allow the Safelist
    private String cleanup(String htmlText) {
        Document.OutputSettings outputSettings = new Document.OutputSettings()
        outputSettings.prettyPrint(false)
        
        htmlText = Jsoup.clean(htmlText, DUMMY, safeList, outputSettings)
        return htmlText
    }

    // Process the elements from the parsed HTML content
    private String process(Elements clauses) {
        String result = ""
        
        clauses.each {
            clause -> result += process(clause)
        }
        
        return result
    }

    // Processing a single element (like img, a, ol, ul, etc.)
    private String process(Element clause) {
        String tagName = clause.tagName()
        
        switch (tagName) {
            case "img":
                return processImage(clause)
            case "a":
                return processHref(clause)
            case "ol":
                return processList(clause, "#")
            case "ul":
                return processList(clause, "*")
            case "p":
                // Explicitly wrap <p> tags with line breaks
                return "\n" + processChilds(clause) + "\n" // Change made here
            case "br":
                // Explicitly handle <br> tags to create newlines
                return "\n"  // Change made here
            case "span":
                return processSpan(clause)
            case "pre":
                return processPre(clause)
            case "table":
                return processTable(clause)
            case "code":
                if (ignoreCodeTag) {
                    return processChilds(clause)
                }
        }
        
        return startTag(tagName) + processChilds(clause) + stopTag(tagName)
    }

    // Handle list items and ensure proper line breaks
    private String processList(Element clause, String listItemMarkUp) {
        listLevel++
        
        String result = clause
            .childNodes()
            .inject("") { r, node ->
                if (node instanceof Element && node.tagName() == "li") {
                    def lineBreakOrNothingIfLast = node.nextSibling() == null ? "" : "\n"
                    r += (listItemMarkUp * listLevel) + " " + processChilds((Element) node) + lineBreakOrNothingIfLast
                } else if (node instanceof TextNode) {
                    r += node.wholeText
                            .replaceAll("\n", "")
                            .replaceAll("\r", "")
                }
                r
            }

        listLevel--
        return result
    }

    // A single line item can contain clauses or sublists and so on
    private String processListItem(Element clause, String listItemMarkUp) {
        if (clause.tagName() == "li")
            return (listItemMarkUp * listLevel) + " " + processChilds(clause) + "\n"
        else return ""
    }

    private String processChilds(Element clause) {
        String result = clause
            .childNodes()
            .inject("") { r, node -> 
                r += process(node)
                r
            }
        
        return result
    }

    private String processListItem(TextNode textNode, String ignore) {
        return textNode.getWholeText()
    }

    private String processHref(Element clause) {
        String href = clause.attr("href")
        String result = ""
        
        clause.childNodes().each {
            child -> result += process(child)
        }
        
        return result > "" ? "[${result}|${href}]" : "[${href}]"
    }

    private String processImage(Element clause) {
        if (clause.attr("title") == "database image")
            return ""
        
        String sourceName = clause.attr("src")
        if (!sourceName) return ""

        // Handle image filename transformation for service now and Azure DevOps
        if (sourceName.contains("sys_attachment.do") && !imageNames.isEmpty()) {
            def matcher = sourceName =~ /sys_attachment.do\?sys_id=(\S+)/
            if (matcher.hasGroup()) {
                String sys_id = matcher[0][1] as String
                sourceName = imageNames[sys_id]
            }
        }

        if (sourceName.contains("/_apis/wit/attachments/")) {
            def matcher = sourceName =~ /fileName=(\S+)/
            if (matcher.hasGroup()) {
                sourceName = matcher[0][1] as String
            }
        }

        return imgWidth == 0 ? "\n!${sourceName}!" : "\n!${sourceName}|width=${imgWidth}!"
    }

    private String processSpan(Element clause) {
        String styleAttribute = clause.attr("style")
        
        String result = ""
        clause.childNodes().each {
            child -> result += process(child)
        }
        
        // Only call processColorHash if the styleAttribute contains color properties
        if (styleAttribute && (styleAttribute.contains("color:") || styleAttribute.contains("background-color:"))) {
            result = processColorHash(styleAttribute, result)
        }

        // Only call processColorRGB if the styleAttribute contains color properties
        if (styleAttribute && (styleAttribute.contains("color:") || styleAttribute.contains("background-color:"))) {
            result = processColorRGB(styleAttribute, result) // Change made here
        }
        
        return result
    }

    private String processPre(Element clause) {
        ignoreCodeTag = true
        String result = startTag("pre") + processChilds(clause) + stopTag("pre")
        ignoreCodeTag = false
        return result
    }

    private String processTable(Element clause) {
        def result
        List<Element> trs = clause
            .childNodes()
            .inject([] as List<Element>, collectTrs)

        result = trs
            .collect { Element tr ->
                def thAndTds = tr
                    .childNodes()
                    .inject([] as List<Element>, collectThsAndTds)
                def trStr = thAndTds
                        .inject("") { str, thOrTd ->
                            def prefix = thOrTd.tagName() == "th" ? "||" : "|"
                            def thOrTdBody = process(thOrTd)
                            def wrapped = (thOrTdBody.contains("\n")) ? "{panel}" + thOrTdBody + "{panel}" : thOrTdBody
                            return str + prefix + wrapped
                        }
                return trStr + "|"
            }
            .join("\n")

        return result
    }

    private Closure<List<Element>> collectTrs;
    {
        collectTrs = { List<Element> trs, Node e ->
            if (e instanceof Element && ((Element)e).tagName() == "tr") {
                trs.add((Element)e)
                return trs
            } else {
                return e
                        .childNodes()
                        .inject(trs, collectTrs)
            }
        }
    }

    private Closure<List<Element>> collectThsAndTds
    {
        collectThsAndTds = { List<Element> thAndTds, Node e ->
            if (e instanceof Element && (((Element)e).tagName() == "th" || ((Element)e).tagName() == "td")) {
                thAndTds.add((Element)e)
                return thAndTds
            } else {
                return e
                    .childNodes()
                    .inject(thAndTds, collectThsAndTds)
            }
        }
    }

    private String startTag(String tagName) {
        return tagMap[tagName] ? tagMap[tagName].get(0) : "?${tagName}?"
    }

    private String stopTag(String tagName) {
        return tagMap[tagName] ? tagMap[tagName].get(1) : "?${tagName}?"
    }

    private String process(TextNode text) {
        return text.getWholeText()
    }
}

Hi Jillani,

Thanks for your input.

I managed to fix the problem yesterdat by changing the “div”. Please refer to the code snippet.

static Map<String, List> tagMap = [

    //  html tag : [ <starttag>, <stoptag> ]
    "h1"    : ["h1. ", "\n"],
    "h2"    : ["h2. ", "\n"],
    "h3"    : ["h3. ", "\n"],
    "h4"    : ["h4. ", "\n"],
    "h5"    : ["h5. ", "\n"],
    "h6"    : ["h6. ", "\n"],
    "i"     : ["_", "_"],
    "u"     : ["", ""],
    "strike": ["", ""],
    "em"    : ["_", "_"],
    "b"     : ["*", "*"],
    "strong": ["*", "*"],
    "p"     : ["", ""],
    "br"    : ["", ""],
    "hr"    : ["", "-----"],
    "table" : ["", ""],
    "tbody" : ["", ""],
    "div" : ["", "\n"],
    "tr"    : ["", ""],
    "td"    : ["", ""],
    "th"    : ["", ""],
    "pre"   : ["{noformat}","{noformat}"],
    "code"  : ["{code}","{code}"]
]

Hi @Vimalraj

These are the same changes, which I shared with you yesterday and glad that you already worked on it.:slight_smile:

Thank you for your cooperation as we both worked together towards a solution. Much appreciated.

Marking your last comment as a solution and sharing here again the updated snippet for the other users.

Incoming Jira.txt (15.5 KB)

1 Like

This topic was automatically closed 24 hours after the last reply. New replies are no longer allowed.