# -*- coding: utf-8 -*- # Copyright 2025 Sveltware Solutions import base64 import logging import re from odoo.tools.misc import file_path, file_open from odoo import api, models _logger = logging.getLogger(__name__) OMUX_ASSET_PREFIX = '_omux-' class ScssEditor(models.AbstractModel): _inherit = 'web_editor.assets' @property def DEF_OMLIGHT(self): return '' @property def ULIGHT(self): return '/udoo_om_ux/static/src/scss/omux/light.scss' @property def UDARK(self): return '/udoo_om_ux/static/src/scss/omux/dark.scss' # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Light # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @api.model def reset_omux_light(self, pattern=None): if not self.env.user.has_group('udoo_om_ux.group_omux'): return self = self.sudo() if not pattern: self.env.ref('udoo_om_ux.remove_light_in_dark').path = self.ULIGHT self._omux_reset(self.ULIGHT, 'web.assets_web') return light_scss = self._omux_decode_scss(self._get_omux_light()) if not light_scss: return scss_lines = [] for line in light_scss.splitlines(): if re.search(pattern, line) is None: scss_lines.append(line.strip()) self._set_omux_light('\n'.join(scss_lines)) def _get_omux_light(self): curl = self._omux_asset_url(self.ULIGHT, 'web.assets_web') attachment = self.env['ir.attachment'].search([('url', '=', curl)]) return attachment and base64.b64decode(attachment.datas) or self.DEF_OMLIGHT def _set_omux_light(self, content): curl, asset = self._omux_bundle(content, 'scss', self.ULIGHT, 'web.assets_web') self.env.ref('udoo_om_ux.remove_light_in_dark').path = curl @api.model def _remove_light_in_dark_correction(self): remove_light_asset = self.env.ref('udoo_om_ux.remove_light_in_dark', raise_if_not_found=False) if not remove_light_asset: return light_url = self._omux_asset_url(self.ULIGHT, 'web.assets_web') if self.env['ir.asset'].search([('path', '=', light_url)]): remove_light_asset.path = light_url self._normalize_new_omux_asset_paths() def _normalize_new_omux_asset_paths(self): OMUX_OLD_PREFIX = '/_omux/' IrAsset = self.env['ir.asset'] IrAttachment = self.env['ir.attachment'] # Process ir.asset records assets = IrAsset.sudo().with_context(active_test=False).search([('path', 'ilike', OMUX_OLD_PREFIX + '%')]) for ast in assets: new_path = ast.path.replace(OMUX_OLD_PREFIX, OMUX_ASSET_PREFIX) _logger.info('Updating IrAsset path: %s -> %s', ast.path, new_path) ast.write({'path': new_path}) # Process ir.attachment records attachments = IrAttachment.sudo().with_context(active_test=False).search([('name', 'ilike', OMUX_OLD_PREFIX + '%')]) for att in attachments: new_url = att.url.replace(OMUX_OLD_PREFIX, OMUX_ASSET_PREFIX) _logger.info('Updating IrAttachment: %s -> %s', att.url, new_url) att.write({'url': new_url, 'name': new_url}) # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Dark # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @api.model def reset_omux_dark(self, pattern=None): if not self.env.user.has_group('udoo_om_ux.group_omux'): return self = self.sudo() if not pattern: self._omux_reset(self.UDARK, 'web.assets_web_dark') return dark_scss = self._omux_decode_scss(self._get_omux_dark()) if not dark_scss: return scss_lines = [] for line in dark_scss.splitlines(): if re.search(pattern, line) is None: scss_lines.append(line.strip()) self._set_omux_dark('\n'.join(scss_lines)) def _get_omux_dark(self): curl = self._omux_asset_url(self.UDARK, 'web.assets_web_dark') attachment = self.env['ir.attachment'].search([('url', '=', curl)]) return attachment and base64.b64decode(attachment.datas) or '' def _set_omux_dark(self, content): self._omux_bundle(content, 'scss', self.UDARK, 'web.assets_web_dark') # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Theming # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @api.model def extf_omux_scheme(self, var_names): if not self.env.user.has_group('udoo_om_ux.group_omux'): return self = self.sudo() light_result = {} dcss_index = self._build_dynamic_omux_varx(False) scss_index = self._build_static_omux_varx(dcss_index, False) for var_name in var_names: light_result[var_name] = self._resolve_scss_var(var_name, scss_index) dark_result = {} dcss_index = self._build_dynamic_omux_varx(True) scss_index = self._build_static_omux_varx(dcss_index, True) for var_name in var_names: dark_result[var_name] = self._resolve_scss_var(var_name, scss_index) return (light_result, dark_result) def _build_dynamic_omux_varx(self, dark=False): scss_index = {} variable_pattern = re.compile( r'^\s*\$([A-Za-z0-9_-]+)\s*:\s*(#[0-9A-Fa-f]+|\$[A-Za-z0-9_-]+)\s*!default;', flags=re.MULTILINE, ) content = self._omux_decode_scss(self._get_omux_dark()) if dark else self._omux_decode_scss(self._get_omux_light()) matches = variable_pattern.findall(content) for var_name, value in matches: if var_name not in scss_index: scss_index[var_name] = value return scss_index def _build_static_omux_varx(self, scss_index, dark=False): paths = self._omux_asset_paths(dark) variable_pattern = re.compile( r'^\s*\$([A-Za-z0-9_-]+)\s*:\s*(#[0-9A-Fa-f]+|\$[A-Za-z0-9_-]+)\s*!default;', flags=re.MULTILINE, ) for path in self.env['ir.asset'].search([('path', 'ilike', f'omux_color_scheme%/scss/primary_variables{"_dark" if dark else ""}.scss')]).mapped('path') + paths: current_path = file_path(path) try: with file_open(current_path, 'rb') as f: content = f.read().decode('utf-8') matches = variable_pattern.findall(content) for var_name, value in matches: if var_name not in scss_index: scss_index[var_name] = value except FileNotFoundError: continue return scss_index def _resolve_scss_var(self, var_name, scss_index, _visited=None): if _visited is None: _visited = set() if var_name in _visited: _logger.warning("Circular reference detected: '%s'", var_name) return '' _visited.add(var_name) value = scss_index.get(var_name) if not value: return '' if value.startswith('#'): return value elif value.startswith('$'): next_var = value.removeprefix('$').strip() return self._resolve_scss_var(next_var, scss_index, _visited) return '' @api.model def repr_omux_scheme(self, repl_dict, dark=False): if not self.env.user.has_group('udoo_om_ux.group_omux'): return self = self.sudo() scss_content = self._omux_decode_scss(self._get_omux_dark()) if dark else self._omux_decode_scss(self._get_omux_light()) remove_keys = {repl_dict[k][0] for k in repl_dict} scss_lines = [] for line in scss_content.splitlines(): if not any(f'${key}:' in line for key in remove_keys): scss_lines.append(line.rstrip()) # Append replacements clearly at the end scss_lines.append('') # Ensure separation with an empty line for k in repl_dict: scss_lines.append(f'${repl_dict[k][0]}: {repl_dict[k][1]} !default; // Managed by Omux') if dark: self._set_omux_dark('\n'.join(scss_lines)) else: self._set_omux_light('\n'.join(scss_lines)) @api.model def repr_omux_font(self, params, asset_key=f'{OMUX_ASSET_PREFIX}backend_font.scss'): """ Represent fonts for OMUX interface. Handle 3 cases: both fk+fs, only fk, or only fs exists in params. """ if not params or not self.env.user.has_group('udoo_om_ux.group_omux'): return # Check what we have has_font = 'fk' in params and params['fk'] has_size = 'fs' in params and params['fs'] if not (has_font or has_size): return self = self.sudo() # Get current asset content if exists # Extract existing settings from attachment name existing_settings = {} if exist_asset := self.env['ir.asset'].search([('path', '=', asset_key)]): existing_settings = self._extract_font_settings(exist_asset.name) # Merge existing settings with new params merged_params = dict(existing_settings) for key, valid in (('fk', has_font), ('fs', has_size)): if params.get(key) == 'default': merged_params.pop(key, None) elif valid: merged_params[key] = params[key] if not merged_params: self.env['ir.attachment'].search([('url', '=', asset_key)]).unlink() self.env['ir.asset'].search([('path', '=', asset_key)]).unlink() return # Build content based on available params css_parts = [] font_name = None if 'fk' in merged_params and merged_params['fk']: font_name = merged_params['fk'].split(':')[0].replace('+', ' ') if font_name == 'Font 72': css_parts.append(""" @font-face { font-family: "72"; src: url('/omux_shared_lib/static/lib/font_72/72-Regular-full.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; } @font-face { font-family: "72"; src: url('/omux_shared_lib/static/lib/font_72/72-Semibold-full.woff2') format('woff2'); font-weight: 500; font-style: normal; font-display: swap; } @font-face { font-family: "72"; src: url('/omux_shared_lib/static/lib/font_72/72-Bold-full.woff2') format('woff2'); font-weight: 600; font-style: normal; font-display: swap; } $o-system-fonts: ("72", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji") !default;""") else: css_parts.append(f"@import url('https://fonts.googleapis.com/css2?family={merged_params['fk']}');") css_parts.append( f'$o-system-fonts: ("{font_name}", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji") !default;' ) if 'fs' in merged_params and merged_params['fs']: fs_value = round(float(merged_params['fs']) / 16, 4) css_parts.append(f'$o-font-size-base: {fs_value}rem !default;') content = '\n'.join(css_parts) # Create appropriate naming name_components = [] if font_name: name_components.append(f'Font: {font_name}') if 'fs' in merged_params and merged_params['fs']: name_components.append(f'Size: {merged_params["fs"]}') asset_name = 'Backend ' + ' + '.join(name_components) # Store the asset with metadata in name custom_url, target_asset = self._omux_bundle(content, 'scss', asset_name, 'web.assets_backend', 'prepend', asset_key) if target_asset: # Encode configuration in the name for state retrieval meta = [] if 'fk' in merged_params and merged_params['fk']: meta.append(f'Font: {merged_params["fk"]}') if 'fs' in merged_params and merged_params['fs']: meta.append(f'Size: {merged_params["fs"]}') target_asset.write({'name': f'[OMUX] {" | ".join(meta)}'}) # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Resolver # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ def _omux_asset_paths(self, dark): if dark: return [ 'udoo_om_ux/static/src/webclient/navbar/start_menu.variables.dark.scss', 'udoo_om_ux/static/src/webclient/navbar/start_menu.variables.scss', 'udoo_om_ux/static/src/scss/primary_variables_dark.scss', 'web/static/src/scss/primary_variables.scss', ] else: return [ 'udoo_om_ux/static/src/webclient/navbar/start_menu.variables.scss', 'udoo_om_ux/static/src/scss/primary_variables.scss', 'web/static/src/scss/primary_variables.scss', ] def _extract_font_settings(self, meta_string): result = {} if 'Font:' in meta_string: match = re.search(r'Font:\s*([^|]+)', meta_string) if match: result['fk'] = match.group(1).strip() if 'Size:' in meta_string: match = re.search(r'Size:\s*([^|]+)', meta_string) if match: result['fs'] = match.group(1).strip() return result def _omux_asset_url(self, url, bundle_xmlid): return f'{OMUX_ASSET_PREFIX}{bundle_xmlid}{url}' def _omux_reset(self, url, bundle): curl = self._omux_asset_url(url, bundle) self.env['ir.attachment'].search([('url', '=', curl)]).unlink() self.env['ir.asset'].search([('path', '=', curl)]).unlink() def _omux_bundle(self, content, type, url, bundle, directive='replace', custom_url=None): IrAsset = self.env['ir.asset'] IrAttachment = self.env['ir.attachment'] custom_url = custom_url or self._omux_asset_url(url, bundle) datas = base64.b64encode((content or '\n').encode('utf-8')) # Check if the file to save had already been modified custom_attachment = IrAttachment.search([('url', '=', custom_url)]) if custom_attachment: # If it was already modified, simply override the corresponding # attachment content custom_attachment.write({'datas': datas}) self.env.registry.clear_cache('assets') else: # If not, create a new attachment to copy the original scss/js file # content, with its modifications new_attach = { 'name': custom_url or url.split('/')[-1], 'type': 'binary', 'public': True, 'mimetype': (type == 'js' and 'text/javascript' or 'text/scss'), 'datas': datas, 'url': custom_url, } IrAttachment.with_context(not_force_website_id=True).create(new_attach) # Create an asset with the new attachment target_asset = IrAsset.search([('path', '=', custom_url)]) if not target_asset: new_asset = { 'path': custom_url, 'name': '[OMUX] ' + url, 'bundle': bundle, 'directive': directive, 'sequence': 98, # NOTE: Keep sequence >= 16 (DEFAULT_SEQUENCE) } if directive not in ['prepend', 'append', 'include']: new_asset['target'] = url target_asset = IrAsset.with_context(not_force_website_id=True).create(new_asset) return (custom_url, target_asset) def _omux_decode_scss(self, content): if isinstance(content, bytes): return content.decode('utf-8') return content or ''