diff --git a/addons/mail/models/ir_http.py b/addons/mail/models/ir_http.py index e8d446aa040..2c99c97c2b3 100644 --- a/addons/mail/models/ir_http.py +++ b/addons/mail/models/ir_http.py @@ -3,7 +3,6 @@ import odoo from odoo import models from odoo.http import request -from odoo.addons.web.controllers.utils import HomeStaticTemplateHelpers class IrHttp(models.AbstractModel): @@ -14,8 +13,6 @@ class IrHttp(models.AbstractModel): result = super(IrHttp, self).session_info() if self.env.user._is_internal(): result['notification_type'] = user.notification_type - assets_discuss_public_hash = HomeStaticTemplateHelpers.get_qweb_templates_checksum(debug=request.session.debug, bundle='mail.assets_discuss_public') - result['cache_hashes']['assets_discuss_public'] = assets_discuss_public_hash guest = self.env.context.get('guest') if not request.session.uid and guest: user_context = {'lang': guest.lang} diff --git a/addons/mail/static/src/public/discuss_public_boot.js b/addons/mail/static/src/public/discuss_public_boot.js index 792664030ed..db40fc016a3 100644 --- a/addons/mail/static/src/public/discuss_public_boot.js +++ b/addons/mail/static/src/public/discuss_public_boot.js @@ -8,7 +8,6 @@ import { DiscussPublicViewContainer } from '@mail/components/discuss_public_view import { PopoverManagerContainer } from '@mail/components/popover_manager_container/popover_manager_container'; import { messagingService } from '@mail/services/messaging_service'; -import { processTemplates } from '@web/core/assets'; import { MainComponentsContainer } from '@web/core/main_components_container'; import { registry } from '@web/core/registry'; import { makeEnv, startServices } from '@web/env'; @@ -21,6 +20,7 @@ import { mapLegacyEnvToWowlEnv, } from '@web/legacy/utils'; import { session } from '@web/session'; +import { templates } from '@web/core/assets'; import * as AbstractService from 'web.AbstractService'; import * as legacyEnv from 'web.env'; @@ -69,10 +69,7 @@ Component.env = legacyEnv; isReady: false, }); const env = makeEnv(); - const [, templates] = await Promise.all([ - startServices(env), - odoo.loadTemplatesPromise.then(processTemplates), - ]); + await startServices(env); mapLegacyEnvToWowlEnv(Component.env, env); odoo.isReady = true; await mount(MainComponentsContainer, document.body, { env, templates, dev: env.debug }); diff --git a/addons/mail/views/discuss_public_templates.xml b/addons/mail/views/discuss_public_templates.xml index 22e90e0d2cf..b22ab14cd4f 100644 --- a/addons/mail/views/discuss_public_templates.xml +++ b/addons/mail/views/discuss_public_templates.xml @@ -15,7 +15,6 @@ csrf_token: "", debug: "", }; - odoo.loadTemplatesPromise = fetch(`/web/webclient/qweb/${odoo.__session_info__.cache_hashes.assets_discuss_public}?bundle=mail.assets_discuss_public`).then(doc => doc.text()); { const { user_context, cache_hashes } = odoo.__session_info__; fetch(`/web/webclient/translations/${cache_hashes.translations}?lang=${user_context.lang}`); diff --git a/addons/mrp_subcontracting/controllers/portal.py b/addons/mrp_subcontracting/controllers/portal.py index c428cf02a54..6343fdadfdb 100644 --- a/addons/mrp_subcontracting/controllers/portal.py +++ b/addons/mrp_subcontracting/controllers/portal.py @@ -9,7 +9,6 @@ from odoo.http import request from odoo.exceptions import AccessError, MissingError from odoo.addons.portal.controllers import portal from odoo.addons.portal.controllers.portal import pager as portal_pager -from odoo.addons.web.controllers.utils import HomeStaticTemplateHelpers class CustomerPortal(portal.CustomerPortal): @@ -91,11 +90,9 @@ class CustomerPortal(portal.CustomerPortal): session_info = request.env['ir.http'].session_info() user_context = dict(request.env.context) if request.session.uid else {} mods = conf.server_wide_modules or [] - qweb_checksum = HomeStaticTemplateHelpers.get_qweb_templates_checksum(debug=request.session.debug, bundle="mrp_subcontracting.assets_qweb") lang = user_context.get("lang") translation_hash = request.env['ir.translation'].get_web_translations_hash(mods, lang) cache_hashes = { - "qweb": qweb_checksum, "translations": translation_hash, } production_company = picking.company_id diff --git a/addons/mrp_subcontracting/views/subcontracting_portal_templates.xml b/addons/mrp_subcontracting/views/subcontracting_portal_templates.xml index 29bd37c362a..0dfccf97bd8 100644 --- a/addons/mrp_subcontracting/views/subcontracting_portal_templates.xml +++ b/addons/mrp_subcontracting/views/subcontracting_portal_templates.xml @@ -70,7 +70,6 @@ diff --git a/addons/point_of_sale/views/pos_assets_index.xml b/addons/point_of_sale/views/pos_assets_index.xml index 833d8ad2897..ae8fcb34133 100644 --- a/addons/point_of_sale/views/pos_assets_index.xml +++ b/addons/point_of_sale/views/pos_assets_index.xml @@ -36,7 +36,6 @@ // Prevent the menu_service to load anything. In an ideal world, POS assets would only contain // what is genuinely necessary, and not the whole backend. odoo.loadMenusPromise = Promise.resolve(); - odoo.loadTemplatesPromise = fetch(`/web/webclient/qweb/${odoo.__session_info__.cache_hashes.qweb}?bundle=web.assets_qweb`).then(doc => doc.text()); diff --git a/addons/project/controllers/portal.py b/addons/project/controllers/portal.py index eccc59f7fac..105fbe6cb1d 100644 --- a/addons/project/controllers/portal.py +++ b/addons/project/controllers/portal.py @@ -12,8 +12,6 @@ from odoo.tools import groupby as groupbyelem from odoo.osv.expression import OR, AND -from odoo.addons.web.controllers.utils import HomeStaticTemplateHelpers - class ProjectCustomerPortal(CustomerPortal): @@ -135,7 +133,6 @@ class ProjectCustomerPortal(CustomerPortal): session_info = request.env['ir.http'].session_info() user_context = dict(request.env.context) if request.session.uid else {} mods = conf.server_wide_modules or [] - qweb_checksum = HomeStaticTemplateHelpers.get_qweb_templates_checksum(debug=request.session.debug, bundle="project.assets_qweb") if request.env.lang: lang = request.env.lang session_info['user_context']['lang'] = lang @@ -144,7 +141,6 @@ class ProjectCustomerPortal(CustomerPortal): lang = user_context.get("lang") translation_hash = request.env['ir.translation'].get_web_translations_hash(mods, lang) cache_hashes = { - "qweb": qweb_checksum, "translations": translation_hash, } diff --git a/addons/project/views/project_sharing_templates.xml b/addons/project/views/project_sharing_templates.xml index d404721055a..5182521f05d 100644 --- a/addons/project/views/project_sharing_templates.xml +++ b/addons/project/views/project_sharing_templates.xml @@ -21,7 +21,6 @@ // Prevent the menu_service to load anything. In an ideal world, Project Sharing assets would only contain // what is genuinely necessary, and not the whole backend. odoo.loadMenusPromise = Promise.resolve(); - odoo.loadTemplatesPromise = fetch(`/web/webclient/qweb/${odoo.__session_info__.cache_hashes.qweb}?bundle=project.assets_qweb`).then(doc => doc.text()); diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index 31cf8bdbd9e..9eaeafc82ea 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -406,6 +406,7 @@ This module provides the core of the Odoo Web Client. 'web/static/lib/clipboard/clipboard.js', 'web/static/lib/jSignature/jSignatureCustom.js', 'web/static/lib/qweb/qweb2.js', + 'web/static/src/legacy/js/assets.js', 'web/static/src/legacy/js/libs/autocomplete.js', 'web/static/src/legacy/js/libs/bootstrap.js', 'web/static/src/legacy/js/libs/content-disposition.js', diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 6071debe315..6054d838471 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -32,7 +32,6 @@ _MOVED_TO_MAP = { 'GroupExportXlsxWriter': export, 'GroupsTreeNode': export, 'Home': home, - 'HomeStaticTemplateHelpers': utils, 'none_values_filtered': export, 'OPERATOR_MAPPING': export, 'ReportController': report, diff --git a/addons/web/controllers/utils.py b/addons/web/controllers/utils.py index c076e47cb23..cf625a623c9 100644 --- a/addons/web/controllers/utils.py +++ b/addons/web/controllers/utils.py @@ -215,212 +215,3 @@ def _local_web_translations(trans_file): if x.id and x.string and "openerp-web" in x.auto_comments: messages.append({'id': x.id, 'string': x.string}) return messages - - -class HomeStaticTemplateHelpers: - r""" - Helper Class that wraps the reading of static qweb templates files - and xpath inheritance applied to those templates - /!\ Template inheritance order is defined by ir.module.module natural order - which is "sequence, name" - Then a topological sort is applied, which just puts dependencies - of a module before that module - """ - NAME_TEMPLATE_DIRECTIVE = 't-name' - STATIC_INHERIT_DIRECTIVE = 't-inherit' - STATIC_INHERIT_MODE_DIRECTIVE = 't-inherit-mode' - PRIMARY_MODE = 'primary' - EXTENSION_MODE = 'extension' - DEFAULT_MODE = PRIMARY_MODE - COMMENT_PATTERN = r'Modified by [\s\w\-.]+ from [\s\w\-.]+' - - def __init__(self, addons, db, checksum_only=False, debug=False): - """ - :param str|list addons: plain list or comma separated list of addons - :param str db: the current db we are working on - :param bool checksum_only: only computes the checksum of all files for addons - :param str debug: the debug mode of the session - """ - self.addons = addons.split(',') if isinstance(addons, str) else addons - self.db = db - self.debug = debug - self.checksum_only = checksum_only - self.template_dict = OrderedDict() - - def _get_parent_template(self, addon, template): - """Computes the real addon name and the template name - of the parent template (the one that is inherited from) - - :param str addon: the addon the template is declared in - :param etree template: the current template we are are handling - :returns: (str, str) - """ - original_template_name = template.attrib[self.STATIC_INHERIT_DIRECTIVE] - split_name_attempt = original_template_name.split('.', 1) - parent_addon, parent_name = tuple(split_name_attempt) if len(split_name_attempt) == 2 else (addon, original_template_name) - if parent_addon not in self.template_dict: - if original_template_name in self.template_dict[addon]: - parent_addon = addon - parent_name = original_template_name - else: - raise ValueError(_('Module %s not loaded or inexistent, or templates of addon being loaded (%s) are misordered') % (parent_addon, addon)) - - if parent_name not in self.template_dict[parent_addon]: - raise ValueError(_("No template found to inherit from. Module %s and template name %s") % (parent_addon, parent_name)) - - return parent_addon, parent_name - - def _compute_xml_tree(self, addon, file_name, source): - """Computes the xml tree that 'source' contains - Applies inheritance specs in the process - - :param str addon: the current addon we are reading files for - :param str file_name: the current name of the file we are reading - :param str source: the content of the file - :returns: etree - """ - try: - all_templates_tree = etree.parse(io.BytesIO(source), parser=etree.XMLParser(remove_comments=True)).getroot() - except etree.ParseError as e: - _logger.error("Could not parse file %s: %s", file_name, e.msg) - raise - - self.template_dict.setdefault(addon, OrderedDict()) - for template_tree in list(all_templates_tree): - if self.NAME_TEMPLATE_DIRECTIVE in template_tree.attrib: - template_name = template_tree.attrib[self.NAME_TEMPLATE_DIRECTIVE] - dotted_names = template_name.split('.', 1) - if len(dotted_names) > 1 and dotted_names[0] == addon: - template_name = dotted_names[1] - else: - # self.template_dict[addon] grows after processing each template - template_name = 'anonymous_template_%s' % len(self.template_dict[addon]) - if self.STATIC_INHERIT_DIRECTIVE in template_tree.attrib: - inherit_mode = template_tree.attrib.get(self.STATIC_INHERIT_MODE_DIRECTIVE, self.DEFAULT_MODE) - if inherit_mode not in [self.PRIMARY_MODE, self.EXTENSION_MODE]: - raise ValueError(_("Invalid inherit mode. Module %s and template name %s") % (addon, template_name)) - - parent_addon, parent_name = self._get_parent_template(addon, template_tree) - - # After several performance tests, we found out that deepcopy is the most efficient - # solution in this case (compared with copy, xpath with '.' and stringifying). - parent_tree = copy.deepcopy(self.template_dict[parent_addon][parent_name]) - - xpaths = list(template_tree) - # owl chokes on comments, disable debug comments for now - # pylint: disable=W0125 - if False: # self.debug and inherit_mode == self.EXTENSION_MODE: - for xpath in xpaths: - xpath.insert(0, etree.Comment(" Modified by %s from %s " % (template_name, addon))) - elif inherit_mode == self.PRIMARY_MODE: - parent_tree.tag = template_tree.tag - inherited_template = apply_inheritance_specs(parent_tree, xpaths) - - if inherit_mode == self.PRIMARY_MODE: # New template_tree: A' = B(A) - for attr_name, attr_val in template_tree.attrib.items(): - if attr_name not in ('t-inherit', 't-inherit-mode'): - inherited_template.set(attr_name, attr_val) - if self.debug: - self._remove_inheritance_comments(inherited_template) - self.template_dict[addon][template_name] = inherited_template - - else: # Modifies original: A = B(A) - self.template_dict[parent_addon][parent_name] = inherited_template - else: - if template_name in self.template_dict[addon]: - raise ValueError(_("Template %s already exists in module %s") % (template_name, addon)) - self.template_dict[addon][template_name] = template_tree - return all_templates_tree - - def _remove_inheritance_comments(self, inherited_template): - '''Remove the comments added in the template already, they come from other templates extending - the base of this inheritance - - :param inherited_template: - ''' - for comment in inherited_template.xpath('//comment()'): - if re.match(self.COMMENT_PATTERN, comment.text.strip()): - comment.getparent().remove(comment) - - def _read_addon_file(self, path_or_url): - """Read the content of a file or an ``ir.attachment`` record given by - ``path_or_url``. - - :param str path_or_url: - :returns: bytes - :raises FileNotFoundError: if the path does not match a module file - or an attachment - """ - try: - with file_open(path_or_url, 'rb') as fp: - contents = fp.read() - except FileNotFoundError as e: - attachment = request.env['ir.attachment'].sudo().search([ - ('url', '=', path_or_url), - ('type', '=', 'binary'), - ], limit=1) - if attachment: - contents = attachment.raw - else: - raise e - return contents - - def _concat_xml(self, file_dict): - """Concatenate xml files - - :param dict(list) file_dict: - key: addon name - value: list of files for an addon - :returns: (concatenation_result, checksum) - :rtype: (bytes, str) - """ - checksum = hashlib.new('sha512') # sha512/256 - if not file_dict: - return b'', checksum.hexdigest() - - root = None - for addon, fnames in file_dict.items(): - for fname in fnames: - contents = self._read_addon_file(fname) - checksum.update(contents) - if not self.checksum_only: - self._compute_xml_tree(addon, fname, contents) - - if root is None: - root = etree.Element('templates') - - for addon in self.template_dict.values(): - for template in addon.values(): - root.append(template) - - return etree.tostring(root, encoding='utf-8') if root is not None else b'', checksum.hexdigest()[:64] - - def _get_asset_paths(self, bundle): - """Proxy for ir_asset._get_asset_paths - Useful to make 'self' testable. - """ - return request.env['ir.asset']._get_asset_paths(addons=self.addons, bundle=bundle, xml=True) - - def _get_qweb_templates(self, bundle): - """One and only entry point that gets and evaluates static qweb templates - - :rtype: (str, str) - """ - xml_paths = defaultdict(list) - - # group paths by module, keeping them in order - for path, addon, _ in self._get_asset_paths(bundle): - addon_paths = xml_paths[addon] - if path not in addon_paths: - addon_paths.append(path) - - content, checksum = self._concat_xml(xml_paths) - return content, checksum - - @classmethod - def get_qweb_templates_checksum(cls, addons=None, db=None, debug=False, bundle=None): - return cls(addons, db, checksum_only=True, debug=debug)._get_qweb_templates(bundle)[1] - - @classmethod - def get_qweb_templates(cls, addons=None, db=None, debug=False, bundle=None): - return cls(addons, db, debug=debug)._get_qweb_templates(bundle)[0] diff --git a/addons/web/controllers/webclient.py b/addons/web/controllers/webclient.py index 66406771701..d5e1b7e76ac 100644 --- a/addons/web/controllers/webclient.py +++ b/addons/web/controllers/webclient.py @@ -17,7 +17,7 @@ from odoo.modules import get_manifest, get_resource_path from odoo.http import request from odoo.tools import lazy from odoo.tools.misc import file_open -from .utils import HomeStaticTemplateHelpers, _local_web_translations +from .utils import _local_web_translations _logger = logging.getLogger(__name__) @@ -53,17 +53,6 @@ class WebClient(http.Controller): ('Cache-Control', f'max-age={http.STATIC_CACHE}'), ]) - @http.route('/web/webclient/qweb/', type='http', auth="none", cors="*") - def qweb(self, unique, mods=None, db=None, bundle=None): - if not request.db and mods is None: - mods = odoo.conf.server_wide_modules or [] - content = HomeStaticTemplateHelpers.get_qweb_templates(mods, db, debug=request.session.debug, bundle=bundle) - - return request.make_response(content, [ - ('Content-Type', 'text/xml'), - ('Cache-Control', f'public, max-age={http.STATIC_CACHE_LONG}') - ]) - @http.route('/web/webclient/bootstrap_translations', type='json', auth="none") def bootstrap_translations(self, mods=None): """ Load local translations from *.po files, as a temporary solution @@ -139,14 +128,20 @@ class WebClient(http.Controller): def benchmarks(self, mod=None, **kwargs): return request.render('web.benchmark_suite') - @http.route('/web/bundle/', auth="user", methods=["GET"]) - def bundle(self, bundle_name): + @http.route('/web/bundle/', auth="public", methods=["GET"]) + def bundle(self, bundle_name, **bundle_params): """ Request the definition of a bundle, including its javascript and css bundled assets """ - files = request.env["ir.qweb"]._get_asset_nodes(bundle_name, debug=request.session.debug, js=True, css=True) - data = json.dumps([{ + if 'lang' in bundle_params: + request.update_context(lang=bundle_params['lang']) + + debug = bundle_params.get('debug', request.session.debug) + files = request.env["ir.qweb"]._get_asset_nodes(bundle_name, debug=debug, js=True, css=True) + data = [{ "type": tag, - "src": attrs.get("src") or attrs.get('href'), - } for tag, attrs, _ in files]) - return request.make_response(data, [('Content-Type', 'application/json')]) + "src": attrs.get("src") or attrs.get("data-src") or attrs.get('href'), + "content": content, + } for tag, attrs, content in files] + + return request.make_json_response(data) diff --git a/addons/web/models/ir_http.py b/addons/web/models/ir_http.py index 027e956e6b0..1be2dfa9f20 100644 --- a/addons/web/models/ir_http.py +++ b/addons/web/models/ir_http.py @@ -9,7 +9,6 @@ from odoo import api, http, models from odoo.http import request from odoo.tools import file_open, image_process, ustr from odoo.tools.misc import str2bool -from odoo.addons.web.controllers.utils import HomeStaticTemplateHelpers _logger = logging.getLogger(__name__) @@ -110,7 +109,12 @@ class Http(models.AbstractModel): ) if session_uid else None, }, "currencies": self.sudo().get_currencies(), + 'bundle_params': { + 'lang': request.session.context['lang'], + }, } + if request.session.debug: + session_info['bundle_params']['debug'] = request.session.debug if self.env.user.has_group('base.group_user'): # the following is only useful in the context of a webclient bootstrapping # but is still included in some other calls (e.g. '/web/session/authenticate') @@ -118,13 +122,11 @@ class Http(models.AbstractModel): # with access to the backend ('internal'-type users) if request.db: mods = list(request.registry._init_modules) + mods - qweb_checksum = HomeStaticTemplateHelpers.get_qweb_templates_checksum(debug=request.session.debug, bundle="web.assets_qweb") menus = request.env['ir.ui.menu'].load_menus(request.session.debug) ordered_menus = {str(k): v for k, v in menus.items()} menu_json_utf8 = json.dumps(ordered_menus, default=ustr, sort_keys=True).encode() session_info['cache_hashes'].update({ "load_menus": hashlib.sha512(menu_json_utf8).hexdigest()[:64], # sha512/256 - "qweb": qweb_checksum, }) session_info.update({ # current_company should be default_company @@ -157,7 +159,12 @@ class Http(models.AbstractModel): 'profile_collectors': request.session.profile_collectors, 'profile_params': request.session.profile_params, 'show_effect': bool(request.env['ir.config_parameter'].sudo().get_param('base_setup.show_effect')), + 'bundle_params': { + 'lang': request.session.context['lang'], + }, } + if request.session.debug: + session_info['bundle_params']['debug'] = request.session.debug if session_uid: version_info = odoo.service.common.exp_version() session_info.update({ diff --git a/addons/web/static/src/boot.js b/addons/web/static/src/boot.js index 2006b59466f..0e573ac123a 100644 --- a/addons/web/static/src/boot.js +++ b/addons/web/static/src/boot.js @@ -111,6 +111,93 @@ return !!job.error; }); }, + processJobs: function () { + var job; + + function processJob(job) { + var require = makeRequire(job); + + var jobExec; + function onError(e) { + job.error = e; + console.error(`Error while loading ${job.name}: ${e.message}`, e); + } + var def = new Promise(function (resolve) { + try { + jobExec = job.factory.call(null, require); + jobs.splice(jobs.indexOf(job), 1); + } catch (e) { + onError(e); + } + if (!job.error) { + Promise.resolve(jobExec) + .then(function (data) { + services[job.name] = data; + resolve(); + odoo.__DEBUG__.processJobs(); + }) + .guardedCatch(function (e) { + job.rejected = e || true; + jobs.push(job); + }) + .catch(function (e) { + if (e instanceof Error) { + onError(e); + } + resolve(); + }); + } else { + resolve(); + } + }); + jobPromises.push(def); + def.then(job.resolve); + } + + function isReady(job) { + return ( + !job.error && + !job.rejected && + job.factory.deps.every(function (name) { + return name in services; + }) + ); + } + + function makeRequire(job) { + var deps = {}; + Object.keys(services) + .filter(function (item) { + return job.deps.indexOf(item) >= 0; + }) + .forEach(function (key) { + deps[key] = services[key]; + }); + + return function require(name) { + if (!(name in deps)) { + console.error("Undefined dependency: ", name); + } + return deps[name]; + }; + } + + while (jobs.length) { + job = undefined; + for (var i = 0; i < jobs.length; i++) { + if (isReady(jobs[i])) { + job = jobs[i]; + break; + } + } + if (!job) { + break; + } + processJob(job); + } + + return services; + }, factories: factories, services: services, }; @@ -147,17 +234,21 @@ factory.deps = deps; factories[name] = factory; + let promiseResolve; + const promise = new Promise(resolve => {promiseResolve = resolve;}); jobs.push({ name: name, factory: factory, deps: deps, + resolve: promiseResolve, + promise: promise, }); deps.forEach(function (dep) { jobDeps.push({ from: dep, to: name }); }); - this.processJobs(jobs, services); + odoo.__DEBUG__.processJobs(); }; odoo.log = function () { var missing = []; @@ -271,91 +362,21 @@ }; didLogInfoResolve(true); }; - odoo.processJobs = function (jobs, services) { - var job; - - function processJob(job) { - var require = makeRequire(job); - - var jobExec; - function onError(e) { - job.error = e; - console.error(`Error while loading ${job.name}: ${e.message}`, e); - } - var def = new Promise(function (resolve) { - try { - jobExec = job.factory.call(null, require); - jobs.splice(jobs.indexOf(job), 1); - } catch (e) { - onError(e); - } - if (!job.error) { - Promise.resolve(jobExec) - .then(function (data) { - services[job.name] = data; - resolve(); - odoo.processJobs(jobs, services); - }) - .guardedCatch(function (e) { - job.rejected = e || true; - jobs.push(job); - }) - .catch(function (e) { - if (e instanceof Error) { - onError(e); - } - resolve(); - }); - } else { - resolve(); - } - }); - jobPromises.push(def); + /** + * Returns a resolved promise when the targeted services are loaded. + * If no service is found the promise is used directly. + * + * @param {string|RegExp} serviceName name of the service to expect + * or regular expression matching the service. + * @returns {Promise} resolved when the services ares + * loaded. The value is equal to the number of services found. + */ + odoo.ready = async function (serviceName) { + function match (name) { + return typeof serviceName === 'string' ? name === serviceName : serviceName.test(name); } - - function isReady(job) { - return ( - !job.error && - !job.rejected && - job.factory.deps.every(function (name) { - return name in services; - }) - ); - } - - function makeRequire(job) { - var deps = {}; - Object.keys(services) - .filter(function (item) { - return job.deps.indexOf(item) >= 0; - }) - .forEach(function (key) { - deps[key] = services[key]; - }); - - return function require(name) { - if (!(name in deps)) { - console.error("Undefined dependency: ", name); - } - return deps[name]; - }; - } - - while (jobs.length) { - job = undefined; - for (var i = 0; i < jobs.length; i++) { - if (isReady(jobs[i])) { - job = jobs[i]; - break; - } - } - if (!job) { - break; - } - processJob(job); - } - - return services; + await Promise.all(jobs.filter(job => match(job.name)).map(job => job.promise)); + return Object.keys(factories).filter(match).length; }; // Automatically log errors detected when loading modules diff --git a/addons/web/static/src/core/assets.js b/addons/web/static/src/core/assets.js index f84799eb2d8..705f0110227 100644 --- a/addons/web/static/src/core/assets.js +++ b/addons/web/static/src/core/assets.js @@ -3,16 +3,16 @@ import { memoize } from "./utils/functions"; import { browser } from "./browser/browser"; import { registry } from "./registry"; - -class AssetsLoadingError extends Error {} +import { session } from "@web/session"; /** - * An object describing a bundle to load - * @typedef {Object} BundleInfo - * @property {'script'|'link'} [type] the type of file in this bundle - * @property {string} [src] the url of the file for this bundle, for this type of file - * @example `[{"type": "script", "src": "/web/assets/266-d34b0b4/documents_spreadsheet.o_spreadsheet.min.js"}]` + * This export is done only in order to modify the behavior of the exported + * functions. This is done in order to be able to make a test environment. + * Modules should only use the methods exported below. */ +export const assets = {}; + +class AssetsLoadingError extends Error {} /** * Loads the given url inside a script tag. @@ -20,7 +20,7 @@ class AssetsLoadingError extends Error {} * @param {string} url the url of the script * @returns {Promise} resolved when the script has been loaded */ -export const loadJS = memoize(function loadJS(url) { +assets.loadJS = memoize(function loadJS(url) { if (document.querySelector(`script[src="${url}"]`)) { // Already in the DOM and wasn't loaded through this function // Unfortunately there is no way to check whether a script has loaded @@ -28,6 +28,7 @@ export const loadJS = memoize(function loadJS(url) { // so we assume it is. return Promise.resolve(); } + const scriptEl = document.createElement("script"); scriptEl.type = "text/javascript"; scriptEl.src = url; @@ -39,13 +40,14 @@ export const loadJS = memoize(function loadJS(url) { }); }); }); + /** * Loads the given url as a stylesheet. * * @param {string} url the url of the stylesheet * @returns {Promise} resolved when the stylesheet has been loaded */ -export const loadCSS = memoize(function loadCSS(url) { +assets.loadCSS = memoize(function loadCSS(url) { if (document.querySelector(`link[href="${url}"]`)) { // Already in the DOM and wasn't loaded through this function // Unfortunately there is no way to check whether a link has loaded @@ -65,110 +67,187 @@ export const loadCSS = memoize(function loadCSS(url) { }); }); }); -/** - * Loads the qweb templates from a given bundle name. - * TODO: merge this into loadBundleDefinition? - * - * @param {string} bundle the name of the bundle as declared in the manifest. - * @returns {Promise} A Promise of an XML document containing - * the owl templates or an empty string if the bundle has none. - */ -export const fetchAndProcessTemplates = memoize(async function fetchAndProcessTemplates(bundle) { - // TODO: quid of the "unique" in the URL? We can't have one cache_hash - // for each and every bundle I'm guessing. - const bundleURL = `/web/webclient/qweb/${Date.now()}?bundle=${bundle}`; - const templates = await (await browser.fetch(bundleURL)).text(); - if (!templates) { - return ""; - } - return processTemplates(templates); -}); /** - * Loads the content definition of a bundle. - * - * @param {string} name the bundleName of the bundle as declared in the manifest. - * @returns {Promise} A promise of the content definition of the bundle + * Container dom containing all the owl templates that have been loaded. + * This can be imported by the modules in order to use it when loading the + * application and the components. */ -const loadBundleDefinition = memoize(async function (bundleName) { - const request = await browser.fetch(`/web/bundle/${bundleName}`); - return await request.json(); -}); - -const bundlesCache = {}; +export const templates = new DOMParser().parseFromString("", "text/xml"); +let defaultApp; /** - * Loads a bundle. + * Loads the given xml template. * - * @param {string} name the name of the bundle to load - * @param {owl.App} [app] the app in which the bundle's templates should be - * loaded. Defaults to the app that's written on the function itself, this - * is considered the main app, and should be written on the function by the - * code that bootstraps the app. In most cases, this will be the webclient, - * and is set in start.js - * @returns {Promise} a promise that is resolved after the bundle has been - * loaded. + * @param {string} xml the string defining the templates + * @param {App} [app=defaultApp] optional owl App instance (default value + * can be changed with setLoadXmlDefaultApp method) + * @returns {Promise} resolved when the template xml has been loaded */ -export async function loadBundle(name, app = loadBundle.app) { - if (!bundlesCache[name]) { - bundlesCache[name] = Promise.all([ - fetchAndProcessTemplates(name).then((templates) => app.addTemplates(templates, app)), - loadBundleDefinition(name).then((bundleInfo) => - Promise.all([ - ...bundleInfo.filter((i) => i.type === "script").map((i) => loadJS(i.src)), - ...bundleInfo.filter((i) => i.type === "link").map((i) => loadCSS(i.src)), - ]) - ), - ]).then(() => {}); + assets.loadXML = function loadXML (xml, app=defaultApp) { + const doc = new DOMParser().parseFromString(xml, "text/xml"); + if (doc.querySelector('parsererror')) { + throw doc.querySelector('parsererror div').textContent.split(':')[0]; } - return bundlesCache[name]; -} - -/** - * Process the qweb templates to obtain only the owl templates. This function - * does NOT register the templates into Owl. - * - * @param {string} templates An xml string describing templates - * @returns {XMLDocument} An xml document containing only the owl templates - */ -export function processTemplates(templates) { - const doc = new DOMParser().parseFromString(templates, "text/xml"); - // as we currently have two qweb engines (owl and legacy), owl templates are - // flagged with attribute `owl="1"`. The following lines removes the "owl" - // attribute from the templates, so that it doesn't appear in the DOM. We - // also remove the non-owl templates, as those shouldn't be loaded in the - // owl application, and will be loaded separately. - for (const template of [...doc.querySelector("templates").children]) { - if (template.hasAttribute("owl")) { - template.removeAttribute("owl"); + for (const element of doc.querySelectorAll("templates > [t-name][owl]")) { + element.removeAttribute("owl"); + const name = element.getAttribute('t-name'); + const previous = templates.querySelector(`[t-name="${name}"]`); + if (previous) { + console.debug('Override template: ' + name); + previous.replaceWith(element); } else { - template.remove(); + templates.documentElement.appendChild(element); } } - return doc; + if (app || defaultApp) { + console.debug('Add templates in Owl app.'); + app.addTemplates(templates, app || defaultApp); + } else { + console.debug('Add templates on window Owl container.'); + } +}; +/** + * Update the default app to load templates. + * + * @param {App} app owl App instance + */ +export function setLoadXmlDefaultApp(app) { + defaultApp = app; } /** - * Renders a public asset template and loads the libraries defined inside of it. - * Only loads js and css, template declarations will be ignored. Only loads - * scripts and styles that are defined in script src and link href, ignores - * inline scripts and styles. + * Get the files information as descriptor object from a public asset template. * - * @deprecated - * @param {string} xmlid The xmlid of the template that defines the public asset - * @param {ORM} orm An ORM object capable of calling methods on models - * @returns {Promise} Resolved when the contents of the asset is loaded + * @param {string} bundleName Name of the bundle containing the list of files + * @returns {Promise<{cssLibs, cssContents, jsLibs, jsContents}>} */ -export const loadPublicAsset = memoize(async function loadPublicAsset(xmlid, orm) { - const xml = await orm.call("ir.ui.view", "render_public_asset", [xmlid]); - const doc = new DOMParser().parseFromString(`${xml}`, "text/xml"); - return Promise.all([ - ...[...doc.querySelectorAll("link[href]")].map((el) => loadCSS(el.getAttribute("href"))), - ...[...doc.querySelectorAll("script[src]")].map((el) => loadJS(el.getAttribute("src"))), - ]); +assets.getBundle = memoize(async function getBundle(bundleName) { + const url = new URL(`/web/bundle/${bundleName}`, location.origin); + for (const [key, value] of Object.entries(session.bundle_params || {})) { + url.searchParams.set(key, value); + } + const response = await browser.fetch(url.href); + const json = await response.json(); + const assets = { + cssLibs: [], + cssContents: [], + jsLibs: [], + jsContents: [], + }; + for (const key in json) { + const file = json[key]; + if (file.type === 'link') { + assets.cssLibs.push(file.src); + } else if (file.type === 'style') { + assets.cssContents.push(file.content); + } else { + if (file.src) { + assets.jsLibs.push(file.src); + } else { + assets.jsContents.push(file.content); + } + } + } + return assets; }); +/** + * Loads the given js/css libraries and asset bundles. Note that no library or + * asset will be loaded if it was already done before. + * + * @param {Object} desc + * @param {Array} [desc.assetLibs=[]] + * The list of assets to load. Each list item may be a string (the xmlID + * of the asset to load) or a list of strings. The first level is loaded + * sequentially (so use this if the order matters) while the assets in + * inner lists are loaded in parallel (use this for efficiency but only + * if the order does not matter, should rarely be the case for assets). + * @param {string[]} [desc.cssLibs=[]] + * The list of CSS files to load. They will all be loaded in parallel but + * put in the DOM in the given order (only the order in the DOM is used + * to determine priority of CSS rules, not loaded time). + * @param {Array} [desc.jsLibs=[]] + * The list of JS files to load. Each list item may be a string (the URL + * of the file to load) or a list of strings. The first level is loaded + * sequentially (so use this if the order matters) while the files in inner + * lists are loaded in parallel (use this for efficiency but only + * if the order does not matter). + * @param {string[]} [desc.cssContents=[]] + * List of inline styles to add after loading the CSS files. + * @param {string[]} [desc.jsContents=[]] + * List of inline scripts to add after loading the JS files. + * + * @returns {Promise} + */ +assets.loadBundle = memoize(async function loadBundle(desc) { + // Load css in parallel + const promiseCSS = Promise.all((desc.cssLibs || []).map(assets.loadCSS)).then(() => { + if (desc.cssContents && desc.cssContents.length) { + const style = document.createElement("style"); + style.textContent = desc.cssContents.join('\n'); + document.head.appendChild(style); + } + }); + // Load JavaScript (don't wait for the css loading) + for (const urlData of desc.jsLibs || []) { + if (typeof urlData === 'string') { + // serial loading + await assets.loadJS(urlData); + // Wait template if the JavaScript come from bundle. + const bundle = urlData.match(/\/web\/assets\/.*\/([^\/]+?)(\.min)?\.js/); + if (bundle) { + await odoo.ready(bundle[1] + '.bundle.xml'); + } + } else { + // parallel loading + await Promise.all(urlData.map(loadJS)); + } + } + + if (desc.jsContents && desc.jsContents.length) { + const script = document.createElement("script"); + script.type = "text/javascript"; + script.textContent = desc.jsContents.join('\n'); + document.head.appendChild(script); + } + // Wait for the scc loading to be completed before loading the other bundle + await promiseCSS; + // Load other desc + for (const bundleName of desc.assetLibs || []) { + if (typeof bundleName === 'string') { + // serial loading + const desc = await assets.getBundle(bundleName); + await assets.loadBundle(desc); + } else { + // parallel loading + await Promise.all(bundleName.map(async bundleName => { + const desc = await assets.getBundle(bundleName); + return assets.loadBundle(desc); + })); + } + } +}); + + +export const loadJS = function (url) { + return assets.loadJS(url); +} +export const loadCSS = function (url) { + return assets.loadCSS(url); +} +export const loadXML = function (xml, app=defaultApp) { + return assets.loadXML(xml, app=app); +} +export const getBundle = function (bundleName) { + return assets.getBundle(bundleName); +} +export const loadBundle = function (desc) { + return assets.loadBundle(desc); +} + + const { Component, xml, onWillStart } = owl; /** * Utility component that loads an asset bundle before instanciating a component @@ -176,7 +255,8 @@ const { Component, xml, onWillStart } = owl; export class LazyComponent extends Component { setup() { onWillStart(async () => { - await loadBundle(this.props.bundle); + const bundle = await getBundle(this.props.bundle); + await loadBundle(bundle); this.Component = registry.category("lazy_components").get(this.props.Component); }); } diff --git a/addons/web/static/src/legacy/js/assets.js b/addons/web/static/src/legacy/js/assets.js new file mode 100644 index 00000000000..8768d84ec92 --- /dev/null +++ b/addons/web/static/src/legacy/js/assets.js @@ -0,0 +1,23 @@ +odoo.define('web.assets.qweb', function (require) { +"use strict"; + +const {assets} = require('@web/core/assets'); + +const loadXML = assets.loadXML; +assets.loadXML = function (xml, app) { + loadXML(xml, app); + + const doc = new DOMParser().parseFromString(xml, "text/xml"); + const qwebTemplates = document.createElement("templates"); + for (const element of doc.querySelectorAll("templates > [t-name]:not([owl]), templates > [t-extend]:not([owl])")) { + qwebTemplates.appendChild(element); + } + + // don't use require to apply the patch before the first template loading. + odoo.ready('web.core').then(function () { + const core = odoo.__DEBUG__.services['web.core']; + core.qweb.add_template(qwebTemplates); + }); +} + +}); diff --git a/addons/web/static/src/legacy/js/core/ajax.js b/addons/web/static/src/legacy/js/core/ajax.js index 27d7eebbd67..fca5219a4dd 100644 --- a/addons/web/static/src/legacy/js/core/ajax.js +++ b/addons/web/static/src/legacy/js/core/ajax.js @@ -2,7 +2,6 @@ odoo.define('web.ajax', function (require) { "use strict"; var config = require('web.config'); -var concurrency = require('web.concurrency'); var core = require('web.core'); const {Markup} = require('web.utils'); var time = require('web.time'); @@ -137,90 +136,6 @@ function rpc(url, params, settings) { return jsonRpc(url, 'call', params, settings); } - -/** - * Load css asynchronously: fetch it from the url parameter and add a link tag - * to . - * If the url has already been requested and loaded, the promise will resolve - * immediately. - * - * @param {String} url of the css to be fetched - * @returns {Promise} resolved when the css has been loaded. - */ -var loadCSS = (function () { - var urlDefs = {}; - - return function loadCSS(url) { - if (url in urlDefs) { - // nothing to do here - } else if ($('link[href="' + url + '"]').length) { - // the link is already in the DOM, the promise can be resolved - urlDefs[url] = Promise.resolve(); - } else { - var $link = $('', { - 'href': url, - 'rel': 'stylesheet', - 'type': 'text/css' - }); - urlDefs[url] = new Promise(function (resolve, reject) { - $link.on('load', function () { - resolve(); - }).on('error', function () { - reject(new Error("Couldn't load css dependency: " + $link[0].href)); - }); - }); - $('head').append($link); - } - return urlDefs[url]; - }; -})(); - -var loadJS = (function () { - var dependenciesPromise = {}; - - var load = function loadJS(url) { - // Check the DOM to see if a script with the specified url is already there - var alreadyRequired = ($('script[src="' + url + '"]').length > 0); - - // If loadJS was already called with the same URL, it will have a registered promise indicating if - // the script has been fully loaded. If not, the promise has to be initialized. - // This is initialized as already resolved if the script was already there without the need of loadJS. - if (url in dependenciesPromise) { - return dependenciesPromise[url]; - } - var scriptLoadedPromise = new Promise(function (resolve, reject) { - if (alreadyRequired) { - resolve(); - } else { - // Get the script associated promise and returns it after initializing the script if needed. The - // promise is marked to be resolved on script load and rejected on script error. - var script = document.createElement('script'); - script.type = 'text/javascript'; - script.src = url; - script.onload = script.onreadystatechange = function() { - if ((script.readyState && script.readyState !== "loaded" && script.readyState !== "complete") || script.onload_done) { - return; - } - script.onload_done = true; - resolve(url); - }; - script.onerror = function () { - console.error("Error loading file", script.src); - reject(url); - }; - var head = document.head || document.getElementsByTagName('head')[0]; - head.appendChild(script); - } - }); - - dependenciesPromise[url] = scriptLoadedPromise; - return scriptLoadedPromise; - }; - - return load; -})(); - - /** * Cooperative file download implementation, for ajaxy APIs. * @@ -335,87 +250,6 @@ function post (controller_url, data) { }); } -/** - * Loads an XML file according to the given URL and adds its associated qweb - * templates to the given qweb engine. The function can also be used to get - * the promise which indicates when all the calls to the function are finished. - * - * Note: "all the calls" = the calls that happened before the current no-args - * one + the calls that will happen after but when the previous ones are not - * finished yet. - * - * @param {string} [url] - an URL where to find qweb templates - * @param {QWeb} [qweb] - the engine to which the templates need to be added - * @returns {Promise} - * If no argument is given to the function, the promise's state - * indicates if "all the calls" are finished (see main description). - * Otherwise, it indicates when the templates associated to the given - * url have been loaded. - */ -var loadXML = (function () { - // Some "static" variables associated to the loadXML function - var isLoading = false; - var loadingsData = []; - var seenURLs = []; - - return function (url, qweb) { - function _load() { - isLoading = true; - if (loadingsData.length) { - // There is something to load, load it, resolve the associated - // promise then start loading the next one - var loadingData = loadingsData[0]; - loadingData.qweb.add_template(loadingData.url, function () { - // Remove from array only now so that multiple calls to - // loadXML with the same URL returns the right promise - loadingsData.shift(); - loadingData.resolve(); - _load(); - }); - } else { - // There is nothing to load anymore, so resolve the - // "all the calls" promise - isLoading = false; - } - } - - // If no argument, simply returns the promise which indicates when - // "all the calls" are finished - if (!url || !qweb) { - return Promise.resolve(); - } - - // If the given URL has already been seen, do nothing but returning the - // associated promise - if (_.contains(seenURLs, url)) { - var oldLoadingData = _.findWhere(loadingsData, {url: url}); - return oldLoadingData ? oldLoadingData.def : Promise.resolve(); - } - seenURLs.push(url); - - - // Add the information about the new data to load: the url, the qweb - // engine and the associated promise - var newLoadingData = { - url: url, - qweb: qweb, - }; - newLoadingData.def = new Promise(function (resolve, reject) { - newLoadingData.resolve = resolve; - newLoadingData.reject = reject; - }); - loadingsData.push(newLoadingData); - - // If not already started, start the loading loop (reinitialize the - // "all the calls" promise to an unresolved state) - if (!isLoading) { - _load(); - } - - // Return the promise associated to the new given URL - return newLoadingData.def; - }; -})(); /** * Loads a template file according to the given xmlId. @@ -478,92 +312,10 @@ var loadAsset = (function () { return load; })(); -/** - * Loads the given js/css libraries and asset bundles. Note that no library or - * asset will be loaded if it was already done before. - * - * @param {Object} libs - * @param {Array} [libs.assetLibs=[]] - * The list of assets to load. Each list item may be a string (the xmlID - * of the asset to load) or a list of strings. The first level is loaded - * sequentially (so use this if the order matters) while the assets in - * inner lists are loaded in parallel (use this for efficiency but only - * if the order does not matter, should rarely be the case for assets). - * @param {string[]} [libs.cssLibs=[]] - * The list of CSS files to load. They will all be loaded in parallel but - * put in the DOM in the given order (only the order in the DOM is used - * to determine priority of CSS rules, not loaded time). - * @param {Array} [libs.jsLibs=[]] - * The list of JS files to load. Each list item may be a string (the URL - * of the file to load) or a list of strings. The first level is loaded - * sequentially (so use this if the order matters) while the files in inner - * lists are loaded in parallel (use this for efficiency but only - * if the order does not matter). - * @param {string[]} [libs.cssContents=[]] - * List of inline styles to add after loading the CSS files. - * @param {string[]} [libs.jsContents=[]] - * List of inline scripts to add after loading the JS files. - * @param {Object} [context] - * additionnal rpc context to be merged with the default one - * @param {string} [tplRoute] - * Custom route to use for template rendering of the potential assets - * to load (see libs.assetLibs). - * - * @returns {Promise} - */ -function loadLibs(libs, context, tplRoute) { - var mutex = new concurrency.Mutex(); - mutex.exec(function () { - var defs = []; - var cssLibs = [libs.cssLibs || []]; // Force loading in parallel - defs.push(_loadArray(cssLibs, ajax.loadCSS).then(function () { - if (libs.cssContents && libs.cssContents.length) { - $('head').append($('