- Copy all OWL frontend assets (JS/CSS/XML/fonts/images) into frontend/ directory (2925 files, 43MB) — no more runtime reads from Python Odoo - Replace OdooAddonsPath config with FrontendDir pointing to local frontend/ - Rewire bundle.go, static.go, templates.go, webclient.go to read from frontend/ instead of external Python Odoo addons directory - Auto-detect frontend/ and build/ dirs relative to binary in main.go - Delete obsolete Python helper scripts (tools/*.py) The Go server is now fully self-contained: single binary + frontend/ folder. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
398 lines
11 KiB
Go
398 lines
11 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// compileXMLTemplates reads all XML template files listed in assets_xml.txt,
|
|
// parses them, and generates a JS module with registerTemplate() /
|
|
// registerTemplateExtension() calls -- the same output that
|
|
// tools/compile_templates.py used to produce at build time.
|
|
//
|
|
// Mirrors: odoo/addons/base/models/assetsbundle.py generate_xml_bundle()
|
|
func compileXMLTemplates(frontendDir string) string {
|
|
// xmlFiles is populated by init() in webclient.go from the embedded
|
|
// assets_xml.txt file.
|
|
|
|
// Collect blocks of templates and extensions to preserve ordering
|
|
// semantics that Odoo relies on (primary templates first, then
|
|
// extensions, interleaved per-file).
|
|
type primaryEntry struct {
|
|
name string
|
|
url string
|
|
xmlStr string
|
|
inheritFrom string
|
|
}
|
|
type extensionEntry struct {
|
|
inheritFrom string
|
|
url string
|
|
xmlStr string
|
|
}
|
|
type block struct {
|
|
kind string // "templates" or "extensions"
|
|
templates []primaryEntry
|
|
extensions []extensionEntry
|
|
}
|
|
|
|
var blocks []block
|
|
var curBlock *block
|
|
templateCount := 0
|
|
errorCount := 0
|
|
|
|
for _, urlPath := range xmlFiles {
|
|
content := readFileFromFrontend(frontendDir, urlPath)
|
|
if content == "" {
|
|
continue
|
|
}
|
|
|
|
// Parse the XML: we need the immediate children of the root element
|
|
// (typically <templates>). Each child with t-name is a primary
|
|
// template; each with t-inherit + t-inherit-mode="extension" is an
|
|
// extension.
|
|
elements, err := parseXMLTemplateFile(content)
|
|
if err != nil {
|
|
log.Printf("templates: ERROR parsing %s: %v", urlPath, err)
|
|
errorCount++
|
|
continue
|
|
}
|
|
|
|
for _, elem := range elements {
|
|
tName := elemAttr(elem, "t-name")
|
|
tInherit := elemAttr(elem, "t-inherit")
|
|
tInheritMode := ""
|
|
if tInherit != "" {
|
|
tInheritMode = elemAttr(elem, "t-inherit-mode")
|
|
if tInheritMode == "" {
|
|
tInheritMode = "primary"
|
|
}
|
|
if tInheritMode != "primary" && tInheritMode != "extension" {
|
|
log.Printf("templates: ERROR invalid inherit mode %q in %s template %s", tInheritMode, urlPath, tName)
|
|
errorCount++
|
|
continue
|
|
}
|
|
}
|
|
|
|
serialized := serializeElement(elem)
|
|
|
|
if tInheritMode == "extension" {
|
|
if curBlock == nil || curBlock.kind != "extensions" {
|
|
blocks = append(blocks, block{kind: "extensions"})
|
|
curBlock = &blocks[len(blocks)-1]
|
|
}
|
|
curBlock.extensions = append(curBlock.extensions, extensionEntry{
|
|
inheritFrom: tInherit,
|
|
url: urlPath,
|
|
xmlStr: serialized,
|
|
})
|
|
templateCount++
|
|
} else if tName != "" {
|
|
if curBlock == nil || curBlock.kind != "templates" {
|
|
blocks = append(blocks, block{kind: "templates"})
|
|
curBlock = &blocks[len(blocks)-1]
|
|
}
|
|
curBlock.templates = append(curBlock.templates, primaryEntry{
|
|
name: tName,
|
|
url: urlPath,
|
|
xmlStr: serialized,
|
|
inheritFrom: tInherit,
|
|
})
|
|
templateCount++
|
|
}
|
|
// Elements without t-name and without extension mode are skipped.
|
|
}
|
|
}
|
|
|
|
// Generate JS registerTemplate / registerTemplateExtension calls.
|
|
var content []string
|
|
names := map[string]bool{}
|
|
primaryParents := map[string]bool{}
|
|
extensionParents := map[string]bool{}
|
|
|
|
for i := range blocks {
|
|
blk := &blocks[i]
|
|
if blk.kind == "templates" {
|
|
for _, t := range blk.templates {
|
|
if t.inheritFrom != "" {
|
|
primaryParents[t.inheritFrom] = true
|
|
}
|
|
names[t.name] = true
|
|
escaped := escapeForJSTemplateLiteral(t.xmlStr)
|
|
content = append(content, fmt.Sprintf("registerTemplate(%q, `%s`, `%s`);",
|
|
t.name, t.url, escaped))
|
|
}
|
|
} else {
|
|
for _, ext := range blk.extensions {
|
|
extensionParents[ext.inheritFrom] = true
|
|
escaped := escapeForJSTemplateLiteral(ext.xmlStr)
|
|
content = append(content, fmt.Sprintf("registerTemplateExtension(%q, `%s`, `%s`);",
|
|
ext.inheritFrom, ext.url, escaped))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for missing parent templates (diagnostic aid).
|
|
var missingPrimary []string
|
|
for p := range primaryParents {
|
|
if !names[p] {
|
|
missingPrimary = append(missingPrimary, p)
|
|
}
|
|
}
|
|
if len(missingPrimary) > 0 {
|
|
content = append(content, fmt.Sprintf("checkPrimaryTemplateParents(%s);", jsonStringArray(missingPrimary)))
|
|
}
|
|
|
|
var missingExtension []string
|
|
for p := range extensionParents {
|
|
if !names[p] {
|
|
missingExtension = append(missingExtension, p)
|
|
}
|
|
}
|
|
if len(missingExtension) > 0 {
|
|
content = append(content, fmt.Sprintf("console.error(\"Missing (extension) parent templates: %s\");",
|
|
strings.Join(missingExtension, ", ")))
|
|
}
|
|
|
|
// Wrap in odoo.define module.
|
|
var buf strings.Builder
|
|
buf.WriteString("odoo.define(\"@web/bundle_xml\", [\"@web/core/templates\"], function(require) {\n")
|
|
buf.WriteString(" \"use strict\";\n")
|
|
buf.WriteString(" const { checkPrimaryTemplateParents, registerTemplate, registerTemplateExtension } = require(\"@web/core/templates\");\n")
|
|
buf.WriteString("\n")
|
|
for _, line := range content {
|
|
buf.WriteString(line)
|
|
buf.WriteString("\n")
|
|
}
|
|
buf.WriteString("});\n")
|
|
|
|
log.Printf("templates: %d templates compiled, %d errors", templateCount, errorCount)
|
|
return buf.String()
|
|
}
|
|
|
|
// readFileFromFrontend reads a file from the frontend assets directory.
|
|
func readFileFromFrontend(frontendDir string, urlPath string) string {
|
|
if frontendDir == "" {
|
|
return ""
|
|
}
|
|
rel := strings.TrimPrefix(urlPath, "/")
|
|
fullPath := filepath.Join(frontendDir, rel)
|
|
data, err := os.ReadFile(fullPath)
|
|
if err == nil {
|
|
return string(data)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// --- XML parsing ---------------------------------------------------------
|
|
//
|
|
// Strategy: we use encoding/xml.Decoder only to identify where each direct
|
|
// child of the root element starts and ends (via InputOffset). Then we
|
|
// extract the raw substring from the original file content. This preserves
|
|
// whitespace, attribute order, namespace prefixes, and all other details
|
|
// exactly as they appear in the source file -- critical for OWL template
|
|
// fidelity.
|
|
//
|
|
// After extraction we strip XML comments from the raw snippet (matching
|
|
// Python lxml's remove_comments=True) and inject the xml:space="preserve"
|
|
// attribute into the root element of the snippet (matching Python lxml's
|
|
// etree.tostring behavior after element.set(xml:space, "preserve")).
|
|
|
|
// xmlElement represents a parsed direct child of the XML root.
|
|
type xmlElement struct {
|
|
raw string // the serialized XML string of the element
|
|
attrs map[string]string // element attributes (for t-name, t-inherit, etc.)
|
|
}
|
|
|
|
func elemAttr(e xmlElement, name string) string {
|
|
return e.attrs[name]
|
|
}
|
|
|
|
// parseXMLTemplateFile extracts the direct children of the root element.
|
|
// It returns each child as an xmlElement with its raw XML and attributes.
|
|
func parseXMLTemplateFile(fileContent string) ([]xmlElement, error) {
|
|
decoder := xml.NewDecoder(strings.NewReader(fileContent))
|
|
// Odoo templates use custom attribute names (t-name, t-if, etc.) that
|
|
// are valid XML but may trigger namespace warnings. Use non-strict
|
|
// mode and provide an entity map for common HTML entities.
|
|
decoder.Strict = false
|
|
decoder.Entity = defaultXMLEntities()
|
|
|
|
var elements []xmlElement
|
|
depth := 0 // 0 = outside root, 1 = inside root, 2+ = inside child
|
|
|
|
// childStartOffset: byte offset just before the '<' of the child's
|
|
// opening tag.
|
|
childStartOffset := int64(0)
|
|
childDepth := 0
|
|
inChild := false
|
|
var childAttrs map[string]string
|
|
|
|
for {
|
|
// Capture offset before consuming token. InputOffset reports the
|
|
// byte position of the NEXT byte the decoder will read, so we
|
|
// record it before calling Token() to get the start of each token.
|
|
offset := decoder.InputOffset()
|
|
tok, err := decoder.Token()
|
|
if err != nil {
|
|
break // EOF or error
|
|
}
|
|
|
|
switch t := tok.(type) {
|
|
case xml.StartElement:
|
|
depth++
|
|
if depth == 2 && !inChild {
|
|
// Start of a direct child of root
|
|
inChild = true
|
|
childStartOffset = offset
|
|
childDepth = 1
|
|
childAttrs = make(map[string]string)
|
|
for _, a := range t.Attr {
|
|
key := a.Name.Local
|
|
if a.Name.Space != "" {
|
|
key = a.Name.Space + ":" + key
|
|
}
|
|
childAttrs[key] = a.Value
|
|
}
|
|
} else if inChild {
|
|
childDepth++
|
|
}
|
|
case xml.EndElement:
|
|
if inChild {
|
|
childDepth--
|
|
if childDepth == 0 {
|
|
// End of direct child -- extract raw XML.
|
|
endOffset := decoder.InputOffset()
|
|
raw := fileContent[childStartOffset:endOffset]
|
|
|
|
// Strip XML comments (<!-- ... -->)
|
|
raw = stripXMLComments(raw)
|
|
|
|
// Inject xml:space="preserve" into root element
|
|
raw = injectXmlSpacePreserve(raw)
|
|
|
|
elements = append(elements, xmlElement{
|
|
raw: raw,
|
|
attrs: childAttrs,
|
|
})
|
|
inChild = false
|
|
childAttrs = nil
|
|
}
|
|
}
|
|
depth--
|
|
}
|
|
}
|
|
|
|
return elements, nil
|
|
}
|
|
|
|
// stripXMLComments removes all XML comments from a string.
|
|
func stripXMLComments(s string) string {
|
|
for {
|
|
start := strings.Index(s, "<!--")
|
|
if start < 0 {
|
|
break
|
|
}
|
|
end := strings.Index(s[start:], "-->")
|
|
if end < 0 {
|
|
break
|
|
}
|
|
s = s[:start] + s[start+end+3:]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// injectXmlSpacePreserve adds xml:space="preserve" to the first opening
|
|
// tag in the string, unless it already has that attribute.
|
|
func injectXmlSpacePreserve(s string) string {
|
|
if strings.Contains(s, `xml:space=`) {
|
|
return s
|
|
}
|
|
// Find the end of the first opening tag (the first '>' that isn't
|
|
// inside an attribute value).
|
|
idx := findFirstTagClose(s)
|
|
if idx < 0 {
|
|
return s
|
|
}
|
|
return s[:idx] + ` xml:space="preserve"` + s[idx:]
|
|
}
|
|
|
|
// findFirstTagClose returns the index of the '>' that closes the first
|
|
// start tag in the string, correctly skipping over '>' characters inside
|
|
// attribute values.
|
|
func findFirstTagClose(s string) int {
|
|
inAttr := false
|
|
attrQuote := byte(0)
|
|
started := false
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if !started {
|
|
if c == '<' {
|
|
started = true
|
|
}
|
|
continue
|
|
}
|
|
if inAttr {
|
|
if c == attrQuote {
|
|
inAttr = false
|
|
}
|
|
continue
|
|
}
|
|
if c == '"' || c == '\'' {
|
|
inAttr = true
|
|
attrQuote = c
|
|
continue
|
|
}
|
|
if c == '>' {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// defaultXMLEntities returns a map of common HTML entities that may appear
|
|
// in Odoo templates (encoding/xml only knows the 5 XML built-in entities).
|
|
func defaultXMLEntities() map[string]string {
|
|
return map[string]string{
|
|
"nbsp": "\u00A0",
|
|
"lt": "<",
|
|
"gt": ">",
|
|
"amp": "&",
|
|
"quot": "\"",
|
|
"apos": "'",
|
|
}
|
|
}
|
|
|
|
// --- JS escaping ---------------------------------------------------------
|
|
|
|
// escapeForJSTemplateLiteral escapes a string for use inside a JS template
|
|
// literal (backtick-delimited string).
|
|
func escapeForJSTemplateLiteral(s string) string {
|
|
s = strings.ReplaceAll(s, "\\", "\\\\")
|
|
s = strings.ReplaceAll(s, "`", "\\`")
|
|
s = strings.ReplaceAll(s, "${", "\\${")
|
|
return s
|
|
}
|
|
|
|
// --- Helpers -------------------------------------------------------------
|
|
|
|
// serializeElement returns the raw XML string of an xmlElement.
|
|
func serializeElement(e xmlElement) string {
|
|
return e.raw
|
|
}
|
|
|
|
// jsonStringArray returns a JSON-encoded array of strings (simple, no
|
|
// external dependency needed).
|
|
func jsonStringArray(items []string) string {
|
|
var parts []string
|
|
for _, s := range items {
|
|
escaped := strings.ReplaceAll(s, "\\", "\\\\")
|
|
escaped = strings.ReplaceAll(escaped, "\"", "\\\"")
|
|
parts = append(parts, "\""+escaped+"\"")
|
|
}
|
|
return "[" + strings.Join(parts, ", ") + "]"
|
|
}
|