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 = """
+
+