diff --git a/runbot/__manifest__.py b/runbot/__manifest__.py index 584a4170..465231bc 100644 --- a/runbot/__manifest__.py +++ b/runbot/__manifest__.py @@ -67,6 +67,16 @@ 'runbot/static/src/diff_match_patch_module.js', 'runbot/static/src/fields/*', ], + 'runbot.assets_frontend': [ + ('include', 'web.assets_frontend_minimal'), # Pray the gods this stays named correctly + + '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', + ] }, 'post_load': 'runbot_post_load', } diff --git a/runbot/static/src/frontend/root.js b/runbot/static/src/frontend/root.js new file mode 100644 index 00000000..f4c24be1 --- /dev/null +++ b/runbot/static/src/frontend/root.js @@ -0,0 +1,64 @@ +import { whenReady, Component, xml, App, onError } from '@runbot/owl'; + +import { getTemplate } from '@web/core/templates'; +import { registry } from '@web/core/registry'; +import { useRegistry } from '@web/core/registry_hook'; + + +const mainComponents = registry.category('main.components'); + +class ErrorHandler extends Component { + static template = xml``; + static props = ["onError", "slots"]; + setup() { + onError((error) => { + this.props.onError(error); + }); + } +} + +class ComponentContainer extends Component { + static components = { ErrorHandler }; + static props = {}; + static template = xml` +
+ + + + + +
+ `; + + setup() { + this.Components = useRegistry(mainComponents); + } + + handleComponentError(error, C) { + console.error('Error while rendering', C[0], 'removing from app.'); + // remove the faulty component and rerender without it + this.Components.entries.splice(this.Components.entries.indexOf(C), 1); + this.render(); + /** + * we rethrow the error to notify the user something bad happened. + * We do it after a tick to make sure owl can properly finish its + * rendering + */ + Promise.resolve().then(() => { + throw error; + }); + } +} + +/** + * Bootstrap the frontend. + */ +(async function startApp() { + await whenReady(); + + const app = new App(ComponentContainer, { + getTemplate, + env: {}, + }); + await app.mount(document.body); +})(); diff --git a/runbot/static/src/vendored/registry.js b/runbot/static/src/vendored/registry.js new file mode 100644 index 00000000..9eef3aba --- /dev/null +++ b/runbot/static/src/vendored/registry.js @@ -0,0 +1,212 @@ +/** @odoo-module alias=@web/core/registry default=false **/ + + +import { EventBus, validate } from "@runbot/owl"; + +// ----------------------------------------------------------------------------- +// Errors +// ----------------------------------------------------------------------------- +export class KeyNotFoundError extends Error {} + +export class DuplicatedKeyError extends Error {} + +// ----------------------------------------------------------------------------- +// Validation +// ----------------------------------------------------------------------------- + +const validateSchema = (name, key, value, schema) => { + if (!odoo.debug) { + return; + } + try { + validate(value, schema); + } catch (error) { + throw new Error(`Validation error for key "${key}" in registry "${name}": ${error}`); + } +}; + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +/** + * @template S + * @template C + * @typedef {import("registries").RegistryData} RegistryData + */ + +/** + * @template T + * @typedef {T extends RegistryData ? T : RegistryData} ToRegistryData + */ + +/** + * @template T + * @typedef {ToRegistryData["__itemShape"]} GetRegistryItemShape + */ + +/** + * @template T + * @typedef {ToRegistryData["__categories"]} GetRegistryCategories + */ + +/** + * Registry + * + * The Registry class is basically just a mapping from a string key to an object. + * It is really not much more than an object. It is however useful for the + * following reasons: + * + * 1. it let us react and execute code when someone add something to the registry + * (for example, the FunctionRegistry subclass this for this purpose) + * 2. it throws an error when the get operation fails + * 3. it provides a chained API to add items to the registry. + * + * @template T + */ +export class Registry extends EventBus { + /** + * @param {string} [name] + */ + constructor(name) { + super(); + /** @type {Record]>}*/ + this.content = {}; + /** @type {{ [P in keyof GetRegistryCategories]?: Registry[P]> }} */ + this.subRegistries = {}; + /** @type {GetRegistryItemShape[]}*/ + this.elements = null; + /** @type {[string, GetRegistryItemShape][]}*/ + this.entries = null; + this.name = name; + this.validationSchema = null; + + this.addEventListener("UPDATE", () => { + this.elements = null; + this.entries = null; + }); + } + + /** + * Add an entry (key, value) to the registry if key is not already used. If + * the parameter force is set to true, an entry with same key (if any) is replaced. + * + * Note that this also returns the registry, so another add method call can + * be chained + * + * @param {string} key + * @param {GetRegistryItemShape} value + * @param {{force?: boolean, sequence?: number}} [options] + * @returns {Registry} + */ + add(key, value, { force, sequence } = {}) { + if (this.validationSchema) { + validateSchema(this.name, key, value, this.validationSchema); + } + if (!force && key in this.content) { + throw new DuplicatedKeyError( + `Cannot add key "${key}" in the "${this.name}" registry: it already exists` + ); + } + let previousSequence; + if (force) { + const elem = this.content[key]; + previousSequence = elem && elem[0]; + } + sequence = sequence === undefined ? previousSequence || 50 : sequence; + this.content[key] = [sequence, value]; + const payload = { operation: "add", key, value }; + this.trigger("UPDATE", payload); + return this; + } + + /** + * Get an item from the registry + * + * @param {string} key + * @returns {GetRegistryItemShape} + */ + get(key, defaultValue) { + if (arguments.length < 2 && !(key in this.content)) { + throw new KeyNotFoundError(`Cannot find key "${key}" in the "${this.name}" registry`); + } + const info = this.content[key]; + return info ? info[1] : defaultValue; + } + + /** + * Check the presence of a key in the registry + * + * @param {string} key + * @returns {boolean} + */ + contains(key) { + return key in this.content; + } + + /** + * Get a list of all elements in the registry. Note that it is ordered + * according to the sequence numbers. + * + * @returns {GetRegistryItemShape[]} + */ + getAll() { + if (!this.elements) { + const content = Object.values(this.content).sort((el1, el2) => el1[0] - el2[0]); + this.elements = content.map((elem) => elem[1]); + } + return this.elements.slice(); + } + + /** + * Return a list of all entries, ordered by sequence numbers. + * + * @returns {[string, GetRegistryItemShape][]} + */ + getEntries() { + if (!this.entries) { + const entries = Object.entries(this.content).sort((el1, el2) => el1[1][0] - el2[1][0]); + this.entries = entries.map(([str, elem]) => [str, elem[1]]); + } + return this.entries.slice(); + } + + /** + * Remove an item from the registry + * + * @param {string} key + */ + remove(key) { + const value = this.content[key]; + delete this.content[key]; + const payload = { operation: "delete", key, value }; + this.trigger("UPDATE", payload); + } + + /** + * Open a sub registry (and create it if necessary) + * + * @template {keyof GetRegistryCategories & string} K + * @param {K} subcategory + * @returns {Registry[K]>} + */ + category(subcategory) { + if (!(subcategory in this.subRegistries)) { + this.subRegistries[subcategory] = new Registry(subcategory); + } + return this.subRegistries[subcategory]; + } + + addValidation(schema) { + if (this.validationSchema) { + throw new Error("Validation schema already set on this registry"); + } + this.validationSchema = schema; + for (const [key, value] of this.getEntries()) { + validateSchema(this.name, key, value, schema); + } + } +} + +/** @type {Registry} */ +export const registry = new Registry(); diff --git a/runbot/static/src/vendored/registry_hook.js b/runbot/static/src/vendored/registry_hook.js new file mode 100644 index 00000000..43b83b4f --- /dev/null +++ b/runbot/static/src/vendored/registry_hook.js @@ -0,0 +1,28 @@ +/** @odoo-module alias=@web/core/registry_hook default=false **/ + + +import { useState, onWillStart, onWillDestroy } from "@runbot/owl"; + +export function useRegistry(registry) { + const state = useState({ entries: registry.getEntries() }); + const listener = ({ detail }) => { + const index = state.entries.findIndex(([k]) => k === detail.key); + if (detail.operation === "add" && index === -1) { + // push the new entry at the right place + const newEntries = registry.getEntries(); + const newEntry = newEntries.find(([k]) => k === detail.key); + const newIndex = newEntries.indexOf(newEntry); + if (newIndex === newEntries.length - 1) { + state.entries.push(newEntry); + } else { + state.entries.splice(newIndex, 0, newEntry); + } + } else if (detail.operation === "delete" && index >= 0) { + state.entries.splice(index, 1); + } + }; + + onWillStart(() => registry.addEventListener("UPDATE", listener)); + onWillDestroy(() => registry.removeEventListener("UPDATE", listener)); + return state; +} diff --git a/runbot/static/src/vendored/template_inheritance.js b/runbot/static/src/vendored/template_inheritance.js new file mode 100644 index 00000000..de498b2c --- /dev/null +++ b/runbot/static/src/vendored/template_inheritance.js @@ -0,0 +1,320 @@ +/** @odoo-module alias=@web/core/template_inheritance default=false **/ + +const RSTRIP_REGEXP = /(?=\n[ \t]*$)/; +/** + * The child nodes of operation represent new content to create before target or + * or other elements to move before target from the target tree (tree from which target is part of). + * Some processing of text nodes has to be done in order to normalize the situation. + * Note: we assume that target has a parent element. + * @param {Element} target + * @param {Element} operation + */ +function addBefore(target, operation) { + const nodes = getNodes(target, operation); + if (nodes.length === 0) { + return; + } + const { previousSibling } = target; + target.before(...nodes); + if (previousSibling?.nodeType === Node.TEXT_NODE) { + const [text1, text2] = previousSibling.data.split(RSTRIP_REGEXP); + previousSibling.data = text1.trimEnd(); + if (nodes[0].nodeType === Node.TEXT_NODE) { + mergeTextNodes(previousSibling, nodes[0]); + } + if (text2 && nodes.some((n) => n.nodeType !== Node.TEXT_NODE)) { + const textNode = document.createTextNode(text2); + target.before(textNode); + if (textNode.previousSibling.nodeType === Node.TEXT_NODE) { + mergeTextNodes(textNode.previousSibling, textNode); + } + } + } +} + +/** + * element is part of a tree. Here we return the root element of that tree. + * Note: this root element is not necessarily the documentElement of the ownerDocument + * of element (hence the following code). + * @param {Element} element + * @returns {Element} + */ +function getRoot(element) { + while (element.parentElement) { + element = element.parentElement; + } + return element; +} + +const HASCLASS_REGEXP = /hasclass\(([^)]*)\)/g; +const CLASS_CONTAINS_REGEX = /contains\(@class.*\)/g; +/** + * @param {Element} operation + * @returns {string} + */ +function getXpath(operation) { + const xpath = operation.getAttribute("expr"); + if (odoo.debug) { + if (CLASS_CONTAINS_REGEX.test(xpath)) { + const parent = operation.closest("t[t-inherit]"); + const templateName = parent.getAttribute("t-name") || parent.getAttribute("t-inherit"); + console.warn( + `Error-prone use of @class in template "${templateName}" (or one of its inheritors).` + + " Use the hasclass(*classes) function to filter elements by their classes" + ); + } + } + // hasclass does not exist in XPath 1.0 but is a custom function defined server side (see _hasclass) usable in lxml. + // Here we have to replace it by a complex condition (which is not nice). + // Note: we assume that classes do not contain the 2 chars , and ) + return xpath.replaceAll(HASCLASS_REGEXP, (_, capturedGroup) => { + return capturedGroup + .split(",") + .map((c) => `contains(concat(' ', @class, ' '), ' ${c.trim().slice(1, -1)} ')`) + .join(" and "); + }); +} + +/** + * @param {Element} element + * @param {Element} operation + * @returns {Node|null} + */ +function getNode(element, operation) { + const root = getRoot(element); + const doc = new Document(); + doc.appendChild(root); // => root is the documentElement of its ownerDocument (we do that in case root is a clone) + if (operation.tagName === "xpath") { + const xpath = getXpath(operation); + const result = doc.evaluate(xpath, root, null, XPathResult.FIRST_ORDERED_NODE_TYPE); + return result.singleNodeValue; + } + for (const elem of root.querySelectorAll(operation.tagName)) { + if ( + [...operation.attributes].every( + ({ name, value }) => name === "position" || elem.getAttribute(name) === value + ) + ) { + return elem; + } + } + return null; +} + +/** + * @param {Element} element + * @param {Element} operation + * @returns {Element} + */ +function getElement(element, operation) { + const node = getNode(element, operation); + if (!node) { + throw new Error(`Element '${operation.outerHTML}' cannot be located in element tree`); + } + if (!(node instanceof Element)) { + throw new Error(`Found node ${node} instead of an element`); + } + return node; +} + +/** + * @param {Element} element + * @param {Element} operation + * @returns {Node[]} + */ +function getNodes(element, operation) { + const nodes = []; + for (const childNode of operation.childNodes) { + if (childNode.tagName === "xpath" && childNode.getAttribute?.("position") === "move") { + const node = getElement(element, childNode); + removeNode(node); + nodes.push(node); + } else { + nodes.push(childNode); + } + } + return nodes; +} + +/** + * @param {Text} first + * @param {Text} second + * @param {boolean} [trimEnd=true] + */ +function mergeTextNodes(first, second, trimEnd = true) { + first.data = (trimEnd ? first.data.trimEnd() : first.data) + second.data; + second.remove(); +} + +function splitAndTrim(str, separator) { + return str.split(separator).map((s) => s.trim()); +} + +/** + * @param {Element} target + * @param {Element} operation + */ +function modifyAttributes(target, operation) { + for (const child of operation.children) { + if (child.tagName !== "attribute") { + continue; + } + const attributeName = child.getAttribute("name"); + const firstNode = child.childNodes[0]; + let value = firstNode?.nodeType === Node.TEXT_NODE ? firstNode.data : ""; + + const add = child.getAttribute("add") || ""; + const remove = child.getAttribute("remove") || ""; + if (add || remove) { + if (firstNode?.nodeType === Node.TEXT_NODE) { + throw new Error(`Useless element content ${firstNode.outerHTML}`); + } + const separator = child.getAttribute("separator") || ","; + const toRemove = new Set(splitAndTrim(remove, separator)); + const values = splitAndTrim(target.getAttribute(attributeName) || "", separator).filter( + (s) => !toRemove.has(s) + ); + values.push(...splitAndTrim(add, separator).filter((s) => s)); + value = values.join(separator); + } + + if (value) { + target.setAttribute(attributeName, value); + } else { + target.removeAttribute(attributeName); + } + } +} + +/** + * Remove node and normalize surrounind text nodes (if any) + * Note: we assume that node has a parent element + * @param {Node} node + */ +function removeNode(node) { + const { nextSibling, previousSibling } = node; + node.remove(); + if (nextSibling?.nodeType === Node.TEXT_NODE && previousSibling?.nodeType === Node.TEXT_NODE) { + mergeTextNodes( + previousSibling, + nextSibling, + previousSibling.parentElement.firstChild === previousSibling + ); + } +} + +/** + * @param {Element} root + * @param {Element} target + * @param {Element} operation + */ +function replace(root, target, operation) { + const mode = operation.getAttribute("mode") || "outer"; + switch (mode) { + case "outer": { + const result = operation.ownerDocument.evaluate( + ".//*[text()='$0']", + operation, + null, + XPathResult.ORDERED_NODE_SNAPSHOT_TYPE + ); + for (let i = 0; i < result.snapshotLength; i++) { + const loc = result.snapshotItem(i); + loc.firstChild.replaceWith(target.cloneNode(true)); + } + if (target.parentElement) { + const nodes = getNodes(target, operation); + target.replaceWith(...nodes); + } else { + let operationContent = null; + let comment = null; + for (const child of operation.childNodes) { + if (child.nodeType === Node.ELEMENT_NODE) { + operationContent = child; + break; + } + if (child.nodeType === Node.COMMENT_NODE) { + comment = child; + } + } + root = operationContent.cloneNode(true); + if (target.hasAttribute("t-name")) { + root.setAttribute("t-name", target.getAttribute("t-name")); + } + if (comment) { + root.prepend(comment); + } + } + break; + } + case "inner": + while (target.firstChild) { + target.removeChild(target.lastChild); + } + target.append(...operation.childNodes); + break; + default: + throw new Error(`Invalid mode attribute: '${mode}'`); + } + return root; +} + +/** + * @param {Element} root + * @param {Element} operations is a single element whose children represent operations to perform on root + * @param {string} [url=""] + * @returns {Element} root modified (in place) by the operations + */ +export function applyInheritance(root, operations, url = "") { + for (const operation of operations.children) { + const target = getElement(root, operation); + const position = operation.getAttribute("position") || "inside"; + + if (odoo.debug && url) { + const attributes = [...operation.attributes].map( + ({ name, value }) => + `${name}=${JSON.stringify(name === "position" ? position : value)}` + ); + const comment = document.createComment( + ` From file: ${url} ; ${attributes.join(" ; ")} ` + ); + if (position === "attributes") { + target.before(comment); // comment won't be visible if target is root + } else { + operation.prepend(comment); + } + } + + switch (position) { + case "replace": { + root = replace(root, target, operation); // root can be replaced (see outer mode) + break; + } + case "attributes": { + modifyAttributes(target, operation); + break; + } + case "inside": { + const sentinel = document.createElement("sentinel"); + target.append(sentinel); + addBefore(sentinel, operation); + removeNode(sentinel); + break; + } + case "after": { + const sentinel = document.createElement("sentinel"); + target.after(sentinel); + addBefore(sentinel, operation); + removeNode(sentinel); + break; + } + case "before": { + addBefore(target, operation); + break; + } + default: + throw new Error(`Invalid position attribute: '${position}'`); + } + } + return root; +} diff --git a/runbot/static/src/vendored/templates.js b/runbot/static/src/vendored/templates.js new file mode 100644 index 00000000..ccef0169 --- /dev/null +++ b/runbot/static/src/vendored/templates.js @@ -0,0 +1,176 @@ +/** @odoo-module alias=@web/core/templates default=false */ + +import { applyInheritance } from "@web/core/template_inheritance"; + +const parser = new DOMParser(); +/** @type {((document: Document) => void)[]} */ +const templateProcessors = []; +/** @type {((url: string) => boolean)[]} */ +let urlFilters = []; +function getParsedTemplate(templateString) { + const doc = parser.parseFromString(templateString, "text/xml"); + for (const processor of templateProcessors) { + processor(doc); + } + return doc.firstChild; +} + +function getClone(template) { + const c = template.cloneNode(true); + new Document().append(c); // => c is the documentElement of its ownerDocument + return c; +} + +const registered = new Set(); +function isRegistered(...args) { + const key = JSON.stringify([...args]); + if (registered.has(key)) { + return true; + } + registered.add(key); + return false; +} + +let blockType = null; +let blockId = 0; + +const templates = {}; +const parsedTemplates = {}; +const info = {}; +export function registerTemplate(name, url, templateString) { + if (isRegistered(...arguments)) { + return; + } + if (blockType !== "templates") { + blockType = "templates"; + blockId++; + } + if (name in templates && (info[name].url !== url || templates[name] !== templateString)) { + throw new Error(`Template ${name} already exists`); + } + templates[name] = templateString; + info[name] = { blockId, url }; +} + +const templateExtensions = {}; +const parsedTemplateExtensions = {}; +export function registerTemplateExtension(inheritFrom, url, templateString) { + if (isRegistered(...arguments)) { + return; + } + if (blockType !== "extensions") { + blockType = "extensions"; + blockId++; + } + if (!templateExtensions[inheritFrom]) { + templateExtensions[inheritFrom] = []; + } + if (!templateExtensions[inheritFrom][blockId]) { + templateExtensions[inheritFrom][blockId] = []; + } + templateExtensions[inheritFrom][blockId].push({ + templateString, + url, + }); +} + +/** + * @param {(document: Document) => void} processor + */ +export function registerTemplateProcessor(processor) { + templateProcessors.push(processor); +} + +/** + * @param {typeof urlFilters} filters + */ +export function setUrlFilters(filters) { + urlFilters = filters; +} + +function _getTemplate(name, blockId = null) { + if (!(name in parsedTemplates)) { + if (!(name in templates)) { + return null; + } + const templateString = templates[name]; + parsedTemplates[name] = getParsedTemplate(templateString); + } + let processedTemplate = parsedTemplates[name]; + + const inheritFrom = processedTemplate.getAttribute("t-inherit"); + if (inheritFrom) { + const parentTemplate = _getTemplate(inheritFrom, blockId || info[name].blockId); + if (!parentTemplate) { + throw new Error( + `Constructing template ${name}: template parent ${inheritFrom} not found` + ); + } + const element = getClone(processedTemplate); + processedTemplate = applyInheritance(getClone(parentTemplate), element, info[name].url); + if (processedTemplate.tagName !== element.tagName) { + const temp = processedTemplate; + processedTemplate = new Document().createElement(element.tagName); + processedTemplate.append(...temp.childNodes); + } + for (const { name, value } of element.attributes) { + if (!["t-inherit", "t-inherit-mode"].includes(name)) { + processedTemplate.setAttribute(name, value); + } + } + } + + for (const otherBlockId in templateExtensions[name] || {}) { + if (blockId && otherBlockId > blockId) { + break; + } + if (!(name in parsedTemplateExtensions)) { + parsedTemplateExtensions[name] = {}; + } + if (!(otherBlockId in parsedTemplateExtensions[name])) { + parsedTemplateExtensions[name][otherBlockId] = []; + for (const { templateString, url } of templateExtensions[name][otherBlockId]) { + parsedTemplateExtensions[name][otherBlockId].push({ + template: getParsedTemplate(templateString), + url, + }); + } + } + for (const { template, url } of parsedTemplateExtensions[name][otherBlockId]) { + if (!urlFilters.every((filter) => filter(url))) { + continue; + } + processedTemplate = applyInheritance( + inheritFrom ? processedTemplate : getClone(processedTemplate), + getClone(template), + url + ); + } + } + + return processedTemplate; +} + +/** @type {Record} */ +let processedTemplates = {}; + +/** + * @param {string} name + */ +export function getTemplate(name) { + if (!processedTemplates[name]) { + processedTemplates[name] = _getTemplate(name); + } + return processedTemplates[name]; +} + +export function clearProcessedTemplates() { + processedTemplates = {}; +} + +export function checkPrimaryTemplateParents(namesToCheck) { + const missing = new Set(namesToCheck.filter((name) => !(name in templates))); + if (missing.size) { + console.error(`Missing (primary) parent templates: ${[...missing].join(", ")}`); + } +} diff --git a/runbot/templates/utils.xml b/runbot/templates/utils.xml index 10089f7e..db3bddba 100644 --- a/runbot/templates/utils.xml +++ b/runbot/templates/utils.xml @@ -15,6 +15,8 @@