[ADD] web_editor, website, web: new LESS editor

The old "HTML" editor now handles LESS edition. The editor has now a
button to switch between XML and LESS edition. By default, it only
displays the odoo files which only appear in the assets_frontend bundle
and in debug mode, it allows to see all the files that influenced the
page design.

The new editor has a new set of options which allows to use it as a
xml editor only or a less editor only if needed.

Also: make the "include bundles" option appear only in debug mode.

Technical details:

- When saving a modified LESS file, an attachment is created with the
full modified content and an extension view xpaths the bundle where
the original file was added to link this attachment instead. This
relies on the fact that qweb, when generating assets bundle,
replaces missing files by the "datas" contents of the ir.attachment's
which have an "url" field equal to the urls it is searching.

- If a LESS error is introduced, the recent changes in assets bundle
generation (see c248674a5c) will not break
the design but only open a dialog on page loading, with the LESS error.
The user can then go to the LESS editor and reset its changes.

This feature code has been started with original work of @pga-odoo
This commit is contained in:
qsm-odoo
2017-02-14 16:37:23 +01:00
parent 7751c1bfb9
commit 198673bf35
7 changed files with 764 additions and 120 deletions
+206
View File
@@ -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)
});
+196 -7
View File
@@ -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 <link/> 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 = """
<data inherit_id="%(inherit_xml_id)s" name="%(name)s">
<xpath expr="//link[@href='%(url_to_replace)s']" position="attributes">
<attribute name="href">%(new_url)s</attribute>
</xpath>
</data>
""" % 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
)
+314 -102
View File
@@ -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($("<option/>", {
self.$lists.xml.append($("<option/>", {
value: view.id,
text: _.str.sprintf("%s%s", _.str.repeat("- ", view.level), view.name),
selected: currentId === view.id
selected: currentId === view.id,
}));
});
this.$lists.less.empty();
_.each(this.sorted_less, function (bundleInfos) {
var $optgroup = $("<optgroup/>", {
label: bundleInfos[0].name,
}).appendTo(self.$lists.less);
_.each(bundleInfos[1], function (lessInfo) {
$optgroup.append($("<option/>", {
value: lessInfo.url,
text: lessInfo.url + (lessInfo.customized ? _.str.sprintf(" (%s)", _t("customized")) : ""),
selected: currentId === lessInfo.url,
}));
});
});
},
/**
* The selectedViewId method returns the currently selected view id.
* @return the currently selected view id.
* The switchType method switches to the LESS or XML edition. Calling this method will adapt all DOM elements to
* keep the editor consistent.
* @param type: either "xml" or "less"
*/
selectedViewId: function () {
return parseInt(this.$viewList.val(), 10);
switchType: function (type) {
this.currentType = type;
this.$typeSwitcherBtn.html(this.$typeSwitcherChoices.filter("[data-type=\"" + type + "\"]").html());
_.each(this.$lists, function ($list, _type) { $list.toggleClass("hidden", type !== _type); });
this.$lists[type].change();
this.$includeBundlesArea.toggleClass("hidden", this.currentType === "less" || !session.debug);
this.$includeAllLessArea.toggleClass("hidden", this.currentType === "xml" || !session.debug || this.options.defaultBundlesRestriction.length === 0);
this.$formatButton.toggleClass("hidden", this.currentType === "less");
},
/**
* The displayView method forces the view identified by its ID to be displayed in the editor.
* The method will update the view select DOM element as well.
* @param viewID: the ID of the view to display.
* The selectedResource method returns the currently selected resource id (view ID or less file URL).
* @return the currently resource id (view ID or less file URL)
*/
displayView: function (viewID) {
var editingSession = this.buffers[viewID];
selectedResource: function () {
return this.$lists[this.currentType].val();
},
/**
* The displayResource method forces the view/less file identified by its ID/URL to be displayed in the editor.
* The method will update the resource select DOM element as well.
* @param resID: the ID/URL of the view/less file to display
* @param type: the type of resource (either "xml" or "less")
*/
displayResource: function (resID, type) {
if (type) this.switchType(type);
var editingSession = this.editingSessions[this.currentType][resID];
if (!editingSession) {
editingSession = this._buildEditingSession(viewID);
editingSession = this.editingSessions[this.currentType][resID] = this._buildEditingSession(resID);
}
this.aceEditor.setSession(editingSession);
this.$viewID.text(_.str.sprintf(_t("Template ID: %s"), this.views[viewID].xml_id));
if (viewID !== this.selectedViewId()) {
this.$viewList.val(viewID);
if (this.currentType === "xml") {
this.$viewID.text(_.str.sprintf(_t("Template ID: %s"), this.views[resID].xml_id));
} else {
this.$viewID.text(_.str.sprintf(_t("Less file: %s"), resID));
}
this.$lists[this.currentType].val(resID);
this.$resetButton.toggleClass("hidden", this.currentType === "xml" || !this.less[resID].customized);
},
/**
* The resetResource method forces the view/less file identified by its ID/URL to be reset to the way it was before
* the user started editing it. TODO view reset is not supported yet
* @param resID: the ID/URL of the view/less file to reset (default to the currently selected one)
* @param type: the type of the resource to reset (default to the currently selected one)
* @return a deferred which is resolved once the resource has been reset
*/
resetResource: function (resID, type) {
resID = resID || this.selectedResource();
type = type || this.currentType;
if (this.currentType === "xml") {
return $.Defered().reject(_t("Reseting views is not supported yet")); // TODO
} else {
return ajax.jsonRpc("/web_editor/reset_less", "call", {
url: resID,
bundle_xmlid: this.less[resID].bundle_xmlid,
});
}
},
/**
* The private _buildEditingSession method initialize a text editor for the specified view.
* @param viewID: the ID of the view to display.
* @return an ace.EditSession object linked to the specified viewID.
* The private _buildEditingSession method initializes a text editor for the specified resource.
* @param resID: the ID/URL of the view/less file whose text editor it will be
* @param type: the type of the given resource (default to the currently selected one)
* @return an ace.EditSession object linked to the specified resID.
*/
_buildEditingSession: function(viewID) {
_buildEditingSession: function(resID, type) {
var self = this;
var editingSession = this.buffers[viewID] = new window.ace.EditSession(this.views[viewID].arch);
type = type || this.currentType;
var editingSession = new window.ace.EditSession(this.resources[type][resID].arch);
editingSession.setUseWorker(false);
editingSession.setMode("ace/mode/xml");
editingSession.setMode("ace/mode/" + (type || this.currentType));
editingSession.setUndoManager(new window.ace.UndoManager());
editingSession.on("change", function () {
_.defer(function () {
self._toggleDirtyInfo(viewID);
self._toggleDirtyInfo(resID);
self._showErrorLine();
});
});
@@ -289,18 +467,21 @@ var ViewEditor = Widget.extend({
},
/**
* The private _toggleDirtyInfo method update the select option DOM element associated with
* a particular viewID to indicate if the option is dirty or not.
* @param viewID: the view id whose option has to be updated
* a particular resID to indicate if the option is dirty or not.
* @param resID: the ID/URL of the view/less file whose option has to be updated
* @param type: the type of the given resource (default to the currently selected one)
* @param isDirty: a boolean to indicate if the view is dirty or not ; default to content of UndoManager
*/
_toggleDirtyInfo: function (viewID, isDirty) {
if (!viewID || !this.buffers[viewID]) return;
_toggleDirtyInfo: function (resID, type, isDirty) {
type = type || this.currentType;
var $option = this.$viewList.find("[value=" + viewID + "]");
if (!resID || !this.editingSessions[type][resID]) return;
var $option = this.$lists[type].find("[value='" + resID + "']");
var optionName = $option.text();
var dirtyMarker = " (" + _t("unsaved changes") + ")";
if (isDirty === undefined) {
isDirty = this.buffers[viewID].getUndoManager().hasUndo();
isDirty = this.editingSessions[type][resID].getUndoManager().hasUndo();
}
if (isDirty && optionName.indexOf(dirtyMarker) < 0) {
$option.text(optionName + dirtyMarker);
@@ -310,21 +491,15 @@ var ViewEditor = Widget.extend({
},
/**
* The formatResource method formats the current resource being vizualized.
* TODO formatting LESS files is not supported yet.
*/
formatResource: function () {
this._formatXML();
},
/**
* The private _formatXML method formats the current resource assuming it is an XML document.
* If the xml is not valid, it shows the error instead.
*/
_formatXML: function () {
var xml = this.aceEditor.getValue();
var check = checkXML(xml);
var res = this.aceEditor.getValue();
var check = (this.currentType === "xml" ? checkXML : checkLESS)(res);
if (check.isValid) {
this.aceEditor.setValue(formatXML(xml));
this.aceEditor.setValue((this.currentType === "xml" ? formatXML : formatLESS)(res));
} else {
this._showErrorLine(check.error.line, check.error.message, this.selectedViewId());
this._showErrorLine(check.error.line, check.error.message, this.selectedResource());
}
},
/**
@@ -333,35 +508,47 @@ var ViewEditor = Widget.extend({
* @return a deferred whose status indicates if the save is finished or if an error occured.
*/
saveResources: function () {
var dirtyBuffers = _.pick(this.buffers, function (session) {
return session.getUndoManager().hasUndo();
});
var toSave = _.map(dirtyBuffers, function (editingSession, viewID) {
return {
id: parseInt(viewID, 10),
text: editingSession.getValue(),
};
});
var toSave = {};
var errorFound = false;
_.each(this.editingSessions, (function (editingSessions, type) {
if (errorFound) return;
this._showErrorLine();
for (var i = 0 ; i < toSave.length ; i++) {
var check = checkXML(toSave[i].text);
if (!check.isValid) {
this._showErrorLine(check.error.line, check.error.message, toSave[i].id);
return $.Deferred().reject(toSave[i]);
var dirtySessions = _.pick(editingSessions, function (session) {
return session.getUndoManager().hasUndo();
});
toSave[type] = _.map(dirtySessions, function (session, resID) {
return {
id: parseInt(resID, 10) || resID,
text: session.getValue(),
};
});
this._showErrorLine();
for (var i = 0 ; i < toSave[type].length && !errorFound ; i++) {
var check = (type === "xml" ? checkXML : checkLESS)(toSave[type][i].text);
if (!check.isValid) {
this._showErrorLine(check.error.line, check.error.message, toSave[type][i].id, type);
errorFound = toSave[type][i];
}
}
}
}).bind(this));
if (errorFound) return $.Deferred().reject(errorFound);
return $.when.apply($, _.map(toSave, this._saveView.bind(this))).fail(function (session, error) {
var defs = [];
_.each(toSave, (function (_toSave, type) {
defs = defs.concat(_.map(_toSave, (type === "xml" ? this._saveView : this._saveLess).bind(this)));
}).bind(this));
return $.when.apply($, defs).fail((function (session, error) {
Dialog.alert(this, "", {
title: _t("Server error"),
$content: $("<div/>").html(
_t("A server error occured. Please check you correctly signed in and that the XML you are saving is well-formed.")
_t("A server error occured. Please check you correctly signed in and that the file you are saving is well-formed.")
+ "<br/>"
+ error
)
});
});
}).bind(this));
},
/**
* The private _saveView method is in charge of saving an unique XML view.
@@ -377,7 +564,29 @@ var ViewEditor = Widget.extend({
method: 'write',
args: [[session.id], {arch: session.text}, _.extend(base.get_context(), {lang: null})],
}).then(function () {
self._toggleDirtyInfo(session.id, false);
self._toggleDirtyInfo(session.id, "xml", false);
def.resolve();
}, function (source, error) {
def.reject(session, error);
});
return def;
},
/**
* The private _saveLess method is in charge of saving an unique LESS file.
* @param session: an object which contains the "id" (url) and the "text" of the view to save.
* @return a deferred whose status indicates if the save is finished or if an error occured.
*/
_saveLess: function (session) {
var def = $.Deferred();
var self = this;
ajax.jsonRpc("/web_editor/save_less", "call", {
url: session.id,
bundle_xmlid: this.less[session.id].bundle_xmlid,
content: session.text,
}).then(function () {
self._toggleDirtyInfo(session.id, "less", false);
def.resolve();
}, function (source, error) {
def.reject(session, error);
@@ -391,9 +600,10 @@ var ViewEditor = Widget.extend({
* number. If the _showErrorLine is called without argument, the effects are removed.
* @param line: the line number to highlight
* @param message: the message to show on click on the line number
* @param viewID: the id of the view whose line is to highlight
* @param resID: the ID/URL of the view/less file whose line is to highlight
* @param type: the type of the given resource
*/
_showErrorLine: function (line, message, viewID) {
_showErrorLine: function (line, message, resID, type) {
if (line === undefined || line <= 0) {
if (this.$errorLine) {
this.$errorLine.removeClass("o_error");
@@ -405,7 +615,9 @@ var ViewEditor = Widget.extend({
return;
}
if (this.selectedViewId() === viewID) {
if (type) this.switchType(type);
if (this.selectedResource() === resID) {
__showErrorLine.call(this, line);
} else {
var onChangeSession = (function () {
@@ -413,7 +625,7 @@ var ViewEditor = Widget.extend({
_.delay(__showErrorLine.bind(this, line), 400);
}).bind(this);
this.aceEditor.on('changeSession', onChangeSession);
this.displayView(viewID);
this.displayResource(resID, this.currentType);
}
function __showErrorLine(line) {
@@ -135,11 +135,16 @@
.o-justify-content(space-between);
padding: @grid-gutter-width/4;
> .o_ace_type_switcher > button::after {
.o-caret-down();
margin-left: 4px;
}
> * {
.o-flex(0, 0, auto);
margin: 0 @grid-gutter-width/4;
&.oe_include_bundles {
&.o_include_option {
.o-flex-display();
.o-align-items(center);
font-size: 11px;
+22 -3
View File
@@ -4,20 +4,39 @@
<t t-name="web_editor.ace_view_editor">
<div class="o_ace_view_editor">
<div class="form-inline o_ace_view_editor_title">
<div class="btn-group o_ace_type_switcher">
<button type="button" class="btn btn-sm btn-info dropdown-toggle" data-toggle="dropdown">XML (HTML)</button>
<ul class="dropdown-menu" role="menu">
<li><a href="#" class="o_ace_type_switcher_choice" data-type="xml">XML (HTML)</a></li>
<li><a href="#" class="o_ace_type_switcher_choice" data-type="less">LESS (CSS)</a></li>
</ul>
</div>
<select id="ace-view-list" class="form-control oe_view_list"/>
<label class="oe_include_bundles">
<select id="ace-less-list" class="form-control oe_view_list hidden"/>
<label class="o_include_option oe_include_bundles">
<div class="o_checkbox">
<input type="checkbox" class="js_include_bundles"/><span/>
</div>
Include Asset Bundles
</label>
<label class="o_include_option o_include_all_less hidden">
<div class="o_checkbox">
<input type="checkbox" class="js_include_all_less" t-att-checked="widget.options.includeAllLess ? 'checked' : undefined"/><span/>
</div>
Include All LESS Files
</label>
<div class="o_button_section">
<button data-action="save" type="submit" class="btn btn-sm btn-primary">Save</button>
<button data-action="format" type="button" class="btn btn-sm btn-default">Format</button>
<button data-action="close" type="button" class="btn btn-sm btn-default">Close</button>
</div>
</div>
<div id="ace-view-id"/>
<div id="ace-view-id">
<span/>
<div class="pull-right">
<button data-action="reset" type="button" class="btn btn-xs btn-danger"><i class="fa fa-undo"/> Reset</button>
<button data-action="format" type="button" class="btn btn-xs btn-link">Format</button>
</div>
</div>
<div id="ace-view-editor"/>
</div>
</t>
+19 -6
View File
@@ -26,14 +26,22 @@ var Ace = Widget.extend({
this.globalEditor.do_show();
} else {
var currentHash = window.location.hash;
var indexOfView = currentHash.indexOf("?view=");
var initialViewID = undefined;
var indexOfView = currentHash.indexOf("?res=");
var initialResID = undefined;
if (indexOfView >= 0) {
initialViewID = parseInt(currentHash.substr(indexOfView + 6), 10);
initialResID = currentHash.substr(indexOfView + ("?res=".length));
var parsedResID = parseInt(initialResID, 10);
if (parsedResID) {
initialResID = parsedResID;
}
}
this.globalEditor = new ViewEditor(this, $(document.documentElement).data('view-xmlid'), {
initialViewID: initialViewID
initialResID: initialResID,
defaultBundlesRestriction: [
"web.assets_frontend",
"website.assets_frontend",
],
});
this.globalEditor.appendTo(document.body);
@@ -46,7 +54,7 @@ var Ace = Widget.extend({
* Extend the default view editor so that the URL hash is updated with view id.
*/
ViewEditor = ViewEditor.extend({
displayView: function () {
displayResource: function () {
this._super.apply(this, arguments);
this._updateHash();
},
@@ -56,12 +64,17 @@ ViewEditor = ViewEditor.extend({
window.location.reload();
}).bind(this));
},
resetResource: function () {
return this._super.apply(this, arguments).then((function () {
window.location.reload();
}).bind(this));
},
do_hide: function () {
this._super.apply(this, arguments);
window.location.hash = "";
},
_updateHash: function () {
window.location.hash = hash + "?view=" + this.selectedViewId();
window.location.hash = hash + "?res=" + this.selectedResource();
},
});
@@ -47,7 +47,7 @@
<li class="dropdown" id="customize-menu">
<a class="dropdown-toggle waves" data-toggle="dropdown" href="#" id="customize-menu-button">Customize</a>
<ul class="dropdown-menu" role="menu">
<li id="html_editor"><a data-action="ace" href="#advanced-view-editor">HTML Editor</a></li>
<li id="html_editor"><a data-action="ace" href="#advanced-view-editor">HTML/CSS Editor</a></li>
<li id="theme_customize"><a href="#">Customize Theme</a></li>
<li id="install_apps"><a href="/web#return_label=Website&amp;action=website.action_module_website">Install Apps</a></li>
<li class="divider"/>