diff --git a/runbot/__manifest__.py b/runbot/__manifest__.py index 84d15e5d4..0cffd9824 100644 --- a/runbot/__manifest__.py +++ b/runbot/__manifest__.py @@ -63,22 +63,30 @@ 'assets': { 'web.assets_backend': [ - 'runbot/static/src/libs/diff_match_patch/diff_match_patch.js', + 'runbot/static/libs/diff_match_patch/diff_match_patch.js', 'runbot/static/src/js/views/**/*', - 'runbot/static/src/js/fields/*', + 'runbot/static/src/diff_match_patch_module.js', + 'runbot/static/src/fields/*', ], 'runbot.assets_frontend': [ - '/web/static/lib/bootstrap/dist/css/bootstrap.css', - '/web/static/src/libs/fontawesome/css/font-awesome.css', - '/runbot/static/src/css/runbot.css', + ('include', 'web.assets_frontend_minimal'), # Pray the gods this stays named correctly - '/web/static/lib/jquery/jquery.js', - '/web/static/lib/popper/popper.js', - #'/web/static/lib/bootstrap/js/dist/util.js', - '/web/static/lib/bootstrap/js/dist/dropdown.js', - '/web/static/lib/bootstrap/js/dist/collapse.js', - '/runbot/static/src/js/runbot.js', - ], + 'runbot/static/libs/bootstrap/css/bootstrap.css', + 'runbot/static/libs/fontawesome/css/font-awesome.css', + 'runbot/static/src/css/runbot.css', + 'runbot/static/libs/jquery/jquery.js', + 'runbot/static/libs/popper/popper.js', + 'runbot/static/libs/bootstrap/js/bootstrap.bundle.js', + + 'runbot/static/libs/owl.js', + 'runbot/static/src/owl_module.js', + + 'runbot/static/src/vendored/**/*', # Vendored files coming from odoo modules + + 'runbot/static/src/frontend/root.js', + 'runbot/static/src/frontend/runbot.js', + 'runbot/static/src/frontend/**/*', + ] }, 'post_load': 'runbot_post_load', } diff --git a/runbot/controllers/__init__.py b/runbot/controllers/__init__.py index 96d149ab7..a3adbc619 100644 --- a/runbot/controllers/__init__.py +++ b/runbot/controllers/__init__.py @@ -3,3 +3,4 @@ from . import frontend from . import hook from . import badge +from . import public_api diff --git a/runbot/controllers/frontend.py b/runbot/controllers/frontend.py index c247a07de..708aa225a 100644 --- a/runbot/controllers/frontend.py +++ b/runbot/controllers/frontend.py @@ -18,6 +18,23 @@ _logger = logging.getLogger(__name__) +def supports_owl_frontend(method): + """ Marks a route as working with frontend client. """ + @functools.wraps(method) + def _wrapped(*args, **kwargs): + if request.httprequest.cookies.get('use_owl_client', '0') == '1': + return request.render('runbot.frontend_spa', { + 'projects': request.env['runbot.project'].search([('hidden', '=', False)]), + 'categories': request.env['runbot.category'].search([]), + 'default_category': request.env['ir.model.data']._xmlid_to_res_id('runbot.default_category'), + 'session_info': request.env['ir.http'].session_info(), + 'error_count': request.env['runbot.build.error'].search_count([]), + 'error_assigned_count': request.env['runbot.build.error'].search_count([('responsible', '=', request.env.user.id)]), + 'error_team_count': request.env['runbot.build.error'].search_count([('responsible', '=', False), ('team_id', 'in', request.env.user.runbot_team_ids.ids)]), + }) + return method(*args, **kwargs) + return _wrapped + def route(routes, **kw): def decorator(f): @o_route(routes, **kw) @@ -107,6 +124,7 @@ def submit(self, more=False, redirect='/', keep_search=False, category=False, fi '/runbot', '/runbot/', '/runbot//search/'], website=True, auth='public', type='http') + @supports_owl_frontend def bundles(self, project=None, search='', projects=False, refresh=False, for_next_freeze=False, limit=40, has_pr=None, **kwargs): search = search if len(search) < 60 else search[:60] env = request.env diff --git a/runbot/controllers/public_api.py b/runbot/controllers/public_api.py new file mode 100644 index 000000000..858440aa0 --- /dev/null +++ b/runbot/controllers/public_api.py @@ -0,0 +1,89 @@ +import json + +from werkzeug.exceptions import BadRequest, Forbidden + +from odoo.exceptions import AccessError +from odoo.http import Controller, request, route +from odoo.tools import mute_logger + +from odoo.addons.runbot.models.public_model_mixin import PublicModelMixin + + +class PublicApi(Controller): + + @mute_logger('odoo.addons.base.models.ir_model') # We don't care about logging acl errors + def _get_model(self, model: str) -> PublicModelMixin: + """ + Returns the model from a model string. + + Raises the appropriate exception if: + - The model does not exist + - The model is not a public model + - The current user can not read the model + """ + pool = request.env.registry + try: + Model = pool[model] + except KeyError: + raise BadRequest('Unknown model') + if not issubclass(Model, pool['runbot.public.model.mixin']): + raise BadRequest('Unknown model') + Model = request.env[model] + Model.check_access('read') + if not Model._api_request_allow_direct_access(): + raise Forbidden('This model does not allow direct access') + return Model + + @route('/runbot/api/models', auth='public', methods=['GET'], readonly=True) + def models(self): + models = [] + for model in request.env.keys(): + try: + models.append(self._get_model(model)) + except (BadRequest, AccessError, Forbidden): + pass + return request.make_json_response( + [Model._name for Model in models] + ) + + @route('/runbot/api//read', auth='public', methods=['POST'], readonly=True, csrf=False) + def read(self, *, model: str): + Model = self._get_model(model) + required_keys = Model._api_request_required_keys() + allowed_keys = Model._api_request_allowed_keys() + try: + data = request.get_json_data() + except json.JSONDecodeError: + raise BadRequest('Invalid payload, missing or malformed json') + if not isinstance(data, dict): + raise BadRequest('Invalid payload, should be a dict.') + if (missing_keys := required_keys - set(data.keys())): + raise BadRequest(f'Invalid payload, missing keys: {", ".join(missing_keys)}') + if (unknown_keys := set(data.keys()) - allowed_keys): + raise BadRequest(f'Invalid payload, unknown keys: {", ".join(unknown_keys)}') + if 'context' in data: + Model = Model.with_context(**data['context']) + if Model._api_request_requires_project(): + if not isinstance(data['project_id'], int): + raise BadRequest('Invalid project_id, should be an int') + # This is an additional layer of protection for project_id + project = request.env['runbot.project'].browse(data['project_id']).exists() + if not project: + raise BadRequest('Unknown project_id') + project.check_access('read') + Model = Model.with_context(project_id=project.id) + return request.make_json_response(Model._api_request_read(data)) + + @route('/runbot/api//spec', auth='public', methods=['GET'], readonly=True) + def spec(self, *, model: str): + Model = self._get_model(model) + required_keys = Model._api_request_required_keys() + allowed_keys = Model._api_request_allowed_keys() + return request.make_json_response({ + 'requires_project': Model._api_request_requires_project(), + 'default_page_size': Model._api_request_default_limit(), + 'max_page_size': Model._api_request_max_limit(), + 'required_keys': list(Model._api_request_required_keys()), + 'allowed_keys': list(allowed_keys - required_keys), + 'specification': self._get_model(model)._api_public_specification(), + }) diff --git a/runbot/models/__init__.py b/runbot/models/__init__.py index dbd376be8..c755616b9 100644 --- a/runbot/models/__init__.py +++ b/runbot/models/__init__.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from . import public_model_mixin + from . import batch from . import branch from . import build diff --git a/runbot/models/batch.py b/runbot/models/batch.py index 1469d9e08..24179324c 100644 --- a/runbot/models/batch.py +++ b/runbot/models/batch.py @@ -12,16 +12,17 @@ class Batch(models.Model): _name = 'runbot.batch' _description = "Bundle batch" + _inherit = ['runbot.public.model.mixin'] - last_update = fields.Datetime('Last ref update') + last_update = fields.Datetime('Last ref update', public=True) bundle_id = fields.Many2one('runbot.bundle', required=True, index=True, ondelete='cascade') - commit_link_ids = fields.Many2many('runbot.commit.link') + commit_link_ids = fields.Many2many('runbot.commit.link', public=True) commit_ids = fields.Many2many('runbot.commit', compute='_compute_commit_ids') - slot_ids = fields.One2many('runbot.batch.slot', 'batch_id') + slot_ids = fields.One2many('runbot.batch.slot', 'batch_id', public=True) all_build_ids = fields.Many2many('runbot.build', compute='_compute_all_build_ids', help="Recursive builds") - state = fields.Selection([('preparing', 'Preparing'), ('ready', 'Ready'), ('done', 'Done'), ('skipped', 'Skipped')]) + state = fields.Selection([('preparing', 'Preparing'), ('ready', 'Ready'), ('done', 'Done'), ('skipped', 'Skipped')], public=True) hidden = fields.Boolean('Hidden', default=False) - age = fields.Integer(compute='_compute_age', string='Build age') + age = fields.Integer(compute='_compute_age', string='Build age', public=True) category_id = fields.Many2one('runbot.category', index=True, default=lambda self: self.env.ref('runbot.default_category', raise_if_not_found=False)) log_ids = fields.One2many('runbot.batch.log', 'batch_id') has_warning = fields.Boolean("Has warning") @@ -34,6 +35,10 @@ class Batch(models.Model): column2='referenced_batch_id', ) + @api.model + def _api_project_id_field_path(self): + return 'bundle_id.project_id' + @api.depends('slot_ids.build_id') def _compute_all_build_ids(self): all_builds = self.env['runbot.build'].search([('id', 'child_of', self.slot_ids.build_id.ids)]) @@ -522,20 +527,25 @@ class BatchSlot(models.Model): _name = 'runbot.batch.slot' _description = 'Link between a bundle batch and a build' _order = 'trigger_id,id' + _inherit = ['runbot.public.model.mixin'] - batch_id = fields.Many2one('runbot.batch', index=True) - trigger_id = fields.Many2one('runbot.trigger', index=True) - build_id = fields.Many2one('runbot.build', index=True) - all_build_ids = fields.Many2many('runbot.build', compute='_compute_all_build_ids') + batch_id = fields.Many2one('runbot.batch', index=True, public=True) + trigger_id = fields.Many2one('runbot.trigger', index=True, public=True) + build_id = fields.Many2one('runbot.build', index=True, public=True) + all_build_ids = fields.Many2many('runbot.build', compute='_compute_all_build_ids', public=True) params_id = fields.Many2one('runbot.build.params', index=True, required=True) - link_type = fields.Selection([('created', 'Build created'), ('matched', 'Existing build matched'), ('rebuild', 'Rebuild')], required=True) # rebuild type? - active = fields.Boolean('Attached', default=True) + link_type = fields.Selection([('created', 'Build created'), ('matched', 'Existing build matched'), ('rebuild', 'Rebuild')], required=True, public=True) # rebuild type? + active = fields.Boolean('Attached', default=True, public=True) skipped = fields.Boolean('Skipped', default=False) # rebuild, what to do: since build can be in multiple batch: # - replace for all batch? # - only available on batch and replace for batch only? # - create a new bundle batch will new linked build? + @api.model + def _api_request_allow_direct_access(self): + return False + @api.depends('build_id') def _compute_all_build_ids(self): all_builds = self.env['runbot.build'].search([('id', 'child_of', self.build_id.ids)]) diff --git a/runbot/models/branch.py b/runbot/models/branch.py index b5aeeeddc..25778eb3c 100644 --- a/runbot/models/branch.py +++ b/runbot/models/branch.py @@ -13,10 +13,11 @@ class Branch(models.Model): _description = "Branch" _order = 'name' _rec_name = 'dname' + _inherit = ['runbot.public.model.mixin'] _sql_constraints = [('branch_repo_uniq', 'unique (name,remote_id)', 'The branch must be unique per repository !')] - name = fields.Char('Name', required=True) + name = fields.Char('Name', required=True, public=True) remote_id = fields.Many2one('runbot.remote', 'Remote', required=True, ondelete='cascade', index=True) head = fields.Many2one('runbot.commit', 'Head Commit', index=True) @@ -25,7 +26,7 @@ class Branch(models.Model): reference_name = fields.Char(compute='_compute_reference_name', string='Bundle name', store=True) bundle_id = fields.Many2one('runbot.bundle', 'Bundle', ondelete='cascade', index=True) - is_pr = fields.Boolean('IS a pr', required=True) + is_pr = fields.Boolean('IS a pr', required=True, public=True) pr_title = fields.Char('Pr Title') pr_body = fields.Char('Pr Body') pr_author = fields.Char('Pr Author') @@ -37,12 +38,16 @@ class Branch(models.Model): reflog_ids = fields.One2many('runbot.ref.log', 'branch_id') - branch_url = fields.Char(compute='_compute_branch_url', string='Branch url', readonly=True) - dname = fields.Char('Display name', compute='_compute_dname', search='_search_dname') + branch_url = fields.Char(compute='_compute_branch_url', string='Branch url', readonly=True, public=True) + dname = fields.Char('Display name', compute='_compute_dname', search='_search_dname', public=True) alive = fields.Boolean('Alive', default=True) draft = fields.Boolean('Draft', store=True) + @api.model + def _api_project_id_field_path(self): + return 'bundle_id.project_id' + @api.depends('name', 'remote_id.short_name') def _compute_dname(self): for branch in self: diff --git a/runbot/models/build.py b/runbot/models/build.py index 0fb17af9c..daabd5b49 100644 --- a/runbot/models/build.py +++ b/runbot/models/build.py @@ -49,6 +49,7 @@ def make_selection(array): class BuildParameters(models.Model): _name = 'runbot.build.params' _description = "All information used by a build to run, should be unique and set on create only" + _inherit = ['runbot.public.model.mixin'] # on param or on build? # execution parametter @@ -56,17 +57,17 @@ class BuildParameters(models.Model): commit_ids = fields.Many2many('runbot.commit', compute='_compute_commit_ids') version_id = fields.Many2one('runbot.version', required=True, index=True) project_id = fields.Many2one('runbot.project', required=True, index=True) # for access rights - trigger_id = fields.Many2one('runbot.trigger', index=True) # for access rights - create_batch_id = fields.Many2one('runbot.batch', index=True) - category = fields.Char('Category', index=True) # normal vs nightly vs weekly, ... + trigger_id = fields.Many2one('runbot.trigger', index=True, public=True) # for access rights + create_batch_id = fields.Many2one('runbot.batch', index=True, public=True) + category = fields.Char('Category', index=True, public=True) # normal vs nightly vs weekly, ... dockerfile_id = fields.Many2one('runbot.dockerfile', index=True, default=lambda self: self.env.ref('runbot.docker_default', raise_if_not_found=False)) skip_requirements = fields.Boolean('Skip requirements.txt auto install') # other informations extra_params = fields.Char('Extra cmd args') - config_id = fields.Many2one('runbot.build.config', 'Run Config', required=True, + config_id = fields.Many2one('runbot.build.config', 'Run Config', required=True, public=True, default=lambda self: self.env.ref('runbot.runbot_build_config_default', raise_if_not_found=False), index=True) - config_data = JsonDictField('Config Data') - used_custom_trigger = fields.Boolean('Custom trigger was used to generate this build') + config_data = JsonDictField('Config Data', public=True) + used_custom_trigger = fields.Boolean('Custom trigger was used to generate this build', public=True) build_ids = fields.One2many('runbot.build', 'params_id') builds_reference_ids = fields.Many2many('runbot.build', relation='runbot_build_params_references', copy=True) @@ -84,6 +85,10 @@ class BuildParameters(models.Model): ('unique_fingerprint', 'unique (fingerprint)', 'avoid duplicate params'), ] + @api.model + def _api_request_allow_direct_access(self): + return False + # @api.depends('version_id', 'project_id', 'extra_params', 'config_id', 'config_data', 'modules', 'commit_link_ids', 'builds_reference_ids') def _compute_fingerprint(self): for param in self: @@ -141,6 +146,7 @@ class BuildResult(models.Model): _name = 'runbot.build' _description = "Build" + _inherit = ['runbot.public.model.mixin'] _parent_store = True _order = 'id desc' @@ -154,27 +160,27 @@ class BuildResult(models.Model): no_auto_run = fields.Boolean('No run') # could be a default value, but possible to change it to allow duplicate accros branches - description = fields.Char('Description', help='Informative description') - md_description = fields.Html(compute='_compute_md_description', string='MD Parsed Description', help='Informative description markdown parsed', sanitize=False) - display_name = fields.Char(compute='_compute_display_name') + description = fields.Char('Description', help='Informative description', public=True) + md_description = fields.Html(compute='_compute_md_description', string='MD Parsed Description', help='Informative description markdown parsed', sanitize=False, public=True) + display_name = fields.Char(compute='_compute_display_name', public=True) # Related fields for convenience - version_id = fields.Many2one('runbot.version', related='params_id.version_id', store=True, index=True) - config_id = fields.Many2one('runbot.build.config', related='params_id.config_id', store=True, index=True) - trigger_id = fields.Many2one('runbot.trigger', related='params_id.trigger_id', store=True, index=True) - create_batch_id = fields.Many2one('runbot.batch', related='params_id.create_batch_id', store=True, index=True) - create_bundle_id = fields.Many2one('runbot.bundle', related='params_id.create_batch_id.bundle_id', index=True) + version_id = fields.Many2one('runbot.version', related='params_id.version_id', store=True, index=True, public=True) + config_id = fields.Many2one('runbot.build.config', related='params_id.config_id', store=True, index=True, public=True) + trigger_id = fields.Many2one('runbot.trigger', related='params_id.trigger_id', store=True, index=True, public=True) + create_batch_id = fields.Many2one('runbot.batch', related='params_id.create_batch_id', store=True, index=True, public=True) + create_bundle_id = fields.Many2one('runbot.bundle', related='params_id.create_batch_id.bundle_id', index=True, public=True) # state machine - global_state = fields.Selection(make_selection(state_order), string='Status', compute='_compute_global_state', store=True, recursive=True) - local_state = fields.Selection(make_selection(state_order), string='Build Status', default='pending', required=True, index=True) - global_result = fields.Selection(make_selection(result_order), string='Result', compute='_compute_global_result', store=True, recursive=True) - local_result = fields.Selection(make_selection(result_order), string='Build Result', default='ok') + global_state = fields.Selection(make_selection(state_order), string='Status', compute='_compute_global_state', store=True, recursive=True, public=True) + local_state = fields.Selection(make_selection(state_order), string='Build Status', default='pending', required=True, index=True, public=True) + global_result = fields.Selection(make_selection(result_order), string='Result', compute='_compute_global_result', store=True, recursive=True, public=True) + local_result = fields.Selection(make_selection(result_order), string='Build Result', default='ok', public=True) - requested_action = fields.Selection([('wake_up', 'To wake up'), ('deathrow', 'To kill')], string='Action requested', index=True) + requested_action = fields.Selection([('wake_up', 'To wake up'), ('deathrow', 'To kill')], string='Action requested', index=True, public=True) # web infos - host = fields.Char('Host name') - host_id = fields.Many2one('runbot.host', string="Host", compute='_compute_host_id') + host = fields.Char('Host name', public=True) + host_id = fields.Many2one('runbot.host', string="Host", compute='_compute_host_id', public=True) keep_host = fields.Boolean('Keep host on rebuild and for children') port = fields.Integer('Port') @@ -184,7 +190,7 @@ class BuildResult(models.Model): log_ids = fields.One2many('ir.logging', 'build_id', string='Logs') error_log_ids = fields.One2many('ir.logging', 'build_id', domain=[('level', 'in', ['WARNING', 'ERROR', 'CRITICAL'])], string='Error Logs') stat_ids = fields.One2many('runbot.build.stat', 'build_id', string='Statistics values') - log_list = fields.Char('Comma separted list of step_ids names with logs') + log_list = fields.Char('Comma separted list of step_ids names with logs', public=True) active_step = fields.Many2one('runbot.build.config.step', 'Active step') job = fields.Char('Active step display name', compute='_compute_job') @@ -235,13 +241,17 @@ class BuildResult(models.Model): slot_ids = fields.One2many('runbot.batch.slot', 'build_id') killable = fields.Boolean('Killable') - database_ids = fields.One2many('runbot.database', 'build_id') + database_ids = fields.One2many('runbot.database', 'build_id', public=True) commit_export_ids = fields.One2many('runbot.commit.export', 'build_id') static_run = fields.Char('Static run URL') access_token = fields.Char('Token', default=lambda self: uuid.uuid4().hex) + @api.model + def _api_project_id_field_path(self): + return 'params_id.project_id' + @api.depends('description', 'params_id.config_id') def _compute_display_name(self): for build in self: diff --git a/runbot/models/bundle.py b/runbot/models/bundle.py index 9cab84933..22884d8e1 100644 --- a/runbot/models/bundle.py +++ b/runbot/models/bundle.py @@ -1,3 +1,5 @@ +from werkzeug.exceptions import BadRequest + import time import logging import datetime @@ -5,40 +7,41 @@ from collections import defaultdict from odoo import models, fields, api, tools +from odoo.osv import expression from ..common import dt2time, s2human_long class Bundle(models.Model): _name = 'runbot.bundle' _description = "Bundle" - _inherit = 'mail.thread' + _inherit = ['mail.thread', 'runbot.public.model.mixin'] - name = fields.Char('Bundle name', required=True, help="Name of the base branch") - project_id = fields.Many2one('runbot.project', required=True, index=True) - branch_ids = fields.One2many('runbot.branch', 'bundle_id') + name = fields.Char('Bundle name', required=True, help="Name of the base branch", public=True) + project_id = fields.Many2one('runbot.project', required=True, index=True, public=True) + branch_ids = fields.One2many('runbot.branch', 'bundle_id', public=True) # custom behaviour - no_build = fields.Boolean('No build') + no_build = fields.Boolean('No build', public=True) no_auto_run = fields.Boolean('No run') build_all = fields.Boolean('Force all triggers') always_use_foreign = fields.Boolean('Use foreign bundle', help='By default, check for the same bundle name in another project to fill missing commits.', default=lambda self: self.project_id.always_use_foreign) modules = fields.Char("Modules to install", help="Comma-separated list of modules to install and test.") batch_ids = fields.One2many('runbot.batch', 'bundle_id') - last_batch = fields.Many2one('runbot.batch', index=True, domain=lambda self: [('category_id', '=', self.env.ref('runbot.default_category').id)]) - last_batchs = fields.Many2many('runbot.batch', 'Last batchs', compute='_compute_last_batchs') + last_batch = fields.Many2one('runbot.batch', index=True, domain=lambda self: [('category_id', '=', self.env.ref('runbot.default_category').id)], public=True) + last_batchs = fields.Many2many('runbot.batch', 'Last batchs', compute='_compute_last_batchs', public=True) last_done_batch = fields.Many2many('runbot.batch', 'Last batchs', compute='_compute_last_done_batch') - sticky = fields.Boolean('Sticky', compute='_compute_sticky', store=True, index=True) - is_base = fields.Boolean('Is base', index=True) + sticky = fields.Boolean('Sticky', compute='_compute_sticky', store=True, index=True, public=True) + is_base = fields.Boolean('Is base', index=True, public=True) defined_base_id = fields.Many2one('runbot.bundle', 'Forced base bundle', domain="[('project_id', '=', project_id), ('is_base', '=', True)]") base_id = fields.Many2one('runbot.bundle', 'Base bundle', compute='_compute_base_id', store=True) to_upgrade = fields.Boolean('To upgrade To', compute='_compute_to_upgrade', store=True, index=False) to_upgrade_from = fields.Boolean('To upgrade From', compute='_compute_to_upgrade_from', store=True, index=False) - has_pr = fields.Boolean('Has PR', compute='_compute_has_pr', store=True) + has_pr = fields.Boolean('Has PR', compute='_compute_has_pr', store=True, public=True) - version_id = fields.Many2one('runbot.version', 'Version', compute='_compute_version_id', store=True, recursive=True) + version_id = fields.Many2one('runbot.version', 'Version', compute='_compute_version_id', store=True, recursive=True, public=True) version_number = fields.Char(related='version_id.number', store=True, index=True) previous_major_version_base_id = fields.Many2one('runbot.bundle', 'Previous base bundle', compute='_compute_relations_base_id') @@ -56,7 +59,37 @@ class Bundle(models.Model): disable_codeowner = fields.Boolean("Disable codeowners", tracking=True) # extra_info - for_next_freeze = fields.Boolean('Should be in next freeze') + for_next_freeze = fields.Boolean('Should be in next freeze', public=True) + + @api.model + def _api_request_allowed_keys(self): + return super()._api_request_allowed_keys() | {'category_id'} + + @api.model + def _api_project_id_field_path(self): + return 'project_id' + + @api.model + def _api_request_read_get_records(self, request_data): + if 'category_id' not in self.env.context: + if 'category_id' in request_data: + if not isinstance(request_data['category_id'], int): + raise BadRequest('Invalid category_id') + category_id = request_data['category_id'] + else: + category_id = self.env['ir.model.data']._xmlid_to_res_id('runbot.default_category') + self = self.with_context(category_id=category_id) + limit, offset = self._api_request_read_get_offset_limit(request_data) + e = expression.expression(request_data['domain'], self) + query = e.query + query.order = """ + (case when "runbot_bundle".sticky then 1 when "runbot_bundle".sticky is null then 2 else 2 end), + case when "runbot_bundle".sticky then "runbot_bundle".version_number end collate "C" desc, + "runbot_bundle".last_batch desc + """ + query.limit = limit + query.offset = offset + return self.browse(query) @api.depends('name') def _compute_host_id(self): diff --git a/runbot/models/commit.py b/runbot/models/commit.py index 29072028b..e08806ec5 100644 --- a/runbot/models/commit.py +++ b/runbot/models/commit.py @@ -16,6 +16,7 @@ class Commit(models.Model): _name = 'runbot.commit' _description = "Commit" + _inherit = ['runbot.public.model.mixin'] _sql_constraints = [ ( @@ -24,18 +25,22 @@ class Commit(models.Model): "Commit must be unique to ensure correct duplicate matching", ) ] - name = fields.Char('SHA') + name = fields.Char('SHA', public=True) tree_hash = fields.Char('Tree hash', readonly=True) - repo_id = fields.Many2one('runbot.repo', string='Repo group') + repo_id = fields.Many2one('runbot.repo', string='Repo group', public=True) date = fields.Datetime('Commit date') author = fields.Char('Author') author_email = fields.Char('Author Email') committer = fields.Char('Committer') committer_email = fields.Char('Committer Email') - subject = fields.Text('Subject') - dname = fields.Char('Display name', compute='_compute_dname') + subject = fields.Text('Subject', public=True) + dname = fields.Char('Display name', compute='_compute_dname', public=True) rebase_on_id = fields.Many2one('runbot.commit', 'Rebase on commit') + @api.model + def _api_project_id_field_path(self): + return 'repo_id.project_id' + @api.model_create_multi def create(self, vals_list): for vals in vals_list: @@ -194,11 +199,13 @@ def _github_status(self, build, context, state, target_url, description=None): class CommitLink(models.Model): _name = 'runbot.commit.link' _description = "Build commit" + _inherit = ['runbot.public.model.mixin'] - commit_id = fields.Many2one('runbot.commit', 'Commit', required=True, index=True) + commit_id = fields.Many2one('runbot.commit', 'Commit', required=True, index=True, public=True) # Link info - match_type = fields.Selection([('new', 'New head of branch'), ('head', 'Head of branch'), ('base_head', 'Found on base branch'), ('base_match', 'Found on base branch')]) # HEAD, DEFAULT + match_type = fields.Selection([('new', 'New head of branch'), ('head', 'Head of branch'), ('base_head', 'Found on base branch'), ('base_match', 'Found on base branch')], public=True) # HEAD, DEFAULT branch_id = fields.Many2one('runbot.branch', string='Found in branch') # Shouldn't be use for anything else than display + remote_base_url = fields.Char(compute='_compute_remote_base_url', public=True) base_commit_id = fields.Many2one('runbot.commit', 'Base head commit', index=True) merge_base_commit_id = fields.Many2one('runbot.commit', 'Merge Base commit', index=True) @@ -208,6 +215,15 @@ class CommitLink(models.Model): diff_add = fields.Integer('# line added') diff_remove = fields.Integer('# line removed') + @api.model + def _api_request_allow_direct_access(self): + return False + + @api.depends('branch_id.remote_id.base_url') + def _compute_remote_base_url(self): + for cml in self: + cml.remote_base_url = cml.branch_id.remote_id.base_url + class CommitStatus(models.Model): _name = 'runbot.commit.status' diff --git a/runbot/models/database.py b/runbot/models/database.py index 4d7f10165..0deff6f6e 100644 --- a/runbot/models/database.py +++ b/runbot/models/database.py @@ -6,11 +6,16 @@ class Database(models.Model): _name = 'runbot.database' _description = "Database" + _inherit = ['runbot.public.model.mixin'] - name = fields.Char('Host name', required=True) + name = fields.Char('Host name', required=True, public=True) build_id = fields.Many2one('runbot.build', index=True, required=True) db_suffix = fields.Char(compute='_compute_db_suffix') + @api.model + def _api_request_allow_direct_access(self): + return False + def _compute_db_suffix(self): for record in self: record.db_suffix = record.name.replace('%s-' % record.build_id.dest, '') diff --git a/runbot/models/host.py b/runbot/models/host.py index 919ba6521..5a15cbc75 100644 --- a/runbot/models/host.py +++ b/runbot/models/host.py @@ -15,9 +15,9 @@ class Host(models.Model): _name = 'runbot.host' _description = "Host" _order = 'id' - _inherit = 'mail.thread' + _inherit = ['mail.thread', 'runbot.public.model.mixin'] - name = fields.Char('Host name', required=True) + name = fields.Char('Host name', required=True, public=True) disp_name = fields.Char('Display name') active = fields.Boolean('Active', default=True, tracking=True) last_start_loop = fields.Datetime('Last start') @@ -49,6 +49,10 @@ class Host(models.Model): use_remote_docker_registry = fields.Boolean('Use remote Docker Registry', default=False, help="Use docker registry for pulling images") docker_registry_url = fields.Char('Registry Url', help="Override global registry URL for this host.") + @api.model + def _api_request_requires_project(self): + return False + def _compute_nb(self): # Array of tuple (host, state, count) groups = self.env['runbot.build']._read_group( diff --git a/runbot/models/project.py b/runbot/models/project.py index 0235dcf98..21e90fc78 100644 --- a/runbot/models/project.py +++ b/runbot/models/project.py @@ -6,11 +6,12 @@ class Project(models.Model): _name = 'runbot.project' _description = 'Project' _order = 'sequence, id' + _inherit = ['runbot.public.model.mixin'] - name = fields.Char('Project name', required=True) + name = fields.Char('Project name', required=True, public=True) group_ids = fields.Many2many('res.groups', string='Required groups') keep_sticky_running = fields.Boolean('Keep last sticky builds running') - trigger_ids = fields.One2many('runbot.trigger', 'project_id', string='Triggers') + trigger_ids = fields.One2many('runbot.trigger', 'project_id', string='Triggers', public=True) dockerfile_id = fields.Many2one('runbot.dockerfile', index=True, help="Project Default Dockerfile") repo_ids = fields.One2many('runbot.repo', 'project_id', string='Repos') sequence = fields.Integer('Sequence') @@ -25,6 +26,10 @@ class Project(models.Model): active = fields.Boolean("Active", default=True) process_delay = fields.Integer('Process delay', default=60, required=True, help="Delay between a push and a batch starting its process.") + @api.model + def _api_request_requires_project(self): + return False + @api.constrains('process_delay') def _constraint_process_delay(self): if any(project.process_delay < 0 for project in self): diff --git a/runbot/models/public_model_mixin.py b/runbot/models/public_model_mixin.py new file mode 100644 index 000000000..592a49b4d --- /dev/null +++ b/runbot/models/public_model_mixin.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +from werkzeug.exceptions import BadRequest, Forbidden + +from typing import Dict, Union, List, Self, TypedDict + +from odoo import models, api, fields, tools +from odoo.osv import expression + + +class SubSpecification(TypedDict): + context: Dict + fields: Dict[str, 'SubSpecification'] +Specification = Dict[str, Union[Dict, 'SubSpecification']] + +SUPPORTED_FIELD_TYPES = { # Perhaps this should be a list of class instead + 'boolean', 'integer', 'float', 'char', 'text', 'html', + 'date', 'datetime', 'selection', 'jsonb', + 'many2one', 'one2many', 'many2many', +} +RELATIONAL_FIELD_TYPES = {'many2one', 'one2many', 'many2many'} +SPEC_MAX_DEPTH = 10 +SPEC_METADATA_FIELD = { + '__type', '__help', +} +DEFAULT_LIMIT = 40 +DEFAULT_MAX_LIMIT = 60 + +def _cleaned_spec(spec: Specification | SubSpecification) -> Specification | SubSpecification: + """ Returns the specification without metadata fields. """ + if not isinstance(spec, dict): + return spec + return { + k: v for k, v in spec.items() + if k not in SPEC_METADATA_FIELD + } + +class PublicModelMixin(models.AbstractModel): + _name = 'runbot.public.model.mixin' + _description = 'Mixin for publicly accessible data' + + @api.model + def _valid_field_parameter(self, field: fields.Field, name: str): + if field.type in SUPPORTED_FIELD_TYPES: + return name in ( + # boolean, whether the field is readable through the public api, + # public fields on record on which the user does not have access are not exposed. + 'public', + ) or super()._valid_field_parameter(field, name) + return super()._valid_field_parameter(field, name) + + @api.model + def _get_public_fields(self) -> List[fields.Field]: + """ Returns a list of publicly readable fields. """ + return [ + field for field in self._fields.values() + if getattr(field, 'public', None) or field.name == 'id' + ] + + ########## REQUESTS ########## + + @api.model + def _api_request_allow_direct_access(self) -> bool: + """ Returns whether this model is accessible directly through the api. """ + return True + + @api.model + def _api_request_allowed_keys(self) -> set[str]: + """ Returns a list of allowed keys for request_data. """ + return self._api_request_required_keys() | { + 'context', + 'limit', 'offset', + } + + @api.model + def _api_request_default_limit(self) -> int: + return DEFAULT_LIMIT + + @api.model + def _api_request_max_limit(self) -> int: + return DEFAULT_MAX_LIMIT + + @api.model + def _api_request_required_keys(self) -> set[str]: + """ Returns a list of required keys for request_data. """ + required_keys = {'specification', 'domain'} + if self._api_request_requires_project(): + required_keys.add('project_id') + return required_keys + + @api.model + def _api_request_requires_project(self) -> bool: #TODO: rename me + """ Public models are by default based on a project_id (filtered on project_id). """ + return self._api_request_allow_direct_access() + + @api.model + def _api_project_id_field_path(self) -> str: + """ Returns the path from the current object to project_id. """ + raise NotImplementedError('_api_project_id_field_path not implemented') + + @api.model + def _api_request_validate_domain(self, domain: list[str | tuple | list]): + """ + Validates a domain against the public spec. + + This only validates that all the fields in the domain are queryable fields, + the actual validity of the domain will be checked by the orm when + searching for records. + + Returns: + domain: a transformed domain if necessary + + Raises: + AssertionError: unknown domain leaf + Forbidden: invalid field used + """ + + try: + self._where_calc(domain) + except ValueError as e: + raise BadRequest('Invalid domain') from e + + spec: Specification = self._api_public_specification() + # recompiles the spec into a list of fields that can be present in the domain + valid_fields: str[str] = set() + def _visit_spec(spec, prefix: str | None = None): + spec = _cleaned_spec(spec) + if not spec: + return + for field, sub_spec in spec.items(): + this_field = f'{prefix}.{field}' if prefix else field + valid_fields.add(this_field) + if sub_spec and sub_spec.get('fields'): + _visit_spec(sub_spec['fields'], prefix=this_field) + _visit_spec(spec) + + for leaf in domain: + if not isinstance(leaf, (tuple, list)): + continue + assert len(leaf) == 3 # Can this happen in a valid domain? + if leaf[0] not in valid_fields and not self.env.user.has_group('runbot.group_runbot_admin'): + raise Forbidden('Trying to filter from private field') + + if self._api_request_requires_project(): + assert 'project_id' in self.env.context + domain = expression.AND([ + [(self._api_project_id_field_path(), '=', self.env.context['project_id'])], + domain + ]) + + return domain + + @api.model + def _api_request_read_get_offset_limit(self, request_data: dict) -> tuple[int, int]: + if 'limit' in request_data: + if not isinstance(request_data['limit'], int): + raise BadRequest('Invalid page size (should be int)') + limit = request_data['limit'] + if limit > self._api_request_max_limit(): + raise BadRequest('Page size exceeds max size') + else: + limit = self._api_request_default_limit() + offset = 0 + if 'offset' in request_data: + if not isinstance(request_data['offset'], int): + raise BadRequest('Invalid page (should be int)') + offset = request_data['offset'] + return limit, offset + + @api.model + def _api_request_read_get_records(self, request_data: dict) -> Self: + limit, offset = self._api_request_read_get_offset_limit(request_data) + return self.search(request_data['domain'], limit=limit, offset=offset) + + @api.model + def _api_request_read(self, request_data: dict) -> list[dict]: + """ + Processes a frontend request and returns the data to be returned by the controller. + + This method is allowed to raise Http specific exceptions. + """ + specification, domain = request_data['specification'], request_data['domain'] + + try: + if not self._api_verify_specification(specification) and\ + not self.env.user.has_group('runbot.group_runbot_admin'): + raise Forbidden('Invalid specification or trying to access private data.') + except (ValueError, AssertionError) as e: + raise BadRequest('Invalid specification') from e + + request_data['domain'] = self._api_request_validate_domain(domain) + records = self._api_request_read_get_records(request_data) + + return records._api_read(request_data['specification']) + + ########## SPEC ########## + + @api.model + def _api_get_relation_field_key(self, field: fields.Field): + """ Returns a relation cache key for a field, a string defining the identity of the relationship. """ + if isinstance(field, fields.Many2one): + return f'{self._name}__{field.name}' + elif isinstance(field, fields.Many2many): + if not field.store: + return f'{self._name}__{field.name}' + return field.relation + elif isinstance(field, fields.One2many): + if not field.store: # is this valid? + return f'{self._name}__{field.name}' + CoModel: PublicModelMixin = self.env[field.comodel_name] + inverse_field = CoModel._fields[field.inverse_name] + return CoModel._api_get_relation_field_key(inverse_field) + raise NotImplementedError('Unsupported field') + + @tools.ormcache() + @api.model + def _api_public_specification(self) -> Specification: + """ + Returns the public specification for the model. + + The specification will go through all the fields marked as public. + For relational fields, the result will be nested (up to a depth of :code:`SPEC_MAX_DEPTH`). + + The specification will contain metadata about each fields. + The specification returned by this method can be used directly with :code:`_api_read`. + + Returns: + specification: The specification as a dictionary. + """ + # We want to prevent infinite loops so we need to track which relations + # have already been explored, this concerns many2one, many2many + def _visit_model(model: PublicModelMixin, visited_relations: set[str], depth = 0) -> Specification | SubSpecification: + spec: Specification | SubSpecification = {} + for field in model._get_public_fields(): + field_metadata = { + '__type': field.type, + } + if field.help: + field_metadata['__help'] = field.help + if field.relational and \ + issubclass(self.pool[field.comodel_name], PublicModelMixin): + field_key = model._api_get_relation_field_key(field) + if field_key in visited_relations or depth == SPEC_MAX_DEPTH: + continue + visited_relations.add(field_key) + CoModel: PublicModelMixin = model.env[field.comodel_name] + field_metadata.update( + fields=_visit_model(CoModel, {*visited_relations}, depth + 1) + ) + spec[field.name] = field_metadata + return spec + + return _visit_model(self, set()) + + @api.model + def _api_verify_specification(self, specification: Specification) -> bool: + """ + Verifies a given specification against the public specification. + + This step also provides some validation of the specification, enough that + the spec can be safely used with `_api_read` if the method does not + raise an exception. + + Args: + specification: The requested specification. + + Returns: + If the spec matches the public spec this method returns True + otherwise False. + + Raises: + ValueError: If a sub spec is given for a non relational field. + ValueError: If a sub spec is given for a relational field that does + not allow public data (id only). + """ + public_specification: Specification = self._api_public_specification() + + def _visit_spec( + model_spec: Specification, + request_spec: Specification, + ) -> bool: + request_spec = _cleaned_spec(request_spec) + for field, sub_spec in request_spec.items(): + sub_spec = _cleaned_spec(sub_spec) + if field not in model_spec: + return False + if not isinstance(sub_spec, dict): + raise ValueError( + 'Invalid sub spec, should be a dict.' + ) + # For now we actually only have keys for relational fields. + sub_spec_allowed_keys = set() + if model_spec[field].get('__type') in RELATIONAL_FIELD_TYPES\ + and 'fields' in model_spec[field]: + sub_spec_allowed_keys.add('fields') + sub_spec_allowed_keys.add('context') + if set(sub_spec.keys()) - sub_spec_allowed_keys: + raise ValueError( + 'Invalid sub spec, contains unknown keys.' + ) + if not sub_spec or 'fields' not in sub_spec: + continue + if 'fields' not in model_spec[field]: + raise ValueError( + f'Sub spec not available for field {field}' + ) + if not _visit_spec(model_spec[field]['fields'], sub_spec['fields']): + return False + return True + + return _visit_spec(public_specification, specification) + + def _api_read(self, specification: Specification) -> list[dict]: + """ Forwards the specification to `web_read`. """ + return self.web_read(specification) diff --git a/runbot/models/repo.py b/runbot/models/repo.py index fbf488cbc..14342821e 100644 --- a/runbot/models/repo.py +++ b/runbot/models/repo.py @@ -44,14 +44,14 @@ class Trigger(models.Model): """ _name = 'runbot.trigger' - _inherit = 'mail.thread' + _inherit = ['mail.thread', 'runbot.public.model.mixin'] _description = 'Triggers' _order = 'sequence, id' - sequence = fields.Integer('Sequence') - name = fields.Char("Name") - description = fields.Char("Description", help="Informative description") + sequence = fields.Integer('Sequence', public=True) + name = fields.Char("Name", public=True) + description = fields.Char("Description", help="Informative description", public=True) project_id = fields.Many2one('runbot.project', string="Project id", required=True) repo_ids = fields.Many2many('runbot.repo', relation='runbot_trigger_triggers', string="Triggers", domain="[('project_id', '=', project_id)]") dependency_ids = fields.Many2many('runbot.repo', relation='runbot_trigger_dependencies', string="Dependencies") @@ -78,8 +78,8 @@ class Trigger(models.Model): ci_context = fields.Char("CI context", tracking=True) category_id = fields.Many2one('runbot.category', default=lambda self: self.env.ref('runbot.default_category', raise_if_not_found=False)) version_domain = fields.Char(string="Version domain") - hide = fields.Boolean('Hide trigger on main page') - manual = fields.Boolean('Only start trigger manually', default=False) + hide = fields.Boolean('Hide trigger on main page', public=True) + manual = fields.Boolean('Only start trigger manually', default=False, public=True) restore_trigger_id = fields.Many2one('runbot.trigger', string='Restore Trigger ID for custom triggers', help="Mainly usefull to automatically define where to find a reference database when creating a custom trigger", tracking=True) upgrade_dumps_trigger_id = fields.Many2one('runbot.trigger', string='Template/complement trigger', tracking=True) @@ -99,6 +99,10 @@ class Trigger(models.Model): context={'default_type': 'qweb', 'default_arch_base': ''}, ) + @api.model + def _api_project_id_field_path(self): + return 'project_id' + @api.depends('config_id.step_order_ids.step_id.make_stats') def _compute_has_stats(self): for trigger in self: @@ -201,9 +205,9 @@ class Remote(models.Model): _name = 'runbot.remote' _description = 'Remote' _order = 'sequence, id' - _inherit = 'mail.thread' + _inherit = ['mail.thread', 'runbot.public.model.mixin'] - name = fields.Char('Url', required=True, tracking=True) + name = fields.Char('Url', required=True, tracking=True, public=True) repo_id = fields.Many2one('runbot.repo', required=True, tracking=True) owner = fields.Char(compute='_compute_base_infos', string='Repo Owner', store=True, readonly=True, tracking=True) @@ -212,7 +216,7 @@ class Remote(models.Model): base_url = fields.Char(compute='_compute_base_url', string='Base URL', readonly=True, tracking=True) - short_name = fields.Char('Short name', compute='_compute_short_name', tracking=True) + short_name = fields.Char('Short name', compute='_compute_short_name', tracking=True, public=True) remote_name = fields.Char('Remote name', compute='_compute_remote_name', tracking=True) sequence = fields.Integer('Sequence', tracking=True) @@ -222,6 +226,10 @@ class Remote(models.Model): token = fields.Char("Github token", groups="runbot.group_runbot_admin") + @api.model + def _api_request_allow_direct_access(self): + return False + @api.depends('name') def _compute_base_infos(self): for remote in self: @@ -386,7 +394,7 @@ class Repo(models.Model): _name = 'runbot.repo' _description = "Repo" _order = 'sequence, id' - _inherit = 'mail.thread' + _inherit = ['mail.thread', 'runbot.public.model.mixin'] name = fields.Char("Name", tracking=True) # odoo/enterprise/upgrade/security/runbot/design_theme identity_file = fields.Char("Identity File", help="Identity file to use with git/ssh", groups="runbot.group_runbot_admin") @@ -402,7 +410,7 @@ class Repo(models.Model): addons_paths = fields.Char('Addons paths', help='Comma separated list of possible addons path', default='', tracking=True) upgrade_paths = fields.Char('Upgrade paths', help='Comma separated list of possible upgrade path', default='', tracking=True) - sequence = fields.Integer('Sequence', tracking=True) + sequence = fields.Integer('Sequence', tracking=True, public=True) path = fields.Char(compute='_compute_path', string='Directory', readonly=True) mode = fields.Selection([('disabled', 'Disabled'), ('poll', 'Poll'), diff --git a/runbot/models/upgrade.py b/runbot/models/upgrade.py index 96843e168..4fd329cd5 100644 --- a/runbot/models/upgrade.py +++ b/runbot/models/upgrade.py @@ -57,7 +57,7 @@ class UpgradeRegex(models.Model): class BuildResult(models.Model): - _inherit = 'runbot.build' + _inherit = ['runbot.build'] def _parse_upgrade_errors(self): ir_logs = self.env['ir.logging'].search([('level', 'in', ('ERROR', 'WARNING', 'CRITICAL')), ('type', '=', 'server'), ('build_id', 'in', self.ids)]) diff --git a/runbot/security/ir.rule.csv b/runbot/security/ir.rule.csv index 0e6bd9297..fb6c2992e 100644 --- a/runbot/security/ir.rule.csv +++ b/runbot/security/ir.rule.csv @@ -12,3 +12,5 @@ rule_commit,"limited to groups",model_runbot_commit,group_user,"['|', ('repo_id. rule_commit_mgmt,"manager can see all",model_runbot_commit,group_runbot_admin,"[(1, '=', 1)]",1,1,1,1 rule_build,"limited to groups",model_runbot_build,group_user,"['|', ('params_id.project_id.group_ids', '=', False), ('params_id.project_id.group_ids', 'in', [g.id for g in user.groups_id])]",1,1,1,1 rule_build_mgmt,"manager can see all",model_runbot_build,group_runbot_admin,"[(1, '=', 1)]",1,1,1,1 +rule_batch,"limited to groups",model_runbot_batch,group_user,"['|', ('bundle_id.project_id.group_ids', '=', False), ('bundle_id.project_id.group_ids', 'in', [g.id for g in user.groups_id])]",1,1,1,1 +rule_batch_mgmt,"manager can see all",model_runbot_batch,group_runbot_admin,"[(1, '=', 1)]",1,1,1,1 diff --git a/runbot/static/src/img/icon_killed.png b/runbot/static/img/icon_killed.png similarity index 100% rename from runbot/static/src/img/icon_killed.png rename to runbot/static/img/icon_killed.png diff --git a/runbot/static/src/img/icon_killed.svg b/runbot/static/img/icon_killed.svg similarity index 100% rename from runbot/static/src/img/icon_killed.svg rename to runbot/static/img/icon_killed.svg diff --git a/runbot/static/src/img/icon_ko.png b/runbot/static/img/icon_ko.png similarity index 100% rename from runbot/static/src/img/icon_ko.png rename to runbot/static/img/icon_ko.png diff --git a/runbot/static/src/img/icon_ko.svg b/runbot/static/img/icon_ko.svg similarity index 100% rename from runbot/static/src/img/icon_ko.svg rename to runbot/static/img/icon_ko.svg diff --git a/runbot/static/src/img/icon_ok.png b/runbot/static/img/icon_ok.png similarity index 100% rename from runbot/static/src/img/icon_ok.png rename to runbot/static/img/icon_ok.png diff --git a/runbot/static/src/img/icon_ok.svg b/runbot/static/img/icon_ok.svg similarity index 100% rename from runbot/static/src/img/icon_ok.svg rename to runbot/static/img/icon_ok.svg diff --git a/runbot/static/src/img/icon_skipped.png b/runbot/static/img/icon_skipped.png similarity index 100% rename from runbot/static/src/img/icon_skipped.png rename to runbot/static/img/icon_skipped.png diff --git a/runbot/static/src/img/icon_skipped.svg b/runbot/static/img/icon_skipped.svg similarity index 100% rename from runbot/static/src/img/icon_skipped.svg rename to runbot/static/img/icon_skipped.svg diff --git a/runbot/static/src/img/icon_warn.png b/runbot/static/img/icon_warn.png similarity index 100% rename from runbot/static/src/img/icon_warn.png rename to runbot/static/img/icon_warn.png diff --git a/runbot/static/src/img/icon_warn.svg b/runbot/static/img/icon_warn.svg similarity index 100% rename from runbot/static/src/img/icon_warn.svg rename to runbot/static/img/icon_warn.svg diff --git a/runbot/static/src/libs/bootstrap/LICENSE b/runbot/static/libs/bootstrap/LICENSE similarity index 100% rename from runbot/static/src/libs/bootstrap/LICENSE rename to runbot/static/libs/bootstrap/LICENSE diff --git a/runbot/static/src/libs/bootstrap/css/bootstrap.css b/runbot/static/libs/bootstrap/css/bootstrap.css similarity index 100% rename from runbot/static/src/libs/bootstrap/css/bootstrap.css rename to runbot/static/libs/bootstrap/css/bootstrap.css diff --git a/runbot/static/src/libs/bootstrap/js/alert.js b/runbot/static/libs/bootstrap/js/alert.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/alert.js rename to runbot/static/libs/bootstrap/js/alert.js diff --git a/runbot/static/src/libs/bootstrap/js/bootstrap.bundle.js b/runbot/static/libs/bootstrap/js/bootstrap.bundle.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/bootstrap.bundle.js rename to runbot/static/libs/bootstrap/js/bootstrap.bundle.js diff --git a/runbot/static/src/libs/bootstrap/js/button.js b/runbot/static/libs/bootstrap/js/button.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/button.js rename to runbot/static/libs/bootstrap/js/button.js diff --git a/runbot/static/src/libs/bootstrap/js/carousel.js b/runbot/static/libs/bootstrap/js/carousel.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/carousel.js rename to runbot/static/libs/bootstrap/js/carousel.js diff --git a/runbot/static/src/libs/bootstrap/js/collapse.js b/runbot/static/libs/bootstrap/js/collapse.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/collapse.js rename to runbot/static/libs/bootstrap/js/collapse.js diff --git a/runbot/static/src/libs/bootstrap/js/dropdown.js b/runbot/static/libs/bootstrap/js/dropdown.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/dropdown.js rename to runbot/static/libs/bootstrap/js/dropdown.js diff --git a/runbot/static/src/libs/bootstrap/js/index.js b/runbot/static/libs/bootstrap/js/index.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/index.js rename to runbot/static/libs/bootstrap/js/index.js diff --git a/runbot/static/src/libs/bootstrap/js/modal.js b/runbot/static/libs/bootstrap/js/modal.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/modal.js rename to runbot/static/libs/bootstrap/js/modal.js diff --git a/runbot/static/src/libs/bootstrap/js/popover.js b/runbot/static/libs/bootstrap/js/popover.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/popover.js rename to runbot/static/libs/bootstrap/js/popover.js diff --git a/runbot/static/src/libs/bootstrap/js/scrollspy.js b/runbot/static/libs/bootstrap/js/scrollspy.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/scrollspy.js rename to runbot/static/libs/bootstrap/js/scrollspy.js diff --git a/runbot/static/src/libs/bootstrap/js/tab.js b/runbot/static/libs/bootstrap/js/tab.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/tab.js rename to runbot/static/libs/bootstrap/js/tab.js diff --git a/runbot/static/src/libs/bootstrap/js/toast.js b/runbot/static/libs/bootstrap/js/toast.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/toast.js rename to runbot/static/libs/bootstrap/js/toast.js diff --git a/runbot/static/src/libs/bootstrap/js/tooltip.js b/runbot/static/libs/bootstrap/js/tooltip.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/tooltip.js rename to runbot/static/libs/bootstrap/js/tooltip.js diff --git a/runbot/static/src/libs/bootstrap/js/util.js b/runbot/static/libs/bootstrap/js/util.js similarity index 100% rename from runbot/static/src/libs/bootstrap/js/util.js rename to runbot/static/libs/bootstrap/js/util.js diff --git a/runbot/static/src/libs/diff_match_patch/LICENSE b/runbot/static/libs/diff_match_patch/LICENSE similarity index 100% rename from runbot/static/src/libs/diff_match_patch/LICENSE rename to runbot/static/libs/diff_match_patch/LICENSE diff --git a/runbot/static/src/libs/diff_match_patch/diff_match_patch.js b/runbot/static/libs/diff_match_patch/diff_match_patch.js similarity index 99% rename from runbot/static/src/libs/diff_match_patch/diff_match_patch.js rename to runbot/static/libs/diff_match_patch/diff_match_patch.js index 0cce87f0f..cf7465367 100644 --- a/runbot/static/src/libs/diff_match_patch/diff_match_patch.js +++ b/runbot/static/libs/diff_match_patch/diff_match_patch.js @@ -2235,7 +2235,7 @@ var diff_match_patch = function() { // /** @suppress {globalThis} */ // this['DIFF_EQUAL'] = DIFF_EQUAL; -export { +(typeof window !== "undefined" ? window : this).DiffMatchPatch = { diff_match_patch, DIFF_DELETE, DIFF_INSERT, diff --git a/runbot/static/src/libs/fontawesome/css/font-awesome.css b/runbot/static/libs/fontawesome/css/font-awesome.css similarity index 100% rename from runbot/static/src/libs/fontawesome/css/font-awesome.css rename to runbot/static/libs/fontawesome/css/font-awesome.css diff --git a/runbot/static/src/libs/fontawesome/fonts/FontAwesome.otf b/runbot/static/libs/fontawesome/fonts/FontAwesome.otf similarity index 100% rename from runbot/static/src/libs/fontawesome/fonts/FontAwesome.otf rename to runbot/static/libs/fontawesome/fonts/FontAwesome.otf diff --git a/runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.eot b/runbot/static/libs/fontawesome/fonts/fontawesome-webfont.eot similarity index 100% rename from runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.eot rename to runbot/static/libs/fontawesome/fonts/fontawesome-webfont.eot diff --git a/runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.svg b/runbot/static/libs/fontawesome/fonts/fontawesome-webfont.svg similarity index 100% rename from runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.svg rename to runbot/static/libs/fontawesome/fonts/fontawesome-webfont.svg diff --git a/runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.ttf b/runbot/static/libs/fontawesome/fonts/fontawesome-webfont.ttf similarity index 100% rename from runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.ttf rename to runbot/static/libs/fontawesome/fonts/fontawesome-webfont.ttf diff --git a/runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.woff b/runbot/static/libs/fontawesome/fonts/fontawesome-webfont.woff similarity index 100% rename from runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.woff rename to runbot/static/libs/fontawesome/fonts/fontawesome-webfont.woff diff --git a/runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.woff2 b/runbot/static/libs/fontawesome/fonts/fontawesome-webfont.woff2 similarity index 100% rename from runbot/static/src/libs/fontawesome/fonts/fontawesome-webfont.woff2 rename to runbot/static/libs/fontawesome/fonts/fontawesome-webfont.woff2 diff --git a/runbot/static/src/libs/jquery/jquery.browser.js b/runbot/static/libs/jquery/jquery.browser.js similarity index 100% rename from runbot/static/src/libs/jquery/jquery.browser.js rename to runbot/static/libs/jquery/jquery.browser.js diff --git a/runbot/static/src/libs/jquery/jquery.js b/runbot/static/libs/jquery/jquery.js similarity index 100% rename from runbot/static/src/libs/jquery/jquery.js rename to runbot/static/libs/jquery/jquery.js diff --git a/runbot/static/libs/owl.js b/runbot/static/libs/owl.js new file mode 100644 index 000000000..a3b0eaba9 --- /dev/null +++ b/runbot/static/libs/owl.js @@ -0,0 +1,6223 @@ +(function (exports) { + 'use strict'; + + function filterOutModifiersFromData(dataList) { + dataList = dataList.slice(); + const modifiers = []; + let elm; + while ((elm = dataList[0]) && typeof elm === "string") { + modifiers.push(dataList.shift()); + } + return { modifiers, data: dataList }; + } + const config = { + // whether or not blockdom should normalize DOM whenever a block is created. + // Normalizing dom mean removing empty text nodes (or containing only spaces) + shouldNormalizeDom: true, + // this is the main event handler. Every event handler registered with blockdom + // will go through this function, giving it the data registered in the block + // and the event + mainEventHandler: (data, ev, currentTarget) => { + if (typeof data === "function") { + data(ev); + } + else if (Array.isArray(data)) { + data = filterOutModifiersFromData(data).data; + data[0](data[1], ev); + } + return false; + }, + }; + + // ----------------------------------------------------------------------------- + // Toggler node + // ----------------------------------------------------------------------------- + class VToggler { + constructor(key, child) { + this.key = key; + this.child = child; + } + mount(parent, afterNode) { + this.parentEl = parent; + this.child.mount(parent, afterNode); + } + moveBeforeDOMNode(node, parent) { + this.child.moveBeforeDOMNode(node, parent); + } + moveBeforeVNode(other, afterNode) { + this.moveBeforeDOMNode((other && other.firstNode()) || afterNode); + } + patch(other, withBeforeRemove) { + if (this === other) { + return; + } + let child1 = this.child; + let child2 = other.child; + if (this.key === other.key) { + child1.patch(child2, withBeforeRemove); + } + else { + child2.mount(this.parentEl, child1.firstNode()); + if (withBeforeRemove) { + child1.beforeRemove(); + } + child1.remove(); + this.child = child2; + this.key = other.key; + } + } + beforeRemove() { + this.child.beforeRemove(); + } + remove() { + this.child.remove(); + } + firstNode() { + return this.child.firstNode(); + } + toString() { + return this.child.toString(); + } + } + function toggler(key, child) { + return new VToggler(key, child); + } + + // Custom error class that wraps error that happen in the owl lifecycle + class OwlError extends Error { + } + + const { setAttribute: elemSetAttribute, removeAttribute } = Element.prototype; + const tokenList = DOMTokenList.prototype; + const tokenListAdd = tokenList.add; + const tokenListRemove = tokenList.remove; + const isArray = Array.isArray; + const { split, trim } = String.prototype; + const wordRegexp = /\s+/; + /** + * We regroup here all code related to updating attributes in a very loose sense: + * attributes, properties and classs are all managed by the functions in this + * file. + */ + function setAttribute(key, value) { + switch (value) { + case false: + case undefined: + removeAttribute.call(this, key); + break; + case true: + elemSetAttribute.call(this, key, ""); + break; + default: + elemSetAttribute.call(this, key, value); + } + } + function createAttrUpdater(attr) { + return function (value) { + setAttribute.call(this, attr, value); + }; + } + function attrsSetter(attrs) { + if (isArray(attrs)) { + if (attrs[0] === "class") { + setClass.call(this, attrs[1]); + } + else { + setAttribute.call(this, attrs[0], attrs[1]); + } + } + else { + for (let k in attrs) { + if (k === "class") { + setClass.call(this, attrs[k]); + } + else { + setAttribute.call(this, k, attrs[k]); + } + } + } + } + function attrsUpdater(attrs, oldAttrs) { + if (isArray(attrs)) { + const name = attrs[0]; + const val = attrs[1]; + if (name === oldAttrs[0]) { + if (val === oldAttrs[1]) { + return; + } + if (name === "class") { + updateClass.call(this, val, oldAttrs[1]); + } + else { + setAttribute.call(this, name, val); + } + } + else { + removeAttribute.call(this, oldAttrs[0]); + setAttribute.call(this, name, val); + } + } + else { + for (let k in oldAttrs) { + if (!(k in attrs)) { + if (k === "class") { + updateClass.call(this, "", oldAttrs[k]); + } + else { + removeAttribute.call(this, k); + } + } + } + for (let k in attrs) { + const val = attrs[k]; + if (val !== oldAttrs[k]) { + if (k === "class") { + updateClass.call(this, val, oldAttrs[k]); + } + else { + setAttribute.call(this, k, val); + } + } + } + } + } + function toClassObj(expr) { + const result = {}; + switch (typeof expr) { + case "string": + // we transform here a list of classes into an object: + // 'hey you' becomes {hey: true, you: true} + const str = trim.call(expr); + if (!str) { + return {}; + } + let words = split.call(str, wordRegexp); + for (let i = 0, l = words.length; i < l; i++) { + result[words[i]] = true; + } + return result; + case "object": + // this is already an object but we may need to split keys: + // {'a': true, 'b c': true} should become {a: true, b: true, c: true} + for (let key in expr) { + const value = expr[key]; + if (value) { + key = trim.call(key); + if (!key) { + continue; + } + const words = split.call(key, wordRegexp); + for (let word of words) { + result[word] = value; + } + } + } + return result; + case "undefined": + return {}; + case "number": + return { [expr]: true }; + default: + return { [expr]: true }; + } + } + function setClass(val) { + val = val === "" ? {} : toClassObj(val); + // add classes + const cl = this.classList; + for (let c in val) { + tokenListAdd.call(cl, c); + } + } + function updateClass(val, oldVal) { + oldVal = oldVal === "" ? {} : toClassObj(oldVal); + val = val === "" ? {} : toClassObj(val); + const cl = this.classList; + // remove classes + for (let c in oldVal) { + if (!(c in val)) { + tokenListRemove.call(cl, c); + } + } + // add classes + for (let c in val) { + if (!(c in oldVal)) { + tokenListAdd.call(cl, c); + } + } + } + + /** + * Creates a batched version of a callback so that all calls to it in the same + * microtick will only call the original callback once. + * + * @param callback the callback to batch + * @returns a batched version of the original callback + */ + function batched(callback) { + let scheduled = false; + return async (...args) => { + if (!scheduled) { + scheduled = true; + await Promise.resolve(); + scheduled = false; + callback(...args); + } + }; + } + /** + * Determine whether the given element is contained in its ownerDocument: + * either directly or with a shadow root in between. + */ + function inOwnerDocument(el) { + if (!el) { + return false; + } + if (el.ownerDocument.contains(el)) { + return true; + } + const rootNode = el.getRootNode(); + return rootNode instanceof ShadowRoot && el.ownerDocument.contains(rootNode.host); + } + function validateTarget(target) { + // Get the document and HTMLElement corresponding to the target to allow mounting in iframes + const document = target && target.ownerDocument; + if (document) { + const HTMLElement = document.defaultView.HTMLElement; + if (target instanceof HTMLElement || target instanceof ShadowRoot) { + if (!document.body.contains(target instanceof HTMLElement ? target : target.host)) { + throw new OwlError("Cannot mount a component on a detached dom node"); + } + return; + } + } + throw new OwlError("Cannot mount component: the target is not a valid DOM element"); + } + class EventBus extends EventTarget { + trigger(name, payload) { + this.dispatchEvent(new CustomEvent(name, { detail: payload })); + } + } + function whenReady(fn) { + return new Promise(function (resolve) { + if (document.readyState !== "loading") { + resolve(true); + } + else { + document.addEventListener("DOMContentLoaded", resolve, false); + } + }).then(fn || function () { }); + } + async function loadFile(url) { + const result = await fetch(url); + if (!result.ok) { + throw new OwlError("Error while fetching xml templates"); + } + return await result.text(); + } + /* + * This class just transports the fact that a string is safe + * to be injected as HTML. Overriding a JS primitive is quite painful though + * so we need to redfine toString and valueOf. + */ + class Markup extends String { + } + /* + * Marks a value as safe, that is, a value that can be injected as HTML directly. + * It should be used to wrap the value passed to a t-out directive to allow a raw rendering. + */ + function markup(value) { + return new Markup(value); + } + + function createEventHandler(rawEvent) { + const eventName = rawEvent.split(".")[0]; + const capture = rawEvent.includes(".capture"); + if (rawEvent.includes(".synthetic")) { + return createSyntheticHandler(eventName, capture); + } + else { + return createElementHandler(eventName, capture); + } + } + // Native listener + let nextNativeEventId = 1; + function createElementHandler(evName, capture = false) { + let eventKey = `__event__${evName}_${nextNativeEventId++}`; + if (capture) { + eventKey = `${eventKey}_capture`; + } + function listener(ev) { + const currentTarget = ev.currentTarget; + if (!currentTarget || !inOwnerDocument(currentTarget)) + return; + const data = currentTarget[eventKey]; + if (!data) + return; + config.mainEventHandler(data, ev, currentTarget); + } + function setup(data) { + this[eventKey] = data; + this.addEventListener(evName, listener, { capture }); + } + function remove() { + delete this[eventKey]; + this.removeEventListener(evName, listener, { capture }); + } + function update(data) { + this[eventKey] = data; + } + return { setup, update, remove }; + } + // Synthetic handler: a form of event delegation that allows placing only one + // listener per event type. + let nextSyntheticEventId = 1; + function createSyntheticHandler(evName, capture = false) { + let eventKey = `__event__synthetic_${evName}`; + if (capture) { + eventKey = `${eventKey}_capture`; + } + setupSyntheticEvent(evName, eventKey, capture); + const currentId = nextSyntheticEventId++; + function setup(data) { + const _data = this[eventKey] || {}; + _data[currentId] = data; + this[eventKey] = _data; + } + function remove() { + delete this[eventKey]; + } + return { setup, update: setup, remove }; + } + function nativeToSyntheticEvent(eventKey, event) { + let dom = event.target; + while (dom !== null) { + const _data = dom[eventKey]; + if (_data) { + for (const data of Object.values(_data)) { + const stopped = config.mainEventHandler(data, event, dom); + if (stopped) + return; + } + } + dom = dom.parentNode; + } + } + const CONFIGURED_SYNTHETIC_EVENTS = {}; + function setupSyntheticEvent(evName, eventKey, capture = false) { + if (CONFIGURED_SYNTHETIC_EVENTS[eventKey]) { + return; + } + document.addEventListener(evName, (event) => nativeToSyntheticEvent(eventKey, event), { + capture, + }); + CONFIGURED_SYNTHETIC_EVENTS[eventKey] = true; + } + + const getDescriptor$3 = (o, p) => Object.getOwnPropertyDescriptor(o, p); + const nodeProto$4 = Node.prototype; + const nodeInsertBefore$3 = nodeProto$4.insertBefore; + const nodeSetTextContent$1 = getDescriptor$3(nodeProto$4, "textContent").set; + const nodeRemoveChild$3 = nodeProto$4.removeChild; + // ----------------------------------------------------------------------------- + // Multi NODE + // ----------------------------------------------------------------------------- + class VMulti { + constructor(children) { + this.children = children; + } + mount(parent, afterNode) { + const children = this.children; + const l = children.length; + const anchors = new Array(l); + for (let i = 0; i < l; i++) { + let child = children[i]; + if (child) { + child.mount(parent, afterNode); + } + else { + const childAnchor = document.createTextNode(""); + anchors[i] = childAnchor; + nodeInsertBefore$3.call(parent, childAnchor, afterNode); + } + } + this.anchors = anchors; + this.parentEl = parent; + } + moveBeforeDOMNode(node, parent = this.parentEl) { + this.parentEl = parent; + const children = this.children; + const anchors = this.anchors; + for (let i = 0, l = children.length; i < l; i++) { + let child = children[i]; + if (child) { + child.moveBeforeDOMNode(node, parent); + } + else { + const anchor = anchors[i]; + nodeInsertBefore$3.call(parent, anchor, node); + } + } + } + moveBeforeVNode(other, afterNode) { + if (other) { + const next = other.children[0]; + afterNode = (next ? next.firstNode() : other.anchors[0]) || null; + } + const children = this.children; + const parent = this.parentEl; + const anchors = this.anchors; + for (let i = 0, l = children.length; i < l; i++) { + let child = children[i]; + if (child) { + child.moveBeforeVNode(null, afterNode); + } + else { + const anchor = anchors[i]; + nodeInsertBefore$3.call(parent, anchor, afterNode); + } + } + } + patch(other, withBeforeRemove) { + if (this === other) { + return; + } + const children1 = this.children; + const children2 = other.children; + const anchors = this.anchors; + const parentEl = this.parentEl; + for (let i = 0, l = children1.length; i < l; i++) { + const vn1 = children1[i]; + const vn2 = children2[i]; + if (vn1) { + if (vn2) { + vn1.patch(vn2, withBeforeRemove); + } + else { + const afterNode = vn1.firstNode(); + const anchor = document.createTextNode(""); + anchors[i] = anchor; + nodeInsertBefore$3.call(parentEl, anchor, afterNode); + if (withBeforeRemove) { + vn1.beforeRemove(); + } + vn1.remove(); + children1[i] = undefined; + } + } + else if (vn2) { + children1[i] = vn2; + const anchor = anchors[i]; + vn2.mount(parentEl, anchor); + nodeRemoveChild$3.call(parentEl, anchor); + } + } + } + beforeRemove() { + const children = this.children; + for (let i = 0, l = children.length; i < l; i++) { + const child = children[i]; + if (child) { + child.beforeRemove(); + } + } + } + remove() { + const parentEl = this.parentEl; + if (this.isOnlyChild) { + nodeSetTextContent$1.call(parentEl, ""); + } + else { + const children = this.children; + const anchors = this.anchors; + for (let i = 0, l = children.length; i < l; i++) { + const child = children[i]; + if (child) { + child.remove(); + } + else { + nodeRemoveChild$3.call(parentEl, anchors[i]); + } + } + } + } + firstNode() { + const child = this.children[0]; + return child ? child.firstNode() : this.anchors[0]; + } + toString() { + return this.children.map((c) => (c ? c.toString() : "")).join(""); + } + } + function multi(children) { + return new VMulti(children); + } + + const getDescriptor$2 = (o, p) => Object.getOwnPropertyDescriptor(o, p); + const nodeProto$3 = Node.prototype; + const characterDataProto$1 = CharacterData.prototype; + const nodeInsertBefore$2 = nodeProto$3.insertBefore; + const characterDataSetData$1 = getDescriptor$2(characterDataProto$1, "data").set; + const nodeRemoveChild$2 = nodeProto$3.removeChild; + class VSimpleNode { + constructor(text) { + this.text = text; + } + mountNode(node, parent, afterNode) { + this.parentEl = parent; + nodeInsertBefore$2.call(parent, node, afterNode); + this.el = node; + } + moveBeforeDOMNode(node, parent = this.parentEl) { + this.parentEl = parent; + nodeInsertBefore$2.call(parent, this.el, node); + } + moveBeforeVNode(other, afterNode) { + nodeInsertBefore$2.call(this.parentEl, this.el, other ? other.el : afterNode); + } + beforeRemove() { } + remove() { + nodeRemoveChild$2.call(this.parentEl, this.el); + } + firstNode() { + return this.el; + } + toString() { + return this.text; + } + } + class VText$1 extends VSimpleNode { + mount(parent, afterNode) { + this.mountNode(document.createTextNode(toText(this.text)), parent, afterNode); + } + patch(other) { + const text2 = other.text; + if (this.text !== text2) { + characterDataSetData$1.call(this.el, toText(text2)); + this.text = text2; + } + } + } + class VComment extends VSimpleNode { + mount(parent, afterNode) { + this.mountNode(document.createComment(toText(this.text)), parent, afterNode); + } + patch() { } + } + function text(str) { + return new VText$1(str); + } + function comment(str) { + return new VComment(str); + } + function toText(value) { + switch (typeof value) { + case "string": + return value; + case "number": + return String(value); + case "boolean": + return value ? "true" : "false"; + default: + return value || ""; + } + } + + const getDescriptor$1 = (o, p) => Object.getOwnPropertyDescriptor(o, p); + const nodeProto$2 = Node.prototype; + const elementProto = Element.prototype; + const characterDataProto = CharacterData.prototype; + const characterDataSetData = getDescriptor$1(characterDataProto, "data").set; + const nodeGetFirstChild = getDescriptor$1(nodeProto$2, "firstChild").get; + const nodeGetNextSibling = getDescriptor$1(nodeProto$2, "nextSibling").get; + const NO_OP = () => { }; + function makePropSetter(name) { + return function setProp(value) { + // support 0, fallback to empty string for other falsy values + this[name] = value === 0 ? 0 : value ? value.valueOf() : ""; + }; + } + const cache$1 = {}; + /** + * Compiling blocks is a multi-step process: + * + * 1. build an IntermediateTree from the HTML element. This intermediate tree + * is a binary tree structure that encode dynamic info sub nodes, and the + * path required to reach them + * 2. process the tree to build a block context, which is an object that aggregate + * all dynamic info in a list, and also, all ref indexes. + * 3. process the context to build appropriate builder/setter functions + * 4. make a dynamic block class, which will efficiently collect references and + * create/update dynamic locations/children + * + * @param str + * @returns a new block type, that can build concrete blocks + */ + function createBlock(str) { + if (str in cache$1) { + return cache$1[str]; + } + // step 0: prepare html base element + const doc = new DOMParser().parseFromString(`${str}`, "text/xml"); + const node = doc.firstChild.firstChild; + if (config.shouldNormalizeDom) { + normalizeNode(node); + } + // step 1: prepare intermediate tree + const tree = buildTree(node); + // step 2: prepare block context + const context = buildContext(tree); + // step 3: build the final block class + const template = tree.el; + const Block = buildBlock(template, context); + cache$1[str] = Block; + return Block; + } + // ----------------------------------------------------------------------------- + // Helper + // ----------------------------------------------------------------------------- + function normalizeNode(node) { + if (node.nodeType === Node.TEXT_NODE) { + if (!/\S/.test(node.textContent)) { + node.remove(); + return; + } + } + if (node.nodeType === Node.ELEMENT_NODE) { + if (node.tagName === "pre") { + return; + } + } + for (let i = node.childNodes.length - 1; i >= 0; --i) { + normalizeNode(node.childNodes.item(i)); + } + } + function buildTree(node, parent = null, domParentTree = null) { + switch (node.nodeType) { + case Node.ELEMENT_NODE: { + // HTMLElement + let currentNS = domParentTree && domParentTree.currentNS; + const tagName = node.tagName; + let el = undefined; + const info = []; + if (tagName.startsWith("block-text-")) { + const index = parseInt(tagName.slice(11), 10); + info.push({ type: "text", idx: index }); + el = document.createTextNode(""); + } + if (tagName.startsWith("block-child-")) { + if (!domParentTree.isRef) { + addRef(domParentTree); + } + const index = parseInt(tagName.slice(12), 10); + info.push({ type: "child", idx: index }); + el = document.createTextNode(""); + } + currentNS || (currentNS = node.namespaceURI); + if (!el) { + el = currentNS + ? document.createElementNS(currentNS, tagName) + : document.createElement(tagName); + } + if (el instanceof Element) { + if (!domParentTree) { + // some html elements may have side effects when setting their attributes. + // For example, setting the src attribute of an will trigger a + // request to get the corresponding image. This is something that we + // don't want at compile time. We avoid that by putting the content of + // the block in a