diff --git a/addons/web/static/lib/ace/mode-less.js b/addons/web/static/lib/ace/mode-less.js new file mode 100644 index 00000000000..8ee94129b2b --- /dev/null +++ b/addons/web/static/lib/ace/mode-less.js @@ -0,0 +1,206 @@ +define("ace/mode/less", ["require", "exports", "module", "ace/lib/oop", "ace/mode/text", "ace/tokenizer", "ace/mode/less_highlight_rules", "ace/mode/matching_brace_outdent", "ace/mode/folding/cstyle"], function(e, t) { + var r = e("../lib/oop"), + o = e("./text").Mode, + n = e("../tokenizer").Tokenizer, + i = e("./less_highlight_rules").LessHighlightRules, + a = e("./matching_brace_outdent").MatchingBraceOutdent, + l = e("./folding/cstyle").FoldMode, + s = function() { + this.$tokenizer = new n((new i).getRules(), "i"), this.$outdent = new a, this.foldingRules = new l + }; + r.inherits(s, o), + function() { + this.getNextLineIndent = function(e, t, r) { + var o = this.$getIndent(t), + n = this.$tokenizer.getLineTokens(t, e).tokens; + if (n.length && "comment" == n[n.length - 1].type) return o; + var i = t.match(/^.*\{\s*$/); + return i && (o += r), o + }, this.checkOutdent = function(e, t, r) { + return this.$outdent.checkOutdent(t, r) + }, this.autoOutdent = function(e, t, r) { + this.$outdent.autoOutdent(t, r) + } + }.call(s.prototype), t.Mode = s +}), define("ace/mode/less_highlight_rules", ["require", "exports", "module", "ace/lib/oop", "ace/lib/lang", "ace/mode/text_highlight_rules"], function(e, t) { + var r = e("../lib/oop"), + o = e("../lib/lang"), + n = e("./text_highlight_rules").TextHighlightRules, + i = function() { + var e = o.arrayToMap(function() { + for (var e = "-webkit-|-moz-|-o-|-ms-|-svg-|-pie-|-khtml-".split("|"), t = "appearance|background-clip|background-inline-policy|background-origin|background-size|binding|border-bottom-colors|border-left-colors|border-right-colors|border-top-colors|border-end|border-end-color|border-end-style|border-end-width|border-image|border-start|border-start-color|border-start-style|border-start-width|box-align|box-direction|box-flex|box-flexgroup|box-ordinal-group|box-orient|box-pack|box-sizing|column-count|column-gap|column-width|column-rule|column-rule-width|column-rule-style|column-rule-color|float-edge|font-feature-settings|font-language-override|force-broken-image-icon|image-region|margin-end|margin-start|opacity|outline|outline-color|outline-offset|outline-radius|outline-radius-bottomleft|outline-radius-bottomright|outline-radius-topleft|outline-radius-topright|outline-style|outline-width|padding-end|padding-start|stack-sizing|tab-size|text-blink|text-decoration-color|text-decoration-line|text-decoration-style|transform|transform-origin|transition|transition-delay|transition-duration|transition-property|transition-timing-function|user-focus|user-input|user-modify|user-select|window-shadow|border-radius".split("|"), r = "azimuth|background-attachment|background-color|background-image|background-position|background-repeat|background|border-bottom-color|border-bottom-style|border-bottom-width|border-bottom|border-collapse|border-color|border-left-color|border-left-style|border-left-width|border-left|border-right-color|border-right-style|border-right-width|border-right|border-spacing|border-style|border-top-color|border-top-style|border-top-width|border-top|border-width|border|bottom|box-sizing|caption-side|clear|clip|color|content|counter-increment|counter-reset|cue-after|cue-before|cue|cursor|direction|display|elevation|empty-cells|float|font-family|font-size-adjust|font-size|font-stretch|font-style|font-variant|font-weight|font|height|left|letter-spacing|line-height|list-style-image|list-style-position|list-style-type|list-style|margin-bottom|margin-left|margin-right|margin-top|marker-offset|margin|marks|max-height|max-width|min-height|min-width|opacity|orphans|outline-color|outline-style|outline-width|outline|overflow|overflow-x|overflow-y|padding-bottom|padding-left|padding-right|padding-top|padding|page-break-after|page-break-before|page-break-inside|page|pause-after|pause-before|pause|pitch-range|pitch|play-during|position|quotes|richness|right|size|speak-header|speak-numeral|speak-punctuation|speech-rate|speak|stress|table-layout|text-align|text-decoration|text-indent|text-shadow|text-transform|top|unicode-bidi|vertical-align|visibility|voice-family|volume|white-space|widows|width|word-spacing|z-index".split("|"), o = [], n = 0, i = e.length; i > n; n++) Array.prototype.push.apply(o, (e[n] + t.join("|" + e[n])).split("|")); + return Array.prototype.push.apply(o, t), Array.prototype.push.apply(o, r), o + }()), + t = o.arrayToMap("hsl|hsla|rgb|rgba|url|attr|counter|counters|lighten|darken|saturate|desaturate|fadein|fadeout|fade|spin|mix|hue|saturation|lightness|alpha|round|ceil|floor|percentage|color|iscolor|isnumber|isstring|iskeyword|isurl|ispixel|ispercentage|isem".split("|")), + r = o.arrayToMap("absolute|all-scroll|always|armenian|auto|baseline|below|bidi-override|block|bold|bolder|border-box|both|bottom|break-all|break-word|capitalize|center|char|circle|cjk-ideographic|col-resize|collapse|content-box|crosshair|dashed|decimal-leading-zero|decimal|default|disabled|disc|distribute-all-lines|distribute-letter|distribute-space|distribute|dotted|double|e-resize|ellipsis|fixed|georgian|groove|hand|hebrew|help|hidden|hiragana-iroha|hiragana|horizontal|ideograph-alpha|ideograph-numeric|ideograph-parenthesis|ideograph-space|inactive|inherit|inline-block|inline|inset|inside|inter-ideograph|inter-word|italic|justify|katakana-iroha|katakana|keep-all|left|lighter|line-edge|line-through|line|list-item|loose|lower-alpha|lower-greek|lower-latin|lower-roman|lowercase|lr-tb|ltr|medium|middle|move|n-resize|ne-resize|newspaper|no-drop|no-repeat|nw-resize|none|normal|not-allowed|nowrap|oblique|outset|outside|overline|pointer|progress|relative|repeat-x|repeat-y|repeat|right|ridge|row-resize|rtl|s-resize|scroll|se-resize|separate|small-caps|solid|square|static|strict|super|sw-resize|table-footer-group|table-header-group|tb-rl|text-bottom|text-top|text|thick|thin|top|transparent|underline|upper-alpha|upper-latin|upper-roman|uppercase|vertical-ideographic|vertical-text|visible|w-resize|wait|whitespace|zero".split("|")), + n = o.arrayToMap("aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|white|yellow".split("|")), + i = o.arrayToMap("@mixin|@extend|@include|@import|@media|@debug|@warn|@if|@for|@each|@while|@else|@font-face|@-webkit-keyframes|if|and|!default|module|def|end|declare|when|not|and".split("|")), + a = o.arrayToMap("a|abbr|acronym|address|applet|area|article|aside|audio|b|base|basefont|bdo|big|blockquote|body|br|button|canvas|caption|center|cite|code|col|colgroup|command|datalist|dd|del|details|dfn|dir|div|dl|dt|em|embed|fieldset|figcaption|figure|font|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hgroup|hr|html|i|iframe|img|input|ins|keygen|kbd|label|legend|li|link|map|mark|menu|meta|meter|nav|noframes|noscript|object|ol|optgroup|option|output|p|param|pre|progress|q|rp|rt|ruby|s|samp|script|section|select|small|source|span|strike|strong|style|sub|summary|sup|table|tbody|td|textarea|tfoot|th|thead|time|title|tr|tt|u|ul|var|video|wbr|xmp".split("|")), + l = "\\-?(?:(?:[0-9]+)|(?:[0-9]*\\.[0-9]+))"; + this.$rules = { + start: [{ + token: "comment", + regex: "\\/\\/.*$" + }, { + token: "comment", + merge: !0, + regex: "\\/\\*", + next: "comment" + }, { + token: "string", + regex: '["](?:(?:\\\\.)|(?:[^"\\\\]))*?["]' + }, { + token: "string", + regex: "['](?:(?:\\\\.)|(?:[^'\\\\]))*?[']" + }, { + token: "constant.numeric", + regex: l + "(?:em|ex|px|cm|mm|in|pt|pc|deg|rad|grad|ms|s|hz|khz|%)" + }, { + token: "constant.numeric", + regex: "#[a-f0-9]{6}" + }, { + token: "constant.numeric", + regex: "#[a-f0-9]{3}" + }, { + token: "constant.numeric", + regex: l + }, { + token: function(e) { + return i.hasOwnProperty(e) ? "keyword" : "variable" + }, + regex: "@[a-z0-9_\\-@]*\\b" + }, { + token: function(o) { + return e.hasOwnProperty(o.toLowerCase()) ? "support.type" : i.hasOwnProperty(o) ? "keyword" : r.hasOwnProperty(o) ? "constant.language" : t.hasOwnProperty(o) ? "support.function" : n.hasOwnProperty(o.toLowerCase()) ? "support.constant.color" : a.hasOwnProperty(o.toLowerCase()) ? "variable.language" : "text" + }, + regex: "\\-?[@a-z_][@a-z0-9_\\-]*" + }, { + token: "variable.language", + regex: "#[a-z0-9-_]+" + }, { + token: "variable.language", + regex: "\\.[a-z0-9-_]+" + }, { + token: "variable.language", + regex: ":[a-z0-9-_]+" + }, { + token: "constant", + regex: "[a-z0-9-_]+" + }, { + token: "keyword.operator", + regex: "<|>|<=|>=|==|!=|-|%|#|\\+|\\$|\\+|\\*" + }, { + token: "paren.lparen", + regex: "[[({]" + }, { + token: "paren.rparen", + regex: "[\\])}]" + }, { + token: "text", + regex: "\\s+" + }], + comment: [{ + token: "comment", + regex: ".*?\\*\\/", + next: "start" + }, { + token: "comment", + merge: !0, + regex: ".+" + }] + } + }; + r.inherits(i, n), t.LessHighlightRules = i +}), define("ace/mode/matching_brace_outdent", ["require", "exports", "module", "ace/range"], function(e, t) { + var r = e("../range").Range, + o = function() {}; + (function() { + this.checkOutdent = function(e, t) { + return /^\s+$/.test(e) ? /^\s*\}/.test(t) : !1 + }, this.autoOutdent = function(e, t) { + var o = e.getLine(t), + n = o.match(/^(\s*\})/); + if (!n) return 0; + var i = n[1].length, + a = e.findMatchingBracket({ + row: t, + column: i + }); + if (!a || a.row == t) return 0; + var l = this.$getIndent(e.getLine(a.row)); + e.replace(new r(t, 0, t, i - 1), l) + }, this.$getIndent = function(e) { + var t = e.match(/^(\s+)/); + return t ? t[1] : "" + } + }).call(o.prototype), t.MatchingBraceOutdent = o +}), define("ace/mode/folding/cstyle", ["require", "exports", "module", "ace/lib/oop", "ace/range", "ace/mode/folding/fold_mode"], function(e, t) { + var r = e("../../lib/oop"), + o = e("../../range").Range, + n = e("./fold_mode").FoldMode, + i = t.FoldMode = function() {}; + r.inherits(i, n), + function() { + this.foldingStartMarker = /(\{|\[)[^\}\]]*$|^\s*(\/\*)/, this.foldingStopMarker = /^[^\[\{]*(\}|\])|^[\s\*]*(\*\/)/, this.getFoldWidgetRange = function(e, t, r) { + var n = e.getLine(r), + i = n.match(this.foldingStartMarker); + if (i) { + var a = i.index; + if (i[1]) return this.openingBracketBlock(e, i[1], r, a); + var l = e.getCommentFoldRange(r, a + i[0].length); + return l.end.column -= 2, l + } + if ("markbeginend" === t) { + var i = n.match(this.foldingStopMarker); + if (i) { + var a = i.index + i[0].length; + if (i[2]) { + var l = e.getCommentFoldRange(r, a); + return l.end.column -= 2, l + } + var s = { + row: r, + column: a + }, + d = e.$findOpeningBracket(i[1], s); + if (!d) return; + return d.column++, s.column--, o.fromPoints(d, s) + } + } + } + }.call(i.prototype) +}), define("ace/mode/folding/fold_mode", ["require", "exports", "module", "ace/range"], function(e, t) { + var r = e("../../range").Range, + o = t.FoldMode = function() {}; + (function() { + this.foldingStartMarker = null, this.foldingStopMarker = null, this.getFoldWidget = function(e, t, r) { + var o = e.getLine(r); + return this.foldingStartMarker.test(o) ? "start" : "markbeginend" == t && this.foldingStopMarker && this.foldingStopMarker.test(o) ? "end" : "" + }, this.getFoldWidgetRange = function() { + return null + }, this.indentationBlock = function(e, t, o) { + for (var n = /^\s*/, i = t, a = t, l = e.getLine(t), s = o || l.length, d = l.match(n)[0].length, c = e.getLength(); ++t < c;) { + l = e.getLine(t); + var g = l.match(n)[0].length; + if (g != l.length) { + if (d >= g) break; + a = t + } + } + if (a > i) { + var u = e.getLine(a).length; + return new r(i, s, a, u) + } + }, this.openingBracketBlock = function(e, t, o, n, i, a) { + var l = { + row: o, + column: n + 1 + }, + s = e.$findClosingBracket(t, l, i, a); + if (s) { + var d = e.foldWidgets[s.row]; + return null == d && (d = this.getFoldWidget(e, s.row)), "start" == d && (s.row--, s.column = e.getLine(s.row).length), r.fromPoints(l, s) + } + } + }).call(o.prototype) +}); diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index 325cbdc6d87..054850b0926 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -5,12 +5,16 @@ import cStringIO import io import json import logging +import os +import re import time import werkzeug.wrappers from PIL import Image, ImageFont, ImageDraw +from lxml import etree, html from odoo.http import request from odoo import http, tools +from odoo.modules.module import get_resource_path, get_module_path logger = logging.getLogger(__name__) @@ -223,11 +227,196 @@ class Web_Editor(http.Controller): attachments_to_remove.unlink() return removal_blocked_by - @http.route('/web_editor/get_assets_editor_resources', type='json', auth='user', website=True) - def get_assets_editor_resources(self, key, bundles=False): - """ Get inherit view's informations of the template ``key``. - returns templates info (which can be active or not) - ``bundles=True`` returns also the asset bundles - """ + ## The get_assets_editor_resources route is in charge of transmitting the resources the assets + ## editor needs to work. + ## @param key - the xml_id or id of the view the resources are related to + ## @param get_views - True if the views must be fetched (default to True) + ## @param get_less - True if the style must be fetched (default to True) + ## @param bundles - True if the bundles views must be fetched (default to False) + ## @param bundles_restriction - Names of the bundle in which to look for less files (if empty, search in all of them) + ## @returns a dictionary with views info in the views key and style info in the less key + @http.route("/web_editor/get_assets_editor_resources", type="json", auth="user") + def get_assets_editor_resources(self, key, get_views=True, get_less=True, bundles=False, bundles_restriction=[]): + # Related views must be fetched if the user wants the views and/or the style views = request.env["ir.ui.view"].get_related_views(key, bundles=bundles) - return views.read(['name', 'id', 'key', 'xml_id', 'arch', 'active', 'inherit_id']) + views = views.read(['name', 'id', 'key', 'xml_id', 'arch', 'active', 'inherit_id']) + + less_files_data_by_bundle = [] + + # Load less only if asked by the user + if get_less: + # Compile regex outside of the loop + # This will used to exclude library less files from the result + excluded_url_matcher = re.compile("^(.+/lib/.+)|(.+import_bootstrap.less)$") + + # Load already customized less files attachments + custom_attachments = request.env["ir.attachment"].search([("url", "=like", self._make_custom_less_file_url("%%.%%", "%%"))]) + + # First check the t-call-assets used in the related views + url_infos = dict() + for v in views: + for asset_call_node in etree.fromstring(v["arch"]).xpath("//t[@t-call-assets]"): + if asset_call_node.get("t-css") == "false": + continue + asset_name = asset_call_node.get("t-call-assets") + + # Loop through bundle files to search for LESS file info + less_files_data = [] + for file_info in request.env["ir.qweb"]._get_asset_content(asset_name, {})[0]: + if file_info["atype"] != "text/less": + continue + url = file_info["url"] + + # Exclude library files (see regex above) + if excluded_url_matcher.match(url): + continue + + # Check if the file is customized and get bundle/path info + less_file_data = self._match_less_file_url(url) + if not less_file_data: + continue + + # Save info (arch will be fetched later) + url_infos[url] = less_file_data + less_files_data.append(url) + + # Less data is returned sorted by bundle, with the bundles names and xmlids + if len(less_files_data): + less_files_data_by_bundle.append([dict(xmlid=asset_name, name=request.env.ref(asset_name).name), less_files_data]) + + # Filter bundles/files: + # - A file which appears in multiple bundles only appears in the first one (the first in the DOM) + # - Only keep bundles with files which appears in the asked bundles and only keep those files + for i in range(0, len(less_files_data_by_bundle)): + bundle_1 = less_files_data_by_bundle[i] + for j in range(0, len(less_files_data_by_bundle)): + bundle_2 = less_files_data_by_bundle[j] + # In unwanted bundles, keep only the files which are in wanted bundles too (less_helpers) + if bundle_1[0]["xmlid"] not in bundles_restriction and bundle_2[0]["xmlid"] in bundles_restriction: + bundle_1[1] = [item_1 for item_1 in bundle_1[1] if item_1 in bundle_2[1]] + for i in range(0, len(less_files_data_by_bundle)): + bundle_1 = less_files_data_by_bundle[i] + for j in range(i+1, len(less_files_data_by_bundle)): + bundle_2 = less_files_data_by_bundle[j] + # In every bundle, keep only the files which were not found in previous bundles + bundle_2[1] = [item_2 for item_2 in bundle_2[1] if item_2 not in bundle_1[1]] + + # Only keep bundles which still have files and that were requested + less_files_data_by_bundle = [ + data for data in less_files_data_by_bundle + if (len(data[1]) > 0 and (not bundles_restriction or data[0]["xmlid"] in bundles_restriction)) + ] + + # Fetch the arch of each kept file, in each bundle + for bundle_data in less_files_data_by_bundle: + for i in range(0, len(bundle_data[1])): + url = bundle_data[1][i] + url_info = url_infos[url] + + content = None + if url_info["customized"]: + # If the file is already customized, the content is found in the corresponding attachment + content = custom_attachments.filtered(lambda a: a.url == url).datas.decode("base64") + else: + # If the file is not yet customized, the content is found by reading the local less file + module = url_info["module"] + module_path = get_module_path(module) + module_resource_path = get_resource_path(module, url_info["resource_path"]) + if module_path and module_resource_path: + module_path = os.path.join(os.path.normpath(module_path), '') # join ensures the path ends with '/' + module_resource_path = os.path.normpath(module_resource_path) + if module_resource_path.startswith(module_path): + with open(module_resource_path, "rb") as f: + content = f.read() + + bundle_data[1][i] = dict( + url = "/%s/%s" % (url_info["module"], url_info["resource_path"]), + arch = content, + customized = url_info["customized"], + ) + + return dict( + views = get_views and views or [], + less = get_less and less_files_data_by_bundle or [], + ) + + ## The save_less route is in charge of saving a given modification of a LESS file. + ## @param url - the original url of the LESS file which has to be modified + ## @param bundle_xmlid - the xmlid of the bundle in which the LESS file addition can be found + ## @param content - the new content of the LESS file + @http.route("/web_editor/save_less", type="json", auth="user") + def save_less(self, url, bundle_xmlid, content): + IrAttachment = request.env["ir.attachment"] + + custom_url = self._make_custom_less_file_url(url, bundle_xmlid) + + # Check if the file to save had already been modified + custom_attachment = IrAttachment.search([("url", "=", custom_url)]) + if custom_attachment: + # If it was already modified, simply override the corresponding attachment content + custom_attachment.write({"datas": content.encode("utf-8").encode("base64")}) + else: + # If not, create a new attachment to copy the original LESS file content, with its modifications + IrAttachment.create(dict( + name = custom_url, + type = "binary", + mimetype = "text/less", + datas = content.encode("utf-8").encode("base64"), + datas_fname = url.split("/")[-1], + url = custom_url, # Having an attachment of "binary" type with an non empty "url" field + # is quite of an hack. This allows to fetch the "datas" field by adding + # a with the "url" content in the bundle template (see qweb) + )) + + # Create a view to extend the template which adds the original file to link the new modified version instead + IrUiView = request.env["ir.ui.view"] + view_to_xpath = IrUiView.get_related_views(bundle_xmlid, bundles=True).filtered(lambda v: v.arch.find(url) >= 0) + IrUiView.create(dict( + name = custom_url, + mode = "extension", + inherit_id = view_to_xpath.id, + arch = """ + + + %(new_url)s + + + """ % dict( + inherit_xml_id = view_to_xpath.xml_id, + name = custom_url, + url_to_replace = url, + new_url = custom_url, + ) + )) + + request.env["ir.qweb"].clear_caches() + + ## The reset_less route is in charge of reverting all the changes that were done to a less file. + ## @param url - the original URL of the LESS file to reset + ## @param bundle_xmlid - the xmlid of the bundle in which the LESS file addition can be found + @http.route("/web_editor/reset_less", type="json", auth="user") + def reset_less(self, url, bundle_xmlid): + IrAttachment = request.env["ir.attachment"] + IrUiView = request.env["ir.ui.view"] + + custom_url = self._make_custom_less_file_url(url, bundle_xmlid) + + # Simply delete the attachement which contains the modified less file and the xpath view which links it + IrAttachment.search([("url", "=", custom_url)]).unlink() + IrUiView.search([("name", "=", custom_url)]).unlink() + + def _make_custom_less_file_url(self, url, bundle): + parts = url.rsplit(".", 1) + return "%s.custom.%s.%s" % (parts[0], bundle, parts[1]) + + _match_less_file_url_regex = re.compile("^/(\w+)/(.+?)(\.custom\.(.+))?\.(\w+)$") + def _match_less_file_url(self, url): + m = self._match_less_file_url_regex.match(url) + if not m: + return False + return dict( + module = m.group(1), + resource_path = "%s.%s" % (m.group(2), m.group(5)), + customized = bool(m.group(3)), + bundle = m.group(4) or False + ) diff --git a/addons/web_editor/static/src/js/ace.js b/addons/web_editor/static/src/js/ace.js index bbcc4fa4f74..7367c33eaa6 100644 --- a/addons/web_editor/static/src/js/ace.js +++ b/addons/web_editor/static/src/js/ace.js @@ -13,57 +13,103 @@ var _t = core._t; ajax.loadXML('/web_editor/static/src/xml/ace.xml', qweb); +function _getCheckReturn(isValid, errorLine, errorMessage) { + return { + isValid: isValid, + error: isValid ? null : { + line: errorLine, + message: errorMessage, + }, + }; +} + function checkXML(xml) { if (typeof window.DOMParser != "undefined") { var xmlDoc = (new window.DOMParser()).parseFromString(xml, "text/xml"); var error = xmlDoc.getElementsByTagName("parsererror"); if (error.length > 0) { - return { - isValid: false, - error: { - line: parseInt(error[0].innerHTML.match(/[Ll]ine[^\d]+(\d+)/)[1], 10), - message: error[0].innerHTML - } - }; + return _getCheckReturn(false, parseInt(error[0].innerHTML.match(/[Ll]ine[^\d]+(\d+)/)[1], 10), error[0].innerHTML); } } else if (typeof window.ActiveXObject != "undefined" && new window.ActiveXObject("Microsoft.XMLDOM")) { var xmlDocIE = new window.ActiveXObject("Microsoft.XMLDOM"); xmlDocIE.async = "false"; xmlDocIE.loadXML(xml); if (xmlDocIE.parseError.line > 0) { - return { - isValid: false, - error: { - line: xmlDocIE.parseError.line, - message: xmlDocIE.parseError.reason - } - }; + return _getCheckReturn(false, xmlDocIE.parseError.line, xmlDocIE.parseError.reason); } } - - return { - isValid: true - }; + return _getCheckReturn(true); } function formatXML(xml) { return window.vkbeautify.xml(xml, 4); } +var checkLESS = (function () { + var mapping = { + '{': '}', '}': '{', + '(': ')', ')': '(', + '[': ']', ']': '[', + }; + var openings = ['{', '(', '[']; + var closings = ['}', ')', ']']; + + return function (less) { + var stack = []; + var line = 1; + for (var i = 0 ; i < less.length ; i++) { + if (_.contains(openings, less[i])) { + stack.push(less[i]); + } else if (_.contains(closings, less[i])) { + if (stack.pop() !== mapping[less[i]]) { + return _getCheckReturn(false, line, _t("Unexpected ") + less[i]); + } + } else if (less[i] === '\n') { + line++; + } + } + if (stack.length > 0) { + return _getCheckReturn(false, line, _t("Expected ") + mapping[stack.pop()]); + } + return _getCheckReturn(true); + }; +})(); + +function formatLESS(less) { + return less; +} + /** * The ViewEditor Widget allow to visualize resources (by default, XML views) and edit them. */ var ViewEditor = Widget.extend({ template: 'web_editor.ace_view_editor', events: { - 'change #ace-view-list': function () { - this.displayView(this.selectedViewId()); + "click .o_ace_type_switcher_choice": function (e) { + e.preventDefault(); + this.switchType($(e.target).data("type")); + }, + 'change .oe_view_list': function () { + this.displayResource(this.selectedResource()); }, 'click .js_include_bundles': function (e) { this.options.includeBundles = $(e.target).prop("checked"); this.loadResources().then(this._updateViewSelectDOM.bind(this)); }, + 'click .js_include_all_less': function (e) { + this.options.includeAllLess = $(e.target).prop("checked"); + this.loadResources().then(this._updateViewSelectDOM.bind(this)); + }, 'click button[data-action=save]': 'saveResources', + "click button[data-action=\"reset\"]": function () { + var self = this; + Dialog.confirm(this, _t("If you reset this file, all your customizations will be lost as it will be reverted to the default file."), { + title: _t("Careful !"), + confirm_callback: function () { + self.resetResource(self.selectedResource()); + }, + }); + }, 'click button[data-action=format]': 'formatResource', 'click button[data-action=close]': 'do_hide', }, @@ -74,7 +120,7 @@ var ViewEditor = Widget.extend({ * @param viewKey: xml_id of the view whose linked resources are to be loaded. Also allow to receive the id directly. * @param options: an object containing some options - * - initialViewID: a specific view ID to load on start (otherwise the main view ID + * - initialResID: a specific view ID to load on start (otherwise the main view ID * associated with the specified viewKey will be used). * - includeBundles: whether or not the assets bundles templates needs to be loaded. */ @@ -82,10 +128,22 @@ var ViewEditor = Widget.extend({ this._super.apply(this, arguments); this.viewKey = viewKey; + this.options = _.defaults({}, options, { + position: 'right', + doNotLoadViews: false, + doNotLoadLess: false, + includeBundles: false, + includeAllLess: false, + defaultBundlesRestriction: [], + }); - this.options = _.defaults({}, options, {position: 'right'}); - this.views = {}; - this.buffers = {}; + this.resources = {xml: {}, less: {}}; + this.editingSessions = {xml: {}, less: {}}; + this.currentType = "xml"; + + // Alias + this.views = this.resources.xml; + this.less = this.resources.less; }, /** * The willStart method is in charge of loading everything the ace library needs to work. @@ -95,6 +153,7 @@ var ViewEditor = Widget.extend({ var js_def = ajax.loadJS('/web/static/lib/ace/ace.odoo-custom.js').then(function () { return $.when( ajax.loadJS('/web/static/lib/ace/mode-xml.js'), + ajax.loadJS('/web/static/lib/ace/mode-less.js'), ajax.loadJS('/web/static/lib/ace/theme-monokai.js') ); }); @@ -106,10 +165,22 @@ var ViewEditor = Widget.extend({ * @return a deferred which is resolved when the widget DOM content is fully loaded. */ start: function () { - this.$viewEditor = this.$('#ace-view-editor'); + this.$viewEditor = this.$("#ace-view-editor"); this.$editor = this.$(".ace_editor"); - this.$viewList = this.$('#ace-view-list'); - this.$viewID = this.$('#ace-view-id'); + + this.$typeSwitcherChoices = this.$(".o_ace_type_switcher_choice"); + this.$typeSwitcherBtn = this.$(".o_ace_type_switcher > .dropdown-toggle"); + + this.$lists = { + xml: this.$("#ace-view-list"), + less: this.$("#ace-less-list") + }; + this.$includeBundlesArea = this.$(".oe_include_bundles"); + this.$includeAllLessArea = this.$(".o_include_all_less"); + this.$viewID = this.$("#ace-view-id > span"); + + this.$formatButton = this.$("button[data-action=\"format\"]"); + this.$resetButton = this.$("button[data-action=\"reset\"]"); this.aceEditor = window.ace.edit(this.$viewEditor[0]); this.aceEditor.setTheme("ace/theme/monokai"); @@ -120,10 +191,32 @@ var ViewEditor = Widget.extend({ var debounceStoreEditorWidth = _.debounce(storeEditorWidth, 500); this._updateViewSelectDOM(); - this.displayView( - this.options.initialViewID - || (typeof this.viewKey === "number" ? this.viewKey : _.findWhere(this.views, {xml_id: this.viewKey}).id) - ); + + var initResID; + var initType; + if (this.options.initialResID) { + initResID = this.options.initialResID; + initType = (_.isString(initResID) && initResID[0] === '/') ? "less" : "xml"; + } else { + if (!this.options.doNotLoadLess) { + initResID = this.sorted_less[0][1][0].url; // first bundle, less files, first one + initType = "less"; + } + if (!this.options.doNotLoadViews) { + initResID = (typeof this.viewKey === "number" ? this.viewKey : _.findWhere(this.views, {xml_id: this.viewKey}).id); + initType = "xml"; + } + } + if (initResID) { + this.displayResource(initResID, initType); + } + + if (!this.sorted_views.length || !this.sorted_less.length) { + _.defer((function () { + this.switchType(this.sorted_views.length ? "xml" : "less"); + this.$typeSwitcherBtn.parent(".btn-group").addClass("hidden"); + }).bind(this)); + } $(document).on("mouseup.ViewEditor", stopResizing.bind(this)).on("mousemove.ViewEditor", updateWidth.bind(this)); if (this.options.position === 'left') { @@ -186,14 +279,29 @@ var ViewEditor = Widget.extend({ * @return a deferred which is resolved once everything is loaded and processed. */ loadResources: function () { + // Reset resources + this.resources = {xml: {}, less: {}}; + this.editingSessions = {xml: {}, less: {}}; + this.views = this.resources.xml; + this.less = this.resources.less; + + // Load resources return ajax.jsonRpc("/web_editor/get_assets_editor_resources", "call", { key: this.viewKey, - bundles: this.options.includeBundles - }).then((function (views) { + get_views: !this.options.doNotLoadViews, + get_less: !this.options.doNotLoadLess, + bundles: this.options.includeBundles, + bundles_restriction: this.options.includeAllLess ? [] : this.options.defaultBundlesRestriction, + }).then((function (resources) { + _process_views.call(this, resources.views); + _process_less.call(this, resources.less); + }).bind(this)); + + function _process_views(views) { // Only keep the active views and index them by ID. - this.views = _.indexBy(_.filter(views, function (view) { + _.extend(this.views, _.indexBy(_.filter(views, function (view) { return view.active; - }), "id"); + }), "id")); // Initialize a 0 level for each view and assign them an array containing their children. var self = this; @@ -225,63 +333,133 @@ var ViewEditor = Widget.extend({ _.each(roots, function (root) { visit(root, 0); }); - }).bind(this)); + } + + function _process_less(less) { + // The received less data is already sorted by bundle and DOM order + this.sorted_less = less; + + // Store the URL ungrouped by bundle and use the URL as key (resource ID) + var self = this; + _.each(less, function (bundleInfos) { + _.each(bundleInfos[1], function (info) { info.bundle_xmlid = bundleInfos[0].xmlid; }); + _.extend(self.less, _.indexBy(bundleInfos[1], "url")); + }); + } }, /** - * The private _updateViewSelectDOM method purpose is to render the content of the view + * The private _updateViewSelectDOM method purpose is to render the content of the view/file * select DOM element according to current widget data. */ _updateViewSelectDOM: function () { - var currentId = this.selectedViewId(); + var currentId = this.selectedResource(); var self = this; - this.$viewList.empty(); + this.$lists.xml.empty(); _.each(this.sorted_views, function (view) { - self.$viewList.append($("", { + label: bundleInfos[0].name, + }).appendTo(self.$lists.less); + _.each(bundleInfos[1], function (lessInfo) { + $optgroup.append($("