Feature #41294 » Partial-quoting-feature-for-Issues-and-Forums.patch
| app/assets/javascripts/application.js | ||
|---|---|---|
| 1262 | 1262 | 
    tribute.attach(element);  | 
| 1263 | 1263 | 
    }  | 
| 1264 | 1264 | |
| 1265 | ||
| 1266 | 1265 | 
    $(document).ready(setupAjaxIndicator);  | 
| 1267 | 1266 | 
    $(document).ready(hideOnLoad);  | 
| 1268 | 1267 | 
    $(document).ready(addFormObserversForDoubleSubmit);  | 
| app/assets/javascripts/quote_reply.js | ||
|---|---|---|
| 1 | 
    function quoteReply(path, selectorForContentElement, textFormatting) {
   | 
|
| 2 | 
    const contentElement = $(selectorForContentElement).get(0);  | 
|
| 3 | 
    const selectedRange = QuoteExtractor.extract(contentElement);  | 
|
| 4 | ||
| 5 | 
    let formatter;  | 
|
| 6 | ||
| 7 | 
      if (textFormatting === 'common_mark') {
   | 
|
| 8 | 
    formatter = new QuoteCommonMarkFormatter();  | 
|
| 9 | 
      } else {
   | 
|
| 10 | 
    formatter = new QuoteTextFormatter();  | 
|
| 11 | 
    }  | 
|
| 12 | ||
| 13 | 
      $.ajax({
   | 
|
| 14 | 
    url: path,  | 
|
| 15 | 
    type: 'post',  | 
|
| 16 | 
        data: { quote: formatter.format(selectedRange) }
   | 
|
| 17 | 
    });  | 
|
| 18 | 
    }  | 
|
| 19 | ||
| 20 | 
    class QuoteExtractor {
   | 
|
| 21 | 
      static extract(targetElement) {
   | 
|
| 22 | 
    return new QuoteExtractor(targetElement).extract();  | 
|
| 23 | 
    }  | 
|
| 24 | ||
| 25 | 
      constructor(targetElement) {
   | 
|
| 26 | 
    this.targetElement = targetElement;  | 
|
| 27 | 
    this.selection = window.getSelection();  | 
|
| 28 | 
    }  | 
|
| 29 | ||
| 30 | 
      extract() {
   | 
|
| 31 | 
    const range = this.retriveSelectedRange();  | 
|
| 32 | ||
| 33 | 
        if (!range) {
   | 
|
| 34 | 
    return null;  | 
|
| 35 | 
    }  | 
|
| 36 | ||
| 37 | 
        if (!this.targetElement.contains(range.startContainer)) {
   | 
|
| 38 | 
    range.setStartBefore(this.targetElement);  | 
|
| 39 | 
    }  | 
|
| 40 | 
        if (!this.targetElement.contains(range.endContainer)) {
   | 
|
| 41 | 
    range.setEndAfter(this.targetElement);  | 
|
| 42 | 
    }  | 
|
| 43 | ||
| 44 | 
    return range;  | 
|
| 45 | 
    }  | 
|
| 46 | ||
| 47 | 
      retriveSelectedRange() {
   | 
|
| 48 | 
        if (!this.isSelected) {
   | 
|
| 49 | 
    return null;  | 
|
| 50 | 
    }  | 
|
| 51 | ||
| 52 | 
    // Retrive the first range that intersects with the target element.  | 
|
| 53 | 
    // NOTE: Firefox allows to select multiple ranges in the document.  | 
|
| 54 | 
        for (let i = 0; i < this.selection.rangeCount; i++) {
   | 
|
| 55 | 
    let range = this.selection.getRangeAt(i);  | 
|
| 56 | 
          if (range.intersectsNode(this.targetElement)) {
   | 
|
| 57 | 
    return range;  | 
|
| 58 | 
    }  | 
|
| 59 | 
    }  | 
|
| 60 | 
    return null;  | 
|
| 61 | 
    }  | 
|
| 62 | ||
| 63 | 
      get isSelected() {
   | 
|
| 64 | 
    return this.selection.containsNode(this.targetElement, true);  | 
|
| 65 | 
    }  | 
|
| 66 | 
    }  | 
|
| 67 | ||
| 68 | 
    class QuoteTextFormatter {
   | 
|
| 69 | 
      format(selectedRange) {
   | 
|
| 70 | 
        if (!selectedRange) {
   | 
|
| 71 | 
    return null;  | 
|
| 72 | 
    }  | 
|
| 73 | ||
| 74 | 
        const fragment = document.createElement('div');
   | 
|
| 75 | 
    fragment.appendChild(selectedRange.cloneContents());  | 
|
| 76 | ||
| 77 | 
    // Remove all unnecessary anchor elements  | 
|
| 78 | 
        fragment.querySelectorAll('a.wiki-anchor').forEach(e => e.remove());
   | 
|
| 79 | ||
| 80 | 
    const html = this.adjustLineBreaks(fragment.innerHTML);  | 
|
| 81 | ||
| 82 | 
        const result = document.createElement('div');
   | 
|
| 83 | 
    result.innerHTML = html;  | 
|
| 84 | ||
| 85 | 
    // Replace continuous line breaks with a single line break and remove tab characters  | 
|
| 86 | 
    return result.textContent  | 
|
| 87 | 
    .trim()  | 
|
| 88 | 
    .replace(/\t/g, '')  | 
|
| 89 | 
    .replace(/\n+/g, "\n");  | 
|
| 90 | 
    }  | 
|
| 91 | ||
| 92 | 
      adjustLineBreaks(html) {
   | 
|
| 93 | 
    return html  | 
|
| 94 | 
    .replace(/<\/(h1|h2|h3|h4|div|p|li|tr)>/g, "\n</$1>")  | 
|
| 95 | 
    .replace(/<br>/g, "\n")  | 
|
| 96 | 
    }  | 
|
| 97 | 
    }  | 
|
| 98 | ||
| 99 | 
    class QuoteCommonMarkFormatter {
   | 
|
| 100 | 
      format(selectedRange) {
   | 
|
| 101 | 
        if (!selectedRange) {
   | 
|
| 102 | 
    return null;  | 
|
| 103 | 
    }  | 
|
| 104 | ||
| 105 | 
    const htmlFragment = this.extractHtmlFragmentFrom(selectedRange);  | 
|
| 106 | 
    const preparedHtml = this.prepareHtml(htmlFragment);  | 
|
| 107 | ||
| 108 | 
    return this.convertHtmlToCommonMark(preparedHtml);  | 
|
| 109 | 
    }  | 
|
| 110 | ||
| 111 | 
      extractHtmlFragmentFrom(range) {
   | 
|
| 112 | 
        const fragment = document.createElement('div');
   | 
|
| 113 | 
    const ancestorNodeName = range.commonAncestorContainer.nodeName;  | 
|
| 114 | ||
| 115 | 
        if (ancestorNodeName == 'CODE' || ancestorNodeName == '#text') {
   | 
|
| 116 | 
    fragment.appendChild(this.wrapPreCode(range));  | 
|
| 117 | 
        } else {
   | 
|
| 118 | 
    fragment.appendChild(range.cloneContents());  | 
|
| 119 | 
    }  | 
|
| 120 | ||
| 121 | 
    return fragment;  | 
|
| 122 | 
    }  | 
|
| 123 | ||
| 124 | 
    // When only the content within the `<code>` element is selected,  | 
|
| 125 | 
    // the HTML within the selection range does not include the `<pre><code>` element itself.  | 
|
| 126 | 
    // To create a complete code block, wrap the selected content with the `<pre><code>` tags.  | 
|
| 127 | 
    //  | 
|
| 128 | 
    // selected contentes => <pre><code class="ruby">selected contents</code></pre>  | 
|
| 129 | 
      wrapPreCode(range) {
   | 
|
| 130 | 
    const rangeAncestor = range.commonAncestorContainer;  | 
|
| 131 | ||
| 132 | 
    let codeElement = null;  | 
|
| 133 | ||
| 134 | 
        if (rangeAncestor.nodeName == 'CODE') {
   | 
|
| 135 | 
    codeElement = rangeAncestor;  | 
|
| 136 | 
        } else {
   | 
|
| 137 | 
          codeElement = rangeAncestor.parentElement.closest('code');
   | 
|
| 138 | 
    }  | 
|
| 139 | ||
| 140 | 
        if (!codeElement) {
   | 
|
| 141 | 
    return range.cloneContents();  | 
|
| 142 | 
    }  | 
|
| 143 | ||
| 144 | 
        const pre = document.createElement('pre');
   | 
|
| 145 | 
    const code = codeElement.cloneNode(false);  | 
|
| 146 | ||
| 147 | 
    code.appendChild(range.cloneContents());  | 
|
| 148 | 
    pre.appendChild(code);  | 
|
| 149 | ||
| 150 | 
    return pre;  | 
|
| 151 | 
    }  | 
|
| 152 | ||
| 153 | 
      convertHtmlToCommonMark(html) {
   | 
|
| 154 | 
        const turndownService = new TurndownService({
   | 
|
| 155 | 
    codeBlockStyle: 'fenced',  | 
|
| 156 | 
    headingStyle: 'atx'  | 
|
| 157 | 
    });  | 
|
| 158 | ||
| 159 | 
        turndownService.addRule('del', {
   | 
|
| 160 | 
    filter: ['del'],  | 
|
| 161 | 
          replacement: content => `~~${content}~~`
   | 
|
| 162 | 
    });  | 
|
| 163 | ||
| 164 | 
        turndownService.addRule('checkList', {
   | 
|
| 165 | 
          filter: node => {
   | 
|
| 166 | 
    return node.type === 'checkbox' && node.parentNode.nodeName === 'LI';  | 
|
| 167 | 
    },  | 
|
| 168 | 
          replacement: (content, node) => {
   | 
|
| 169 | 
    return node.checked ? '[x]' : '[ ]';  | 
|
| 170 | 
    }  | 
|
| 171 | 
    });  | 
|
| 172 | ||
| 173 | 
    // Table does not maintain its original format,  | 
|
| 174 | 
    // and the text within the table is displayed as it is  | 
|
| 175 | 
    //  | 
|
| 176 | 
    // | A | B | C |  | 
|
| 177 | 
    // |---|---|---|  | 
|
| 178 | 
    // | 1 | 2 | 3 |  | 
|
| 179 | 
    // =>  | 
|
| 180 | 
    // A B C  | 
|
| 181 | 
    // 1 2 3  | 
|
| 182 | 
        turndownService.addRule('table', {
   | 
|
| 183 | 
    filter: ['td', 'th'],  | 
|
| 184 | 
          replacement: (content, node) => {
   | 
|
| 185 | 
    const separator = node.parentElement.lastElementChild === node ? '' : ' ';  | 
|
| 186 | 
    return content + separator;  | 
|
| 187 | 
    }  | 
|
| 188 | 
    });  | 
|
| 189 | 
        turndownService.addRule('tableHeading', {
   | 
|
| 190 | 
    filter: ['thead', 'tbody', 'tfoot', 'tr'],  | 
|
| 191 | 
    replacement: (content, _node) => content  | 
|
| 192 | 
    });  | 
|
| 193 | 
        turndownService.addRule('tableRow', {
   | 
|
| 194 | 
    filter: ['tr'],  | 
|
| 195 | 
          replacement: (content, _node) => {
   | 
|
| 196 | 
    return content + '\n'  | 
|
| 197 | 
    }  | 
|
| 198 | 
    });  | 
|
| 199 | ||
| 200 | 
    return turndownService.turndown(html);  | 
|
| 201 | 
    }  | 
|
| 202 | ||
| 203 | 
      prepareHtml(htmlFragment) {
   | 
|
| 204 | 
    // Remove all anchor elements.  | 
|
| 205 | 
    // <h1>Title1<a href="#Title" class="wiki-anchor">¶</a></h1> => <h1>Title1</h1>  | 
|
| 206 | 
        htmlFragment.querySelectorAll('a.wiki-anchor').forEach(e => e.remove());
   | 
|
| 207 | ||
| 208 | 
    // Convert code highlight blocks to CommonMark format code blocks.  | 
|
| 209 | 
    // <code class="ruby" data-language="ruby"> => <code class="language-ruby" data-language="ruby">  | 
|
| 210 | 
        htmlFragment.querySelectorAll('code[data-language]').forEach(e => {
   | 
|
| 211 | 
    e.classList.replace(e.dataset['language'], 'language-' + e.dataset['language'])  | 
|
| 212 | 
    });  | 
|
| 213 | ||
| 214 | 
    return htmlFragment.innerHTML;  | 
|
| 215 | 
    }  | 
|
| 216 | 
    }  | 
|
| app/assets/javascripts/turndown-7.2.0.min.js | ||
|---|---|---|
| 1 | 
    /*  | 
|
| 2 | 
    * Turndown v7.2.0  | 
|
| 3 | 
    * https://github.com/mixmark-io/turndown  | 
|
| 4 | 
    * Copyright (c) 2017 Dom Christie  | 
|
| 5 | 
    * Released under the MIT license  | 
|
| 6 | 
    * https://github.com/mixmark-io/turndown/blob/master/LICENSE  | 
|
| 7 | 
    */  | 
|
| 8 | 
    var TurndownService=(()=>{function u(e,n){return Array(n+1).join(e)}var n=["ADDRESS","ARTICLE","ASIDE","AUDIO","BLOCKQUOTE","BODY","CANVAS","CENTER","DD","DIR","DIV","DL","DT","FIELDSET","FIGCAPTION","FIGURE","FOOTER","FORM","FRAMESET","H1","H2","H3","H4","H5","H6","HEADER","HGROUP","HR","HTML","ISINDEX","LI","MAIN","MENU","NAV","NOFRAMES","NOSCRIPT","OL","OUTPUT","P","PRE","SECTION","TABLE","TBODY","TD","TFOOT","TH","THEAD","TR","UL"];function f(e){return o(e,n)}var r=["AREA","BASE","BR","COL","COMMAND","EMBED","HR","IMG","INPUT","KEYGEN","LINK","META","PARAM","SOURCE","TRACK","WBR"];function d(e){return o(e,r)}var i=["A","TABLE","THEAD","TBODY","TFOOT","TH","TD","IFRAME","SCRIPT","AUDIO","VIDEO"];function o(e,n){return 0<=n.indexOf(e.nodeName)}function a(n,e){return n.getElementsByTagName&&e.some(function(e){return n.getElementsByTagName(e).length})}var t={};function c(e){return e?e.replace(/(\n+\s*)+/g,"\n"):""}function l(e){for(var n in this.options=e,this._keep=[],this._remove=[],this.blankRule={replacement:e.blankReplacement},this.keepReplacement=e.keepReplacement,this.defaultRule={replacement:e.defaultReplacement},this.array=[],e.rules)this.array.push(e.rules[n])}function s(e,n,t){for(var r=0;r<e.length;r++){var i=e[r];if(((e,n,t)=>{var r=e.filter;if("string"==typeof r)return r===n.nodeName.toLowerCase();if(Array.isArray(r))return-1<r.indexOf(n.nodeName.toLowerCase());if("function"==typeof r)return!!r.call(e,n,t);throw new TypeError("`filter` needs to be a string, array, or function")})(i,n,t))return i}}function p(e){var n=e.nextSibling||e.parentNode;return e.parentNode.removeChild(e),n}function h(e,n,t){return e&&e.parentNode===n||t(n)?n.nextSibling||n.parentNode:n.firstChild||n.nextSibling||n.parentNode}t.paragraph={filter:"p",replacement:function(e){return"\n\n"+e+"\n\n"}},t.lineBreak={filter:"br",replacement:function(e,n,t){return t.br+"\n"}},t.heading={filter:["h1","h2","h3","h4","h5","h6"],replacement:function(e,n,t){n=Number(n.nodeName.charAt(1));return"setext"===t.headingStyle&&n<3?"\n\n"+e+"\n"+u(1===n?"=":"-",e.length)+"\n\n":"\n\n"+u("#",n)+" "+e+"\n\n"}},t.blockquote={filter:"blockquote",replacement:function(e){return"\n\n"+(e=(e=e.replace(/^\n+|\n+$/g,"")).replace(/^/gm,"> "))+"\n\n"}},t.list={filter:["ul","ol"],replacement:function(e,n){var t=n.parentNode;return"LI"===t.nodeName&&t.lastElementChild===n?"\n"+e:"\n\n"+e+"\n\n"}},t.listItem={filter:"li",replacement:function(e,n,t){e=e.replace(/^\n+/,"").replace(/\n+$/,"\n").replace(/\n/gm,"\n    ");var r,t=t.bulletListMarker+"   ",i=n.parentNode;return"OL"===i.nodeName&&(r=i.getAttribute("start"),i=Array.prototype.indexOf.call(i.children,n),t=(r?Number(r)+i:i+1)+".  "),t+e+(n.nextSibling&&!/\n$/.test(e)?"\n":"")}},t.indentedCodeBlock={filter:function(e,n){return"indented"===n.codeBlockStyle&&"PRE"===e.nodeName&&e.firstChild&&"CODE"===e.firstChild.nodeName},replacement:function(e,n,t){return"\n\n    "+n.firstChild.textContent.replace(/\n/g,"\n    ")+"\n\n"}},t.fencedCodeBlock={filter:function(e,n){return"fenced"===n.codeBlockStyle&&"PRE"===e.nodeName&&e.firstChild&&"CODE"===e.firstChild.nodeName},replacement:function(e,n,t){for(var r,i=((n.firstChild.getAttribute("class")||"").match(/language-(\S+)/)||[null,""])[1],o=n.firstChild.textContent,n=t.fence.charAt(0),a=3,l=new RegExp("^"+n+"{3,}","gm");r=l.exec(o);)r[0].length>=a&&(a=r[0].length+1);t=u(n,a);return"\n\n"+t+i+"\n"+o.replace(/\n$/,"")+"\n"+t+"\n\n"}},t.horizontalRule={filter:"hr",replacement:function(e,n,t){return"\n\n"+t.hr+"\n\n"}},t.inlineLink={filter:function(e,n){return"inlined"===n.linkStyle&&"A"===e.nodeName&&e.getAttribute("href")},replacement:function(e,n){var t=(t=n.getAttribute("href"))&&t.replace(/([()])/g,"\\$1"),n=c(n.getAttribute("title"));return"["+e+"]("+t+(n=n&&' "'+n.replace(/"/g,'\\"')+'"')+")"}},t.referenceLink={filter:function(e,n){return"referenced"===n.linkStyle&&"A"===e.nodeName&&e.getAttribute("href")},replacement:function(e,n,t){var r=n.getAttribute("href"),i=(i=c(n.getAttribute("title")))&&' "'+i+'"';switch(t.linkReferenceStyle){case"collapsed":a="["+e+"][]",l="["+e+"]: "+r+i;break;case"shortcut":a="["+e+"]",l="["+e+"]: "+r+i;break;default:var o=this.references.length+1,a="["+e+"]["+o+"]",l="["+o+"]: "+r+i}return this.references.push(l),a},references:[],append:function(e){var n="";return this.references.length&&(n="\n\n"+this.references.join("\n")+"\n\n",this.references=[]),n}},t.emphasis={filter:["em","i"],replacement:function(e,n,t){return e.trim()?t.emDelimiter+e+t.emDelimiter:""}},t.strong={filter:["strong","b"],replacement:function(e,n,t){return e.trim()?t.strongDelimiter+e+t.strongDelimiter:""}},t.code={filter:function(e){var n=e.previousSibling||e.nextSibling,n="PRE"===e.parentNode.nodeName&&!n;return"CODE"===e.nodeName&&!n},replacement:function(e){if(!e)return"";e=e.replace(/\r?\n|\r/g," ");for(var n=/^`|^ .*?[^ ].* $|`$/.test(e)?" ":"",t="`",r=e.match(/`+/gm)||[];-1!==r.indexOf(t);)t+="`";return t+n+e+n+t}},t.image={filter:"img",replacement:function(e,n){var t=c(n.getAttribute("alt")),r=n.getAttribute("src")||"",n=c(n.getAttribute("title"));return r?"+")":""}},l.prototype={add:function(e,n){this.array.unshift(n)},keep:function(e){this._keep.unshift({filter:e,replacement:this.keepReplacement})},remove:function(e){this._remove.unshift({filter:e,replacement:function(){return""}})},forNode:function(e){return e.isBlank?this.blankRule:s(this.array,e,this.options)||s(this._keep,e,this.options)||s(this._remove,e,this.options)||this.defaultRule},forEach:function(e){for(var n=0;n<this.array.length;n++)e(this.array[n],n)}};var g,m="undefined"!=typeof window?window:{},A=(()=>{var e=m.DOMParser,n=!1;try{(new e).parseFromString("","text/html")&&(n=!0)}catch(e){}return n})()?m.DOMParser:((()=>{var n=!1;try{document.implementation.createHTMLDocument("").open()}catch(e){m.ActiveXObject&&(n=!0)}return n})()?e.prototype.parseFromString=function(e){var n=new window.ActiveXObject("htmlfile");return n.designMode="on",n.open(),n.write(e),n.close(),n}:e.prototype.parseFromString=function(e){var n=document.implementation.createHTMLDocument("");return n.open(),n.write(e),n.close(),n},e);function e(){}function y(e,n){var n={element:e="string"==typeof e?(g=g||new A).parseFromString('<x-turndown id="turndown-root">'+e+"</x-turndown>","text/html").getElementById("turndown-root"):e.cloneNode(!0),isBlock:f,isVoid:d,isPre:n.preformattedCode?v:null},t=n.element,r=n.isBlock,i=n.isVoid,o=n.isPre||function(e){return"PRE"===e.nodeName};if(t.firstChild&&!o(t)){for(var a=null,l=!1,u=h(s=null,t,o);u!==t;){if(3===u.nodeType||4===u.nodeType){var c=u.data.replace(/[ \r\n\t]+/g," ");if(!(c=a&&!/ $/.test(a.data)||l||" "!==c[0]?c:c.substr(1))){u=p(u);continue}u.data=c,a=u}else{if(1!==u.nodeType){u=p(u);continue}r(u)||"BR"===u.nodeName?(a&&(a.data=a.data.replace(/ $/,"")),a=null,l=!1):i(u)||o(u)?l=!(a=null):a&&(l=!1)}var c=h(s,u,o),s=u,u=c}a&&(a.data=a.data.replace(/ $/,""),a.data||p(a))}return e}function v(e){return"PRE"===e.nodeName||"CODE"===e.nodeName}function N(e,n){var t;return e.isBlock=f(e),e.isCode="CODE"===e.nodeName||e.parentNode.isCode,e.isBlank=!d(t=e)&&!(e=>o(e,i))(t)&&/^\s*$/i.test(t.textContent)&&!(e=>a(e,r))(t)&&!(e=>a(e,i))(t),e.flankingWhitespace=((e,n)=>{var t;return e.isBlock||n.preformattedCode&&e.isCode?{leading:"",trailing:""}:((t=(e=>({leading:(e=e.match(/^(([ \t\r\n]*)(\s*))(?:(?=\S)[\s\S]*\S)?((\s*?)([ \t\r\n]*))$/))[1],leadingAscii:e[2],leadingNonAscii:e[3],trailing:e[4],trailingNonAscii:e[5],trailingAscii:e[6]}))(e.textContent)).leadingAscii&&E("left",e,n)&&(t.leading=t.leadingNonAscii),t.trailingAscii&&E("right",e,n)&&(t.trailing=t.trailingNonAscii),{leading:t.leading,trailing:t.trailing})})(e,n),e}function E(e,n,t){var r,i,e="left"===e?(r=n.previousSibling,/ $/):(r=n.nextSibling,/^ /);return r&&(3===r.nodeType?i=e.test(r.nodeValue):t.preformattedCode&&"CODE"===r.nodeName?i=!1:1!==r.nodeType||f(r)||(i=e.test(r.textContent))),i}var T=Array.prototype.reduce,R=[[/\\/g,"\\\\"],[/\*/g,"\\*"],[/^-/g,"\\-"],[/^\+ /g,"\\+ "],[/^(=+)/g,"\\$1"],[/^(#{1,6}) /g,"\\$1 "],[/`/g,"\\`"],[/^~~~/g,"\\~~~"],[/\[/g,"\\["],[/\]/g,"\\]"],[/^>/g,"\\>"],[/_/g,"\\_"],[/^(\d+)\. /g,"$1\\. "]];function C(e){if(!(this instanceof C))return new C(e);this.options=function(e){for(var n=1;n<arguments.length;n++){var t,r=arguments[n];for(t in r)r.hasOwnProperty(t)&&(e[t]=r[t])}return e}({},{rules:t,headingStyle:"setext",hr:"* * *",bulletListMarker:"*",codeBlockStyle:"indented",fence:"```",emDelimiter:"_",strongDelimiter:"**",linkStyle:"inlined",linkReferenceStyle:"full",br:"  ",preformattedCode:!1,blankReplacement:function(e,n){return n.isBlock?"\n\n":""},keepReplacement:function(e,n){return n.isBlock?"\n\n"+n.outerHTML+"\n\n":n.outerHTML},defaultReplacement:function(e,n){return n.isBlock?"\n\n"+e+"\n\n":e}},e),this.rules=new l(this.options)}function k(e){var r=this;return T.call(e.childNodes,function(e,n){var t="";return 3===(n=new N(n,r.options)).nodeType?t=n.isCode?n.nodeValue:r.escape(n.nodeValue):1===n.nodeType&&(t=function(e){var n=this.rules.forNode(e),t=k.call(this,e),r=e.flankingWhitespace;(r.leading||r.trailing)&&(t=t.trim());return r.leading+n.replacement(t,e,this.options)+r.trailing}.call(r,n)),b(e,t)},"")}function b(e,n){var t=(e=>{for(var n=e.length;0<n&&"\n"===e[n-1];)n--;return e.substring(0,n)})(e),r=n.replace(/^\n*/,""),e=Math.max(e.length-t.length,n.length-r.length);return t+"\n\n".substring(0,e)+r}return C.prototype={turndown:function(e){if(null==(n=e)||"string"!=typeof n&&(!n.nodeType||1!==n.nodeType&&9!==n.nodeType&&11!==n.nodeType))throw new TypeError(e+" is not a string, or an element/document/fragment node.");var n;return""===e?"":(n=k.call(this,new y(e,this.options)),function(n){var t=this;return this.rules.forEach(function(e){"function"==typeof e.append&&(n=b(n,e.append(t.options)))}),n.replace(/^[\t\r\n]+/,"").replace(/[\t\r\n\s]+$/,"")}.call(this,n))},use:function(e){if(Array.isArray(e))for(var n=0;n<e.length;n++)this.use(e[n]);else{if("function"!=typeof e)throw new TypeError("plugin must be a Function or an Array of Functions");e(this)}return this},addRule:function(e,n){return this.rules.add(e,n),this},keep:function(e){return this.rules.keep(e),this},remove:function(e){return this.rules.remove(e),this},escape:function(e){return R.reduce(function(e,n){return e.replace(n[0],n[1])},e)}},C})();
   | 
|
| app/controllers/journals_controller.rb | ||
|---|---|---|
| 31 | 31 | 
    helper :queries  | 
| 32 | 32 | 
    helper :attachments  | 
| 33 | 33 | 
    include QueriesHelper  | 
| 34 | 
    include Redmine::QuoteReply::Builder  | 
|
| 34 | 35 | |
| 35 | 36 | 
    def index  | 
| 36 | 37 | 
    retrieve_query  | 
| ... | ... | |
| 65 | 66 | |
| 66 | 67 | 
    def new  | 
| 67 | 68 | 
    @journal = Journal.visible.find(params[:journal_id]) if params[:journal_id]  | 
| 68 | 
    if @journal  | 
|
| 69 | 
    user = @journal.user  | 
|
| 70 | 
    text = @journal.notes  | 
|
| 71 | 
          @content = "#{ll(Setting.default_language, :text_user_wrote_in, {:value => user, :link => "#note-#{params[:journal_indice]}"})}\n> "
   | 
|
| 72 | 
    else  | 
|
| 73 | 
    user = @issue.author  | 
|
| 74 | 
    text = @issue.description  | 
|
| 75 | 
          @content = "#{ll(Setting.default_language, :text_user_wrote, user)}\n> "
   | 
|
| 76 | 
    end  | 
|
| 77 | 
    # Replaces pre blocks with [...]  | 
|
| 78 | 
        text = text.to_s.strip.gsub(%r{<pre>(.*?)</pre>}m, '[...]')
   | 
|
| 79 | 
    @content << text.gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"  | 
|
| 69 | 
    @content = if @journal  | 
|
| 70 | 
    quote_issue_journal(@journal, indice: params[:journal_indice], partial_quote: params[:quote])  | 
|
| 71 | 
    else  | 
|
| 72 | 
    quote_issue(@issue, partial_quote: params[:quote])  | 
|
| 73 | 
    end  | 
|
| 80 | 74 | 
    rescue ActiveRecord::RecordNotFound  | 
| 81 | 75 | 
    render_404  | 
| 82 | 76 | 
    end  | 
| app/controllers/messages_controller.rb | ||
|---|---|---|
| 29 | 29 | 
    helper :watchers  | 
| 30 | 30 | 
    helper :attachments  | 
| 31 | 31 | 
    include AttachmentsHelper  | 
| 32 | 
    include Redmine::QuoteReply::Builder  | 
|
| 32 | 33 | |
| 33 | 34 | 
    REPLIES_PER_PAGE = 25 unless const_defined?(:REPLIES_PER_PAGE)  | 
| 34 | 35 | |
| ... | ... | |
| 119 | 120 | 
    @subject = @message.subject  | 
| 120 | 121 | 
        @subject = "RE: #{@subject}" unless @subject.starts_with?('RE:')
   | 
| 121 | 122 | |
| 122 | 
    if @message.root == @message  | 
|
| 123 | 
          @content = "#{ll(Setting.default_language, :text_user_wrote, @message.author)}\n> "
   | 
|
| 124 | 
    else  | 
|
| 125 | 
          @content = "#{ll(Setting.default_language, :text_user_wrote_in, {:value => @message.author, :link => "message##{@message.id}"})}\n> "
   | 
|
| 126 | 
    end  | 
|
| 127 | 
        @content << @message.content.to_s.strip.gsub(%r{<pre>(.*?)</pre>}m, '[...]').gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"
   | 
|
| 123 | 
    @content = if @message.root == @message  | 
|
| 124 | 
    quote_root_message(@message, partial_quote: params[:quote])  | 
|
| 125 | 
    else  | 
|
| 126 | 
    quote_message(@message, partial_quote: params[:quote])  | 
|
| 127 | 
    end  | 
|
| 128 | 128 | |
| 129 | 129 | 
    respond_to do |format|  | 
| 130 | 130 | 
          format.html { render_404 }
   | 
| app/helpers/journals_helper.rb | ||
|---|---|---|
| 18 | 18 | 
    # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.  | 
| 19 | 19 | |
| 20 | 20 | 
    module JournalsHelper  | 
| 21 | 
    include Redmine::QuoteReply::Helper  | 
|
| 22 | ||
| 21 | 23 | 
    # Returns the attachments of a journal that are displayed as thumbnails  | 
| 22 | 24 | 
    def journal_thumbnail_attachments(journal)  | 
| 23 | 25 | 
    journal.attachments.select(&:thumbnailable?)  | 
| ... | ... | |
| 40 | 42 | |
| 41 | 43 | 
    if journal.notes.present?  | 
| 42 | 44 | 
    if options[:reply_links]  | 
| 43 | 
            links << link_to(icon_with_label('comment', l(:button_quote)),
   | 
|
| 44 | 
    quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice),  | 
|
| 45 | 
    :remote => true,  | 
|
| 46 | 
    :method => 'post',  | 
|
| 47 | 
    :title => l(:button_quote),  | 
|
| 48 | 
    :class => 'icon-only icon-comment'  | 
|
| 49 | 
    )  | 
|
| 45 | 
    url = quoted_issue_path(issue, :journal_id => journal, :journal_indice => indice)  | 
|
| 46 | 
            links << quote_reply(url, "#journal-#{journal.id}-notes", icon_only: true)
   | 
|
| 50 | 47 | 
    end  | 
| 51 | 48 | 
    if journal.editable_by?(User.current)  | 
| 52 | 49 | 
            links << link_to(icon_with_label('edit', l(:button_edit)),
   | 
| app/helpers/messages_helper.rb | ||
|---|---|---|
| 18 | 18 | 
    # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.  | 
| 19 | 19 | |
| 20 | 20 | 
    module MessagesHelper  | 
| 21 | 
    include Redmine::QuoteReply::Helper  | 
|
| 21 | 22 | 
    end  | 
| app/views/issues/show.html.erb | ||
|---|---|---|
| 1 | 
    <% content_for :header_tags do %>  | 
|
| 2 | 
    <%= javascripts_for_quote_reply_include_tag %>  | 
|
| 3 | 
    <% end %>  | 
|
| 4 | ||
| 1 | 5 | 
    <%= render :partial => 'action_menu' %>  | 
| 2 | 6 | |
| 3 | 7 | 
    <h2 class="inline-block"><%= issue_heading(@issue) %></h2><%= issue_status_type_badge(@issue.status) %>  | 
| ... | ... | |
| 84 | 88 | 
    <hr />  | 
| 85 | 89 | 
    <div class="description">  | 
| 86 | 90 | 
    <div class="contextual">  | 
| 87 | 
      <%= link_to icon_with_label('comment', l(:button_quote)), quoted_issue_path(@issue), :remote => true, :method => 'post', :class => 'icon icon-comment ' if @issue.notes_addable? %>
   | 
|
| 91 | 
      <%= quote_reply(quoted_issue_path(@issue), '#issue_description_wiki') if @issue.notes_addable? %>
   | 
|
| 88 | 92 | 
    </div>  | 
| 89 | 93 | |
| 90 | 94 | 
    <p><strong><%=l(:field_description)%></strong></p>  | 
| 91 | 
    <div class="wiki">  | 
|
| 95 | 
      <div id="issue_description_wiki" class="wiki">
   | 
|
| 92 | 96 | 
    <%= textilizable @issue, :description, :attachments => @issue.attachments %>  | 
| 93 | 97 | 
    </div>  | 
| 94 | 98 | 
    </div>  | 
| app/views/messages/show.html.erb | ||
|---|---|---|
| 1 | 
    <% content_for :header_tags do %>  | 
|
| 2 | 
    <%= javascripts_for_quote_reply_include_tag %>  | 
|
| 3 | 
    <% end %>  | 
|
| 4 | ||
| 1 | 5 | 
    <%= board_breadcrumb(@message) %>  | 
| 2 | 6 | |
| 3 | 7 | 
    <div class="contextual">  | 
| 4 | 8 | 
    <%= watcher_link(@topic, User.current) %>  | 
| 5 | 
    <%= link_to(  | 
|
| 6 | 
              icon_with_label('comment', l(:button_quote)),
   | 
|
| 7 | 
              {:action => 'quote', :id => @topic},
   | 
|
| 8 | 
    :method => 'get',  | 
|
| 9 | 
    :class => 'icon icon-comment',  | 
|
| 10 | 
              :remote => true) if !@topic.locked? && authorize_for('messages', 'reply') %>
   | 
|
| 9 | 
    <%= quote_reply(  | 
|
| 10 | 
    url_for(:action => 'quote', :id => @topic, :format => 'js'),  | 
|
| 11 | 
    '#message_topic_wiki'  | 
|
| 12 | 
            ) if !@topic.locked? && authorize_for('messages', 'reply') %>
   | 
|
| 11 | 13 | 
    <%= link_to(  | 
| 12 | 14 | 
              icon_with_label('edit', l(:button_edit)),
   | 
| 13 | 15 | 
              {:action => 'edit', :id => @topic},
   | 
| ... | ... | |
| 26 | 28 | |
| 27 | 29 | 
    <div class="message">  | 
| 28 | 30 | 
    <p><span class="author"><%= authoring @topic.created_on, @topic.author %></span></p>  | 
| 29 | 
    <div class="wiki">  | 
|
| 31 | 
    <div id="message_topic_wiki" class="wiki">
   | 
|
| 30 | 32 | 
    <%= textilizable(@topic, :content) %>  | 
| 31 | 33 | 
    </div>  | 
| 32 | 34 | 
    <%= link_to_attachments @topic, :author => false, :thumbnails => true %>  | 
| ... | ... | |
| 42 | 44 | 
    <% @replies.each do |message| %>  | 
| 43 | 45 | 
      <div class="message reply" id="<%= "message-#{message.id}" %>">
   | 
| 44 | 46 | 
    <div class="contextual">  | 
| 45 | 
    <%= link_to(  | 
|
| 46 | 
                icon_with_label('comment', l(:button_quote), icon_only: true),
   | 
|
| 47 | 
                {:action => 'quote', :id => message},
   | 
|
| 48 | 
    :remote => true,  | 
|
| 49 | 
    :method => 'get',  | 
|
| 50 | 
    :title => l(:button_quote),  | 
|
| 51 | 
    :class => 'icon icon-comment'  | 
|
| 47 | 
    <%= quote_reply(  | 
|
| 48 | 
    url_for(:action => 'quote', :id => message, :format => 'js'),  | 
|
| 49 | 
                "#message-#{message.id} .wiki",
   | 
|
| 50 | 
    icon_only: true  | 
|
| 52 | 51 | 
              ) if !@topic.locked? && authorize_for('messages', 'reply') %>
   | 
| 53 | 52 | 
    <%= link_to(  | 
| 54 | 53 | 
                icon_with_label('edit', l(:button_edit), icon_only: true),
   | 
| lib/redmine/quote_reply.rb | ||
|---|---|---|
| 1 | 
    # frozen_string_literal: true  | 
|
| 2 | ||
| 3 | 
    # Redmine - project management software  | 
|
| 4 | 
    # Copyright (C) 2006- Jean-Philippe Lang  | 
|
| 5 | 
    #  | 
|
| 6 | 
    # This program is free software; you can redistribute it and/or  | 
|
| 7 | 
    # modify it under the terms of the GNU General Public License  | 
|
| 8 | 
    # as published by the Free Software Foundation; either version 2  | 
|
| 9 | 
    # of the License, or (at your option) any later version.  | 
|
| 10 | 
    #  | 
|
| 11 | 
    # This program is distributed in the hope that it will be useful,  | 
|
| 12 | 
    # but WITHOUT ANY WARRANTY; without even the implied warranty of  | 
|
| 13 | 
    # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the  | 
|
| 14 | 
    # GNU General Public License for more details.  | 
|
| 15 | 
    #  | 
|
| 16 | 
    # You should have received a copy of the GNU General Public License  | 
|
| 17 | 
    # along with this program; if not, write to the Free Software  | 
|
| 18 | 
    # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.  | 
|
| 19 | ||
| 20 | 
    module Redmine  | 
|
| 21 | 
    module QuoteReply  | 
|
| 22 | 
    module Helper  | 
|
| 23 | 
    def javascripts_for_quote_reply_include_tag  | 
|
| 24 | 
    javascript_include_tag 'turndown-7.2.0.min', 'quote_reply'  | 
|
| 25 | 
    end  | 
|
| 26 | ||
| 27 | 
    def quote_reply(url, selector_for_content, icon_only: false)  | 
|
| 28 | 
            quote_reply_function = "quoteReply('#{j url}', '#{j selector_for_content}', '#{j Setting.text_formatting}')"
   | 
|
| 29 | ||
| 30 | 
            html_options = { class: 'icon icon-comment' }
   | 
|
| 31 | 
    html_options[:title] = l(:button_quote) if icon_only  | 
|
| 32 | ||
| 33 | 
    link_to_function(  | 
|
| 34 | 
              icon_with_label('comment', l(:button_quote), icon_only: icon_only),
   | 
|
| 35 | 
    quote_reply_function,  | 
|
| 36 | 
    html_options  | 
|
| 37 | 
    )  | 
|
| 38 | 
    end  | 
|
| 39 | 
    end  | 
|
| 40 | ||
| 41 | 
    module Builder  | 
|
| 42 | 
    def quote_issue(issue, partial_quote: nil)  | 
|
| 43 | 
    user = issue.author  | 
|
| 44 | ||
| 45 | 
    build_quote(  | 
|
| 46 | 
              "#{ll(Setting.default_language, :text_user_wrote, user)}\n> ",
   | 
|
| 47 | 
    issue.description,  | 
|
| 48 | 
    partial_quote  | 
|
| 49 | 
    )  | 
|
| 50 | 
    end  | 
|
| 51 | ||
| 52 | 
    def quote_issue_journal(journal, indice:, partial_quote: nil)  | 
|
| 53 | 
    user = journal.user  | 
|
| 54 | ||
| 55 | 
    build_quote(  | 
|
| 56 | 
              "#{ll(Setting.default_language, :text_user_wrote_in, {value: journal.user, link: "#note-#{indice}"})}\n> ",
   | 
|
| 57 | 
    journal.notes,  | 
|
| 58 | 
    partial_quote  | 
|
| 59 | 
    )  | 
|
| 60 | 
    end  | 
|
| 61 | ||
| 62 | 
    def quote_root_message(message, partial_quote: nil)  | 
|
| 63 | 
    build_quote(  | 
|
| 64 | 
              "#{ll(Setting.default_language, :text_user_wrote, message.author)}\n> ",
   | 
|
| 65 | 
    message.content,  | 
|
| 66 | 
    partial_quote  | 
|
| 67 | 
    )  | 
|
| 68 | 
    end  | 
|
| 69 | ||
| 70 | 
    def quote_message(message, partial_quote: nil)  | 
|
| 71 | 
    build_quote(  | 
|
| 72 | 
              "#{ll(Setting.default_language, :text_user_wrote_in, {value: message.author, link: "message##{message.id}"})}\n> ",
   | 
|
| 73 | 
    message.content,  | 
|
| 74 | 
    partial_quote  | 
|
| 75 | 
    )  | 
|
| 76 | 
    end  | 
|
| 77 | ||
| 78 | 
    private  | 
|
| 79 | ||
| 80 | 
    def build_quote(quote_header, text, partial_quote = nil)  | 
|
| 81 | 
            quote_text = partial_quote.presence || text.to_s.strip.gsub(%r{<pre>(.*?)</pre>}m, '[...]')
   | 
|
| 82 | ||
| 83 | 
            "#{quote_header}#{quote_text.gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"}"
   | 
|
| 84 | 
    end  | 
|
| 85 | 
    end  | 
|
| 86 | 
    end  | 
|
| 87 | 
    end  | 
|
| test/functional/journals_controller_test.rb | ||
|---|---|---|
| 168 | 168 | |
| 169 | 169 | 
    def test_reply_to_issue  | 
| 170 | 170 | 
    @request.session[:user_id] = 2  | 
| 171 | 
        get(:new, :params => {:id => 6}, :xhr => true)
   | 
|
| 171 | 
        post(:new, :params => {:id => 6}, :xhr => true)
   | 
|
| 172 | 172 | 
    assert_response :success  | 
| 173 | 173 | |
| 174 | 174 | 
    assert_equal 'text/javascript', response.media_type  | 
| ... | ... | |
| 177 | 177 | |
| 178 | 178 | 
    def test_reply_to_issue_without_permission  | 
| 179 | 179 | 
    @request.session[:user_id] = 7  | 
| 180 | 
        get(:new, :params => {:id => 6}, :xhr => true)
   | 
|
| 180 | 
        post(:new, :params => {:id => 6}, :xhr => true)
   | 
|
| 181 | 181 | 
    assert_response :forbidden  | 
| 182 | 182 | 
    end  | 
| 183 | 183 | |
| 184 | 184 | 
    def test_reply_to_note  | 
| 185 | 185 | 
    @request.session[:user_id] = 2  | 
| 186 | 
        get(
   | 
|
| 186 | 
        post(
   | 
|
| 187 | 187 | 
    :new,  | 
| 188 | 188 | 
          :params => {
   | 
| 189 | 189 | 
    :id => 6,  | 
| ... | ... | |
| 202 | 202 | 
    journal = Journal.create!(:journalized => Issue.find(2), :notes => 'Privates notes', :private_notes => true)  | 
| 203 | 203 | 
    @request.session[:user_id] = 2  | 
| 204 | 204 | |
| 205 | 
        get(
   | 
|
| 205 | 
        post(
   | 
|
| 206 | 206 | 
    :new,  | 
| 207 | 207 | 
          :params => {
   | 
| 208 | 208 | 
    :id => 2,  | 
| ... | ... | |
| 215 | 215 | 
    assert_include '> Privates notes', response.body  | 
| 216 | 216 | |
| 217 | 217 | 
    Role.find(1).remove_permission! :view_private_notes  | 
| 218 | 
        get(
   | 
|
| 218 | 
        post(
   | 
|
| 219 | 219 | 
    :new,  | 
| 220 | 220 | 
          :params => {
   | 
| 221 | 221 | 
    :id => 2,  | 
| ... | ... | |
| 226 | 226 | 
    assert_response :not_found  | 
| 227 | 227 | 
    end  | 
| 228 | 228 | |
| 229 | 
    def test_reply_to_issue_with_partial_quote  | 
|
| 230 | 
    @request.session[:user_id] = 2  | 
|
| 231 | ||
| 232 | 
        params = { id: 6, quote: 'a private subproject of cookbook' }
   | 
|
| 233 | 
    post :new, params: params, xhr: true  | 
|
| 234 | ||
| 235 | 
    assert_response :success  | 
|
| 236 | 
    assert_equal 'text/javascript', response.media_type  | 
|
| 237 | 
    assert_include 'John Smith wrote:', response.body  | 
|
| 238 | 
    assert_include '> a private subproject of cookbook', response.body  | 
|
| 239 | 
    end  | 
|
| 240 | ||
| 241 | 
    def test_reply_to_note_with_partial_quote  | 
|
| 242 | 
    @request.session[:user_id] = 2  | 
|
| 243 | ||
| 244 | 
        params = { id: 6, journal_id: 4, journal_indice: 1, quote: 'a private version' }
   | 
|
| 245 | 
    post :new, params: params, xhr: true  | 
|
| 246 | ||
| 247 | 
    assert_response :success  | 
|
| 248 | 
    assert_equal 'text/javascript', response.media_type  | 
|
| 249 | 
    assert_include 'Redmine Admin wrote in #note-1:', response.body  | 
|
| 250 | 
    assert_include '> a private version', response.body  | 
|
| 251 | 
    end  | 
|
| 252 | ||
| 229 | 253 | 
    def test_edit_xhr  | 
| 230 | 254 | 
    @request.session[:user_id] = 1  | 
| 231 | 255 | 
        get(:edit, :params => {:id => 2}, :xhr => true)
   | 
| test/functional/messages_controller_test.rb | ||
|---|---|---|
| 288 | 288 | |
| 289 | 289 | 
    def test_quote_if_message_is_root  | 
| 290 | 290 | 
    @request.session[:user_id] = 2  | 
| 291 | 
        get(
   | 
|
| 291 | 
        post(
   | 
|
| 292 | 292 | 
    :quote,  | 
| 293 | 293 | 
          :params => {
   | 
| 294 | 294 | 
    :board_id => 1,  | 
| ... | ... | |
| 306 | 306 | |
| 307 | 307 | 
    def test_quote_if_message_is_not_root  | 
| 308 | 308 | 
    @request.session[:user_id] = 2  | 
| 309 | 
        get(
   | 
|
| 309 | 
        post(
   | 
|
| 310 | 310 | 
    :quote,  | 
| 311 | 311 | 
          :params => {
   | 
| 312 | 312 | 
    :board_id => 1,  | 
| ... | ... | |
| 322 | 322 | 
    assert_include '> An other reply', response.body  | 
| 323 | 323 | 
    end  | 
| 324 | 324 | |
| 325 | 
    def test_quote_with_partial_quote_if_message_is_root  | 
|
| 326 | 
    @request.session[:user_id] = 2  | 
|
| 327 | ||
| 328 | 
        params = { board_id: 1, id: 1,
   | 
|
| 329 | 
    quote: "the very first post\nin the forum" }  | 
|
| 330 | 
    post :quote, params: params, xhr: true  | 
|
| 331 | ||
| 332 | 
    assert_response :success  | 
|
| 333 | 
    assert_equal 'text/javascript', response.media_type  | 
|
| 334 | ||
| 335 | 
    assert_include 'RE: First post', response.body  | 
|
| 336 | 
    assert_include "Redmine Admin wrote:", response.body  | 
|
| 337 | 
    assert_include '> the very first post\n> in the forum', response.body  | 
|
| 338 | 
    end  | 
|
| 339 | ||
| 340 | 
    def test_quote_with_partial_quote_if_message_is_not_root  | 
|
| 341 | 
    @request.session[:user_id] = 2  | 
|
| 342 | ||
| 343 | 
        params = { board_id: 1, id: 3, quote: 'other reply' }
   | 
|
| 344 | 
    post :quote, params: params, xhr: true  | 
|
| 345 | ||
| 346 | 
    assert_response :success  | 
|
| 347 | 
    assert_equal 'text/javascript', response.media_type  | 
|
| 348 | ||
| 349 | 
    assert_include 'RE: First post', response.body  | 
|
| 350 | 
    assert_include 'John Smith wrote in message#3:', response.body  | 
|
| 351 | 
    assert_include '> other reply', response.body  | 
|
| 352 | 
    end  | 
|
| 353 | ||
| 325 | 354 | 
    def test_quote_as_html_should_respond_with_404  | 
| 326 | 355 | 
    @request.session[:user_id] = 2  | 
| 327 | 
        get(
   | 
|
| 356 | 
        post(
   | 
|
| 328 | 357 | 
    :quote,  | 
| 329 | 358 | 
          :params => {
   | 
| 330 | 359 | 
    :board_id => 1,  | 
| test/helpers/journals_helper_test.rb | ||
|---|---|---|
| 57 | 57 | 
    journals = issue.visible_journals_with_index # add indice  | 
| 58 | 58 | 
        journal_actions = render_journal_actions(issue, journals.first, {reply_links: true})
   | 
| 59 | 59 | |
| 60 | 
        assert_select_in journal_actions, 'a[title=?][class="icon-only icon-comment"]', 'Quote'
   | 
|
| 60 | 
    assert_select_in journal_actions, 'a[title=?][class="icon icon-comment"]', 'Quote'  | 
|
| 61 | 61 | 
    assert_select_in journal_actions, 'a[title=?][class="icon-only icon-edit"]', 'Edit'  | 
| 62 | 62 | 
    assert_select_in journal_actions, 'div[class="drdn-items"] a[class="icon icon-del"]'  | 
| 63 | 63 | 
    assert_select_in journal_actions, 'div[class="drdn-items"] a[class="icon icon-copy-link"]'  | 
| test/system/issues_reply_test.rb | ||
|---|---|---|
| 1 | 
    # frozen_string_literal: true  | 
|
| 2 | ||
| 3 | 
    # Redmine - project management software  | 
|
| 4 | 
    # Copyright (C) 2006- Jean-Philippe Lang  | 
|
| 5 | 
    #  | 
|
| 6 | 
    # This program is free software; you can redistribute it and/or  | 
|
| 7 | 
    # modify it under the terms of the GNU General Public License  | 
|
| 8 | 
    # as published by the Free Software Foundation; either version 2  | 
|
| 9 | 
    # of the License, or (at your option) any later version.  | 
|
| 10 | 
    #  | 
|
| 11 | 
    # This program is distributed in the hope that it will be useful,  | 
|
| 12 | 
    # but WITHOUT ANY WARRANTY; without even the implied warranty of  | 
|
| 13 | 
    # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the  | 
|
| 14 | 
    # GNU General Public License for more details.  | 
|
| 15 | 
    #  | 
|
| 16 | 
    # You should have received a copy of the GNU General Public License  | 
|
| 17 | 
    # along with this program; if not, write to the Free Software  | 
|
| 18 | 
    # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.  | 
|
| 19 | ||
| 20 | 
    require_relative '../application_system_test_case'  | 
|
| 21 | ||
| 22 | 
    class IssuesReplyTest < ApplicationSystemTestCase  | 
|
| 23 | 
    fixtures :projects, :users, :email_addresses, :roles, :members, :member_roles,  | 
|
| 24 | 
    :trackers, :projects_trackers, :enabled_modules,  | 
|
| 25 | 
    :issue_statuses, :issues, :issue_categories,  | 
|
| 26 | 
    :enumerations, :custom_fields, :custom_values, :custom_fields_trackers,  | 
|
| 27 | 
    :watchers, :journals, :journal_details, :versions,  | 
|
| 28 | 
    :workflows  | 
|
| 29 | ||
| 30 | 
    def test_reply_to_issue  | 
|
| 31 | 
    with_text_formatting 'common_mark' do  | 
|
| 32 | 
    within '.issue.details' do  | 
|
| 33 | 
    click_link 'Quote'  | 
|
| 34 | 
    end  | 
|
| 35 | ||
| 36 | 
    # Select the other than the issue description element.  | 
|
| 37 | 
    page.execute_script <<-JS  | 
|
| 38 | 
    const range = document.createRange();  | 
|
| 39 | 
    // Select "Description" text.  | 
|
| 40 | 
            range.selectNodeContents(document.querySelector('.description > p'))
   | 
|
| 41 | ||
| 42 | 
    window.getSelection().addRange(range);  | 
|
| 43 | 
    JS  | 
|
| 44 | ||
| 45 | 
    assert_field 'issue_notes', with: <<~TEXT  | 
|
| 46 | 
    John Smith wrote:  | 
|
| 47 | 
    > Unable to print recipes  | 
|
| 48 | ||
| 49 | 
    TEXT  | 
|
| 50 | 
    assert_selector :css, '#issue_notes:focus'  | 
|
| 51 | 
    end  | 
|
| 52 | 
    end  | 
|
| 53 | ||
| 54 | 
    def test_reply_to_note  | 
|
| 55 | 
    with_text_formatting 'textile' do  | 
|
| 56 | 
    within '#change-1' do  | 
|
| 57 | 
    click_link 'Quote'  | 
|
| 58 | 
    end  | 
|
| 59 | ||
| 60 | 
    assert_field 'issue_notes', with: <<~TEXT  | 
|
| 61 | 
    Redmine Admin wrote in #note-1:  | 
|
| 62 | 
    > Journal notes  | 
|
| 63 | ||
| 64 | 
    TEXT  | 
|
| 65 | 
    assert_selector :css, '#issue_notes:focus'  | 
|
| 66 | 
    end  | 
|
| 67 | 
    end  | 
|
| 68 | ||
| 69 | 
    def test_reply_to_issue_with_partial_quote  | 
|
| 70 | 
    with_text_formatting 'common_mark' do  | 
|
| 71 | 
    assert_text 'Unable to print recipes'  | 
|
| 72 | ||
| 73 | 
    # Select only the "print" text from the text "Unable to print recipes" in the description.  | 
|
| 74 | 
    page.execute_script <<-JS  | 
|
| 75 | 
    const range = document.createRange();  | 
|
| 76 | 
            const wiki = document.querySelector('#issue_description_wiki > p').childNodes[0];
   | 
|
| 77 | 
    range.setStart(wiki, 10);  | 
|
| 78 | 
    range.setEnd(wiki, 15);  | 
|
| 79 | ||
| 80 | 
    window.getSelection().addRange(range);  | 
|
| 81 | 
    JS  | 
|
| 82 | ||
| 83 | 
    within '.issue.details' do  | 
|
| 84 | 
    click_link 'Quote'  | 
|
| 85 | 
    end  | 
|
| 86 | ||
| 87 | 
    assert_field 'issue_notes', with: <<~TEXT  | 
|
| 88 | 
    John Smith wrote:  | 
|
| 89 | 
     | 
|
| 90 | ||
| 91 | 
    TEXT  | 
|
| 92 | 
    assert_selector :css, '#issue_notes:focus'  | 
|
| 93 | 
    end  | 
|
| 94 | 
    end  | 
|
| 95 | ||
| 96 | 
    def test_reply_to_note_with_partial_quote  | 
|
| 97 | 
    with_text_formatting 'textile' do  | 
|
| 98 | 
    assert_text 'Journal notes'  | 
|
| 99 | ||
| 100 | 
    # Select the entire details of the note#1 and the part of the note#1's text.  | 
|
| 101 | 
    page.execute_script <<-JS  | 
|
| 102 | 
    const range = document.createRange();  | 
|
| 103 | 
            range.setStartBefore(document.querySelector('#change-1 .details'));
   | 
|
| 104 | 
    // Select only the text "Journal" from the text "Journal notes" in the note-1.  | 
|
| 105 | 
            range.setEnd(document.querySelector('#change-1 .wiki > p').childNodes[0], 7);
   | 
|
| 106 | ||
| 107 | 
    window.getSelection().addRange(range);  | 
|
| 108 | 
    JS  | 
|
| 109 | ||
| 110 | 
    within '#change-1' do  | 
|
| 111 | 
    click_link 'Quote'  | 
|
| 112 | 
    end  | 
|
| 113 | ||
| 114 | 
    assert_field 'issue_notes', with: <<~TEXT  | 
|
| 115 | 
    Redmine Admin wrote in #note-1:  | 
|
| 116 | 
    > Journal  | 
|
| 117 | ||
| 118 | 
    TEXT  | 
|
| 119 | 
    assert_selector :css, '#issue_notes:focus'  | 
|
| 120 | 
    end  | 
|
| 121 | 
    end  | 
|
| 122 | ||
| 123 | 
    def test_partial_quotes_should_be_quoted_in_plain_text_when_text_format_is_textile  | 
|
| 124 | 
    issues(:issues_001).update!(description: <<~DESC)  | 
|
| 125 | 
    # "Redmine":https://redmine.org is  | 
|
| 126 | 
    # a *flexible* project management  | 
|
| 127 | 
    # web application.  | 
|
| 128 | 
    DESC  | 
|
| 129 | ||
| 130 | 
    with_text_formatting 'textile' do  | 
|
| 131 | 
    assert_text /a flexible project management/  | 
|
| 132 | ||
| 133 | 
    # Select the entire description of the issue.  | 
|
| 134 | 
    page.execute_script <<-JS  | 
|
| 135 | 
    const range = document.createRange();  | 
|
| 136 | 
            range.selectNodeContents(document.querySelector('#issue_description_wiki'))
   | 
|
| 137 | 
    window.getSelection().addRange(range);  | 
|
| 138 | 
    JS  | 
|
| 139 | ||
| 140 | 
    within '.issue.details' do  | 
|
| 141 | 
    click_link 'Quote'  | 
|
| 142 | 
    end  | 
|
| 143 | ||
| 144 | 
    expected_value = [  | 
|
| 145 | 
    'John Smith wrote:',  | 
|
| 146 | 
    '> Redmine is',  | 
|
| 147 | 
    '> a flexible project management',  | 
|
| 148 | 
    '> web application.',  | 
|
| 149 | 
    '',  | 
|
| 150 | 
    ''  | 
|
| 151 | 
    ]  | 
|
| 152 | 
          assert_equal expected_value.join("\n"), find_field('issue_notes').value
   | 
|
| 153 | 
    end  | 
|
| 154 | 
    end  | 
|
| 155 | ||
| 156 | 
    def test_partial_quotes_should_be_quoted_in_common_mark_format_when_text_format_is_common_mark  | 
|
| 157 | 
    issues(:issues_001).update!(description: <<~DESC)  | 
|
| 158 | 
    # Title1  | 
|
| 159 | 
    [Redmine](https://redmine.org) is a **flexible** project management web application.  | 
|
| 160 | ||
| 161 | 
    ## Title2  | 
|
| 162 | 
    * List1  | 
|
| 163 | 
    * List1-1  | 
|
| 164 | 
    * List2  | 
|
| 165 | ||
| 166 | 
    1. Number1  | 
|
| 167 | 
    1. Number2  | 
|
| 168 | ||
| 169 | 
    ### Title3  | 
|
| 170 | 
    ```ruby  | 
|
| 171 | 
    puts "Hello, world!"  | 
|
| 172 | 
    ```  | 
|
| 173 | 
    ```  | 
|
| 174 | 
    $ bin/rails db:migrate  | 
|
| 175 | 
    ```  | 
|
| 176 | ||
| 177 | 
    | Subject1 | Subject2 |  | 
|
| 178 | 
    | -------- | -------- |  | 
|
| 179 | 
    | ~~cell1~~| **cell2**|  | 
|
| 180 | ||
| 181 | 
    * [ ] Checklist1  | 
|
| 182 | 
    * [x] Checklist2  | 
|
| 183 | ||
| 184 | 
    [[WikiPage]]  | 
|
| 185 | 
    Issue #14  | 
|
| 186 | 
    Issue ##2  | 
|
| 187 | ||
| 188 | 
    Redmine is `a flexible` project management  | 
|
| 189 | ||
| 190 | 
    web application.  | 
|
| 191 | 
    DESC  | 
|
| 192 | ||
| 193 | 
    with_text_formatting 'common_mark' do  | 
|
| 194 | 
    assert_text /Title1/  | 
|
| 195 | ||
| 196 | 
    # Select the entire description of the issue.  | 
|
| 197 | 
    page.execute_script <<-JS  | 
|
| 198 | 
    const range = document.createRange();  | 
|
| 199 | 
            range.selectNodeContents(document.querySelector('#issue_description_wiki'))
   | 
|
| 200 | 
    window.getSelection().addRange(range);  | 
|
| 201 | 
    JS  | 
|
| 202 | ||
| 203 | 
    within '.issue.details' do  | 
|
| 204 | 
    click_link 'Quote'  | 
|
| 205 | 
    end  | 
|
| 206 | ||
| 207 | 
    expected_value = [  | 
|
| 208 | 
    'John Smith wrote:',  | 
|
| 209 | 
    '> # Title1',  | 
|
| 210 | 
    '> ',  | 
|
| 211 | 
    '> [Redmine](https://redmine.org) is a **flexible** project management web application.',  | 
|
| 212 | 
    '> ',  | 
|
| 213 | 
    '> ## Title2',  | 
|
| 214 | 
    '> ',  | 
|
| 215 | 
    '> * List1',  | 
|
| 216 | 
    '> * List1-1',  | 
|
| 217 | 
    '> * List2',  | 
|
| 218 | 
    '> ',  | 
|
| 219 | 
    '> 1. Number1',  | 
|
| 220 | 
    '> 2. Number2',  | 
|
| 221 | 
    '> ',  | 
|
| 222 | 
    '> ### Title3',  | 
|
| 223 | 
    '> ',  | 
|
| 224 | 
    '> ```ruby',  | 
|
| 225 | 
    '> puts "Hello, world!"',  | 
|
| 226 | 
    '> ```',  | 
|
| 227 | 
    '> ',  | 
|
| 228 | 
    '> ```',  | 
|
| 229 | 
    '> $ bin/rails db:migrate',  | 
|
| 230 | 
    '> ```',  | 
|
| 231 | 
    '> ',  | 
|
| 232 | 
    '> Subject1 Subject2',  | 
|
| 233 | 
    '> ~~cell1~~ **cell2**',  | 
|
| 234 | 
    '> ',  | 
|
| 235 | 
    '> * [ ] Checklist1',  | 
|
| 236 | 
    '> * [x] Checklist2',  | 
|
| 237 | 
    '> ',  | 
|
| 238 | 
    '> [WikiPage](/projects/ecookbook/wiki/WikiPage) ',  | 
|
| 239 | 
    '> Issue [#14](/issues/14 "Bug: Private issue on public project (New)") ',  | 
|
| 240 | 
    '> Issue [Feature request #2: Add ingredients categories](/issues/2 "Status: Assigned")',  | 
|
| 241 | 
    '> ',  | 
|
| 242 | 
    '> Redmine is `a flexible` project management',  | 
|
| 243 | 
    '> ',  | 
|
| 244 | 
    '> web application.',  | 
|
| 245 | 
    '',  | 
|
| 246 | 
    ''  | 
|
| 247 | 
    ]  | 
|
| 248 | 
          assert_equal expected_value.join("\n"), find_field('issue_notes').value
   | 
|
| 249 | 
    end  | 
|
| 250 | 
    end  | 
|
| 251 | ||
| 252 | 
    private  | 
|
| 253 | ||
| 254 | 
    def with_text_formatting(format)  | 
|
| 255 | 
    with_settings text_formatting: format do  | 
|
| 256 | 
          log_user('jsmith', 'jsmith')
   | 
|
| 257 | 
    visit '/issues/1'  | 
|
| 258 | ||
| 259 | 
    yield  | 
|
| 260 | 
    end  | 
|
| 261 | 
    end  | 
|
| 262 | 
    end  | 
|
| test/system/messages_test.rb | ||
|---|---|---|
| 1 | 
    # frozen_string_literal: true  | 
|
| 2 | ||
| 3 | 
    # Redmine - project management software  | 
|
| 4 | 
    # Copyright (C) 2006- Jean-Philippe Lang  | 
|
| 5 | 
    #  | 
|
| 6 | 
    # This program is free software; you can redistribute it and/or  | 
|
| 7 | 
    # modify it under the terms of the GNU General Public License  | 
|
| 8 | 
    # as published by the Free Software Foundation; either version 2  | 
|
| 9 | 
    # of the License, or (at your option) any later version.  | 
|
| 10 | 
    #  | 
|
| 11 | 
    # This program is distributed in the hope that it will be useful,  | 
|
| 12 | 
    # but WITHOUT ANY WARRANTY; without even the implied warranty of  | 
|
| 13 | 
    # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the  | 
|
| 14 | 
    # GNU General Public License for more details.  | 
|
| 15 | 
    #  | 
|
| 16 | 
    # You should have received a copy of the GNU General Public License  | 
|
| 17 | 
    # along with this program; if not, write to the Free Software  | 
|
| 18 | 
    # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.  | 
|
| 19 | ||
| 20 | 
    require_relative '../application_system_test_case'  | 
|
| 21 | ||
| 22 | 
    class MessagesTest < ApplicationSystemTestCase  | 
|
| 23 | 
    fixtures :projects, :users, :roles, :members, :member_roles,  | 
|
| 24 | 
    :enabled_modules, :enumerations,  | 
|
| 25 | 
    :custom_fields, :custom_values, :custom_fields_trackers,  | 
|
| 26 | 
    :watchers, :boards, :messages  | 
|
| 27 | ||
| 28 | 
    def test_reply_to_topic_message  | 
|
| 29 | 
    with_text_formatting 'common_mark' do  | 
|
| 30 | 
    within '#content > .contextual' do  | 
|
| 31 | 
    click_link 'Quote'  | 
|
| 32 | 
    end  | 
|
| 33 | ||
| 34 | 
    assert_field 'message_content', with: <<~TEXT  | 
|
| 35 | 
    Redmine Admin wrote:  | 
|
| 36 | 
    > This is the very first post  | 
|
| 37 | 
    > in the forum  | 
|
| 38 | ||
| 39 | 
    TEXT  | 
|
| 40 | 
    end  | 
|
| 41 | 
    end  | 
|
| 42 | ||
| 43 | 
    def test_reply_to_message  | 
|
| 44 | 
    with_text_formatting 'textile' do  | 
|
| 45 | 
    within '#message-2' do  | 
|
| 46 | 
    click_link 'Quote'  | 
|
| 47 | 
    end  | 
|
| 48 | ||
| 49 | 
    assert_field 'message_content', with: <<~TEXT  | 
|
| 50 | 
    Redmine Admin wrote in message#2:  | 
|
| 51 | 
    > Reply to the first post  | 
|
| 52 | ||
| 53 | 
    TEXT  | 
|
| 54 | 
    end  | 
|
| 55 | 
    end  | 
|
| 56 | ||
| 57 | 
    def test_reply_to_topic_message_with_partial_quote  | 
|
| 58 | 
    with_text_formatting 'textile' do  | 
|
| 59 | 
    assert_text /This is the very first post/  | 
|
| 60 | ||
| 61 | 
    # Select the part of the topic message through the entire text of the attachment below it.  | 
|
| 62 | 
    page.execute_script <<-'JS'  | 
|
| 63 | 
    const range = document.createRange();  | 
|
| 64 | 
            const message = document.querySelector('#message_topic_wiki');
   | 
|
| 65 | 
    // Select only the text "in the forum" from the text "This is the very first post\nin the forum".  | 
|
| 66 | 
            range.setStartBefore(message.querySelector('p').childNodes[2]);
   | 
|
| 67 | 
            range.setEndAfter(message.parentNode.querySelector('.attachments'));
   | 
|
| 68 | ||
| 69 | 
    window.getSelection().addRange(range);  | 
|
| 70 | 
    JS  | 
|
| 71 | ||
| 72 | 
    within '#content > .contextual' do  | 
|
| 73 | 
    click_link 'Quote'  | 
|
| 74 | 
    end  | 
|
| 75 | ||
| 76 | 
    assert_field 'message_content', with: <<~TEXT  | 
|
| 77 | 
    Redmine Admin wrote:  | 
|
| 78 | 
    > in the forum  | 
|
| 79 | ||
| 80 | 
    TEXT  | 
|
| 81 | 
    end  | 
|
| 82 | 
    end  | 
|
| 83 | ||
| 84 | 
    def test_reply_to_message_with_partial_quote  | 
|
| 85 | 
    with_text_formatting 'common_mark' do  | 
|
| 86 | 
    assert_text 'Reply to the first post'  | 
|
| 87 | ||
| 88 | 
    # Select the entire message, including the subject and headers of messages #2 and #3.  | 
|
| 89 | 
    page.execute_script <<-JS  | 
|
| 90 | 
    const range = document.createRange();  | 
|
| 91 | 
            range.setStartBefore(document.querySelector('#message-2'));
   | 
|
| 92 | 
            range.setEndAfter(document.querySelector('#message-3'));
   | 
|
| 93 | ||
| 94 | 
    window.getSelection().addRange(range);  | 
|
| 95 | 
    JS  | 
|
| 96 | ||
| 97 | 
    within '#message-2' do  | 
|
| 98 | 
    click_link 'Quote'  | 
|
| 99 | 
    end  | 
|
| 100 | ||
| 101 | 
    assert_field 'message_content', with: <<~TEXT  | 
|
| 102 | 
    Redmine Admin wrote in message#2:  | 
|
| 103 | 
    > Reply to the first post  | 
|
| 104 | ||
| 105 | 
    TEXT  | 
|
| 106 | 
    end  | 
|
| 107 | 
    end  | 
|
| 108 | ||
| 109 | 
    private  | 
|
| 110 | ||
| 111 | 
    def with_text_formatting(format)  | 
|
| 112 | 
    with_settings text_formatting: format do  | 
|
| 113 | 
          log_user('jsmith', 'jsmith')
   | 
|
| 114 | 
    visit '/boards/1/topics/1'  | 
|
| 115 | ||
| 116 | 
    yield  | 
|
| 117 | 
    end  | 
|
| 118 | 
    end  | 
|
| 119 | 
    end  | 
|
| test/unit/lib/redmine/quote_reply_helper_test.rb | ||
|---|---|---|
| 1 | 
    # frozen_string_literal: true  | 
|
| 2 | ||
| 3 | 
    # Redmine - project management software  | 
|
| 4 | 
    # Copyright (C) 2006- Jean-Philippe Lang  | 
|
| 5 | 
    #  | 
|
| 6 | 
    # This program is free software; you can redistribute it and/or  | 
|
| 7 | 
    # modify it under the terms of the GNU General Public License  | 
|
| 8 | 
    # as published by the Free Software Foundation; either version 2  | 
|
| 9 | 
    # of the License, or (at your option) any later version.  | 
|
| 10 | 
    #  | 
|
| 11 | 
    # This program is distributed in the hope that it will be useful,  | 
|
| 12 | 
    # but WITHOUT ANY WARRANTY; without even the implied warranty of  | 
|
| 13 | 
    # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the  | 
|
| 14 | 
    # GNU General Public License for more details.  | 
|
| 15 | 
    #  | 
|
| 16 | 
    # You should have received a copy of the GNU General Public License  | 
|
| 17 | 
    # along with this program; if not, write to the Free Software  | 
|
| 18 | 
    # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.  | 
|
| 19 | ||
| 20 | 
    require_relative '../../../test_helper'  | 
|
| 21 | ||
| 22 | 
    class QuoteReplyHelperTest < ActionView::TestCase  | 
|
| 23 | 
    include ERB::Util  | 
|
| 24 | 
    include Redmine::QuoteReply::Helper  | 
|
| 25 | ||
| 26 | 
    fixtures :issues  | 
|
| 27 | ||
| 28 | 
    def test_quote_reply  | 
|
| 29 | 
    url = quoted_issue_path(issues(:issues_001))  | 
|
| 30 | ||
| 31 | 
    a_tag = quote_reply(url, '#issue_description_wiki')  | 
|
| 32 | 
        assert_includes a_tag, %|onclick="#{h "quoteReply('/issues/1/quoted', '#issue_description_wiki', 'common_mark'); return false;"}"|
   | 
|
| 33 | 
    assert_includes a_tag, %|class="icon icon-comment"|  | 
|
| 34 | 
    assert_not_includes a_tag, 'title='  | 
|
| 35 | ||
| 36 | 
    # When icon_only is true  | 
|
| 37 | 
    a_tag = quote_reply(url, '#issue_description_wiki', icon_only: true)  | 
|
| 38 | 
    assert_includes a_tag, %|title="Quote"|  | 
|
| 39 | 
    end  | 
|
| 40 | 
    end  | 
|