yuthon_base/udoo_om_ux/models/web_editor.py
李鹏宇 cd653ce24c 1
2026-06-04 14:24:28 +08:00

421 lines
16 KiB
Python

# -*- 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 ''