Files
goodie/pkg/server/transpiler.go
Marc 9c444061fd Backend improvements: views, fields_get, session, RPC stubs
- Improved auto-generated list/form/search views with priority fields,
  two-column form layout, statusbar widget, notebook for O2M fields
- Enhanced fields_get with currency_field, compute, related metadata
- Fixed session handling: handleSessionInfo/handleSessionCheck use real
  session from cookie instead of hardcoded values
- Added read_progress_bar and activity_format RPC stubs
- Improved bootstrap translations with lang_parameters
- Added "contacts" to session modules list

Server starts successfully: 14 modules, 93 models, 378 XML templates,
503 JS modules transpiled — all from local frontend/ directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:16:26 +02:00

554 lines
17 KiB
Go

// Package server — JS module transpiler.
// Mirrors: odoo/tools/js_transpiler.py
//
// Converts ES module syntax (import/export) to odoo.define() format:
//
// import { X } from "@web/foo" --> const { X } = require("@web/foo")
// export class Foo { ... } --> const Foo = __exports.Foo = class Foo { ... }
//
// Wrapped in:
//
// odoo.define("@web/core/foo", ["@web/foo"], function(require) {
// "use strict"; let __exports = {};
// ...
// return __exports;
// });
package server
import (
"fmt"
"regexp"
"strings"
)
// Compiled regex patterns for import/export matching.
// Mirrors: odoo/tools/js_transpiler.py URL_RE, IMPORT_RE, etc.
var (
// Import patterns — (?m)^ ensures we only match at line start (not inside comments)
reNamedImport = regexp.MustCompile(`(?m)^\s*import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']\s*;?`)
reDefaultImport = regexp.MustCompile(`(?m)^\s*import\s+(\w+)\s+from\s*["']([^"']+)["']\s*;?`)
reNamespaceImport = regexp.MustCompile(`(?m)^\s*import\s*\*\s*as\s+(\w+)\s+from\s*["']([^"']+)["']\s*;?`)
reSideEffectImport = regexp.MustCompile(`(?m)^\s*import\s*["']([^"']+)["']\s*;?`)
// Export patterns
reExportClass = regexp.MustCompile(`export\s+class\s+(\w+)`)
reExportFunction = regexp.MustCompile(`export\s+(async\s+)?function\s+(\w+)`)
reExportConst = regexp.MustCompile(`export\s+const\s+(\w+)`)
reExportLet = regexp.MustCompile(`export\s+let\s+(\w+)`)
reExportDefault = regexp.MustCompile(`export\s+default\s+`)
reExportNamedFrom = regexp.MustCompile(`export\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']\s*;?`)
reExportNamed = regexp.MustCompile(`export\s*\{([^}]*)\}\s*;?`)
reExportStar = regexp.MustCompile(`export\s*\*\s*from\s*["']([^"']+)["']\s*;?`)
// Block comment removal
reBlockComment = regexp.MustCompile(`(?s)/\*.*?\*/`)
// Detection patterns
reHasImport = regexp.MustCompile(`(?m)^\s*import\s`)
reHasExport = regexp.MustCompile(`(?m)^\s*export\s`)
reOdooModuleTag = regexp.MustCompile(`(?m)^\s*//\s*@odoo-module`)
reOdooModuleIgnore = regexp.MustCompile(`(?m)^\s*//\s*@odoo-module\s+ignore`)
)
// TranspileJS converts an ES module JS file to odoo.define() format.
// Mirrors: odoo/tools/js_transpiler.py transpile_javascript()
//
// urlPath is the URL-style path, e.g. "/web/static/src/core/foo.js".
// content is the raw JS source code.
// Returns the transpiled source, or the original content if the file is not
// an ES module.
func TranspileJS(urlPath, content string) string {
if !IsOdooModule(urlPath, content) {
return content
}
moduleName := URLToModuleName(urlPath)
// Extract imports and build dependency list
deps, requireLines, cleanContent := extractImports(moduleName, content)
// Transform exports
cleanContent = transformExports(cleanContent)
// Wrap in odoo.define
return wrapWithOdooDefine(moduleName, deps, requireLines, cleanContent)
}
// URLToModuleName converts a URL path to an Odoo module name.
// Mirrors: odoo/tools/js_transpiler.py url_to_module_name()
//
// Examples:
//
// /web/static/src/core/foo.js -> @web/core/foo
// /web/static/src/env.js -> @web/env
// /web/static/lib/luxon/luxon.js -> @web/../lib/luxon/luxon
// /stock/static/src/widgets/foo.js -> @stock/widgets/foo
func URLToModuleName(url string) string {
// Remove leading slash
path := strings.TrimPrefix(url, "/")
// Remove .js extension
path = strings.TrimSuffix(path, ".js")
// Split into addon name and the rest
parts := strings.SplitN(path, "/", 2)
if len(parts) < 2 {
return "@" + path
}
addonName := parts[0]
rest := parts[1]
// Remove "static/src/" prefix from the rest
if strings.HasPrefix(rest, "static/src/") {
rest = strings.TrimPrefix(rest, "static/src/")
} else if strings.HasPrefix(rest, "static/") {
// For lib files: static/lib/foo -> ../lib/foo
rest = "../" + strings.TrimPrefix(rest, "static/")
}
return "@" + addonName + "/" + rest
}
// resolveRelativeImport converts relative import paths to absolute module names.
// E.g., if current module is "@web/core/browser/feature_detection" and dep is "./browser",
// it resolves to "@web/core/browser/browser".
// "../utils/hooks" from "@web/core/browser/feature_detection" → "@web/core/utils/hooks"
func resolveRelativeImport(currentModule, dep string) string {
if !strings.HasPrefix(dep, "./") && !strings.HasPrefix(dep, "../") {
return dep // Already absolute
}
// Split current module into parts: @web/core/browser/feature_detection → [core, browser]
// (remove the @addon/ prefix and the filename)
parts := strings.Split(currentModule, "/")
if len(parts) < 2 {
return dep
}
// Get the directory of the current module (drop the last segment = filename)
dir := parts[:len(parts)-1]
// Resolve the relative path
relParts := strings.Split(dep, "/")
for _, p := range relParts {
if p == "." {
continue
} else if p == ".." {
if len(dir) > 1 {
dir = dir[:len(dir)-1]
}
} else {
dir = append(dir, p)
}
}
return strings.Join(dir, "/")
}
// IsOdooModule determines whether a JS file should be transpiled.
// Mirrors: odoo/tools/js_transpiler.py is_odoo_module()
//
// Returns true if the file contains ES module syntax (import/export) or
// the @odoo-module tag (without "ignore").
func IsOdooModule(url, content string) bool {
// Must be a JS file
if !strings.HasSuffix(url, ".js") {
return false
}
// Explicit ignore directive
if reOdooModuleIgnore.MatchString(content) {
return false
}
// Explicit @odoo-module tag
if reOdooModuleTag.MatchString(content) {
return true
}
// Has import or export statements
if reHasImport.MatchString(content) || reHasExport.MatchString(content) {
return true
}
return false
}
// extractImports finds all import statements in the content, returns:
// - deps: list of dependency module names (for the odoo.define deps array)
// - requireLines: list of "const ... = require(...)" lines
// - cleanContent: content with import statements removed
func extractImports(moduleName, content string) (deps []string, requireLines []string, cleanContent string) {
depSet := make(map[string]bool)
var depOrder []string
resolve := func(dep string) string {
return resolveRelativeImport(moduleName, dep)
}
addDep := func(dep string) {
dep = resolve(dep)
if !depSet[dep] {
depSet[dep] = true
depOrder = append(depOrder, dep)
}
}
cleanContent = content
// Remove @odoo-module tag line (not needed in output)
cleanContent = reOdooModuleTag.ReplaceAllString(cleanContent, "")
// Don't strip block comments (it breaks string literals containing /*).
// Instead, the import regexes below only match at positions that are
// clearly actual code, not inside comments. Since import/export statements
// in ES modules must appear at the top level (before any function body),
// they'll always be at the beginning of a line. The regexes already handle
// this correctly for most cases. The one edge case (import inside JSDoc)
// is handled by checking the matched line doesn't start with * or //.
// Named imports: import { X, Y as Z } from "dep"
cleanContent = reNamedImport.ReplaceAllStringFunc(cleanContent, func(match string) string {
m := reNamedImport.FindStringSubmatch(match)
if len(m) < 3 {
return match
}
names := m[1]
dep := m[2]
addDep(dep)
// Parse the import specifiers, handle "as" aliases
specifiers := parseImportSpecifiers(names)
if len(specifiers) == 0 {
return ""
}
// Build destructuring: const { X, Y: Z } = require("dep")
var parts []string
for _, s := range specifiers {
if s.alias != "" {
parts = append(parts, s.name+": "+s.alias)
} else {
parts = append(parts, s.name)
}
}
line := "const { " + strings.Join(parts, ", ") + " } = require(\"" + resolve(dep) + "\");"
requireLines = append(requireLines, line)
return ""
})
// Namespace imports: import * as X from "dep"
cleanContent = reNamespaceImport.ReplaceAllStringFunc(cleanContent, func(match string) string {
m := reNamespaceImport.FindStringSubmatch(match)
if len(m) < 3 {
return match
}
name := m[1]
dep := m[2]
addDep(dep)
line := "const " + name + " = require(\"" + dep + "\");"
requireLines = append(requireLines, line)
return ""
})
// Default imports: import X from "dep"
cleanContent = reDefaultImport.ReplaceAllStringFunc(cleanContent, func(match string) string {
m := reDefaultImport.FindStringSubmatch(match)
if len(m) < 3 {
return match
}
name := m[1]
dep := m[2]
addDep(dep)
// Default import uses Symbol.for("default")
line := "const " + name + " = require(\"" + dep + "\")[Symbol.for(\"default\")];"
requireLines = append(requireLines, line)
return ""
})
// Side-effect imports: import "dep"
cleanContent = reSideEffectImport.ReplaceAllStringFunc(cleanContent, func(match string) string {
m := reSideEffectImport.FindStringSubmatch(match)
if len(m) < 2 {
return match
}
dep := m[1]
addDep(dep)
line := "require(\"" + dep + "\");"
requireLines = append(requireLines, line)
return ""
})
// export { X, Y } from "dep" — named re-export: import dep + export names
cleanContent = reExportNamedFrom.ReplaceAllStringFunc(cleanContent, func(match string) string {
m := reExportNamedFrom.FindStringSubmatch(match)
if len(m) >= 3 {
names := m[1]
dep := m[2]
addDep(dep)
// Named re-export: export { X } from "dep"
// Import the dep (using a temp var to avoid redeclaration with existing imports)
// then assign to __exports
specifiers := parseExportSpecifiers(names)
var parts []string
tmpVar := fmt.Sprintf("_reexport_%d", len(deps))
parts = append(parts, fmt.Sprintf("var %s = require(\"%s\");", tmpVar, dep))
for _, s := range specifiers {
exported := s.name
if s.alias != "" {
exported = s.alias
}
parts = append(parts, fmt.Sprintf("__exports.%s = %s.%s;", exported, tmpVar, s.name))
}
return strings.Join(parts, " ")
}
return match
})
// export * from "dep" — treat as import dependency
cleanContent = reExportStar.ReplaceAllStringFunc(cleanContent, func(match string) string {
m := reExportStar.FindStringSubmatch(match)
if len(m) >= 2 {
addDep(m[1])
}
return match // keep the export * line — transformExports will handle it
})
deps = depOrder
return
}
// importSpecifier holds a single import specifier, e.g. "X" or "X as Y".
type importSpecifier struct {
name string // exported name
alias string // local alias (empty if same as name)
}
// parseImportSpecifiers parses the inside of { ... } in an import statement.
// E.g. "X, Y as Z, W" -> [{X, ""}, {Y, "Z"}, {W, ""}]
func parseImportSpecifiers(raw string) []importSpecifier {
var result []importSpecifier
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
fields := strings.Fields(part)
switch len(fields) {
case 1:
result = append(result, importSpecifier{name: fields[0]})
case 3:
// "X as Y"
if fields[1] == "as" {
result = append(result, importSpecifier{name: fields[0], alias: fields[2]})
}
}
}
return result
}
// transformExports converts export statements to __exports assignments.
// Mirrors: odoo/tools/js_transpiler.py (various export transformers)
func transformExports(content string) string {
// export class Foo { ... } -> const Foo = __exports.Foo = class Foo { ... }
content = reExportClass.ReplaceAllStringFunc(content, func(match string) string {
m := reExportClass.FindStringSubmatch(match)
if len(m) < 2 {
return match
}
name := m[1]
return "const " + name + " = __exports." + name + " = class " + name
})
// export [async] function foo(...) { ... } -> __exports.foo = [async] function foo(...) { ... }
content = reExportFunction.ReplaceAllStringFunc(content, func(match string) string {
m := reExportFunction.FindStringSubmatch(match)
if len(m) < 3 {
return match
}
async := m[1] // "async " or ""
name := m[2]
// Use "var name = __exports.name = function name" so the name is available
// as a local variable (needed when code references it after declaration,
// e.g., uniqueId.nextId = 0)
return "var " + name + " = __exports." + name + " = " + async + "function " + name
})
// export const foo = ... -> const foo = __exports.foo = ...
// (replaces just the "export const foo" part, the rest of the line stays)
content = reExportConst.ReplaceAllStringFunc(content, func(match string) string {
m := reExportConst.FindStringSubmatch(match)
if len(m) < 2 {
return match
}
name := m[1]
return "const " + name + " = __exports." + name
})
// export let foo = ... -> let foo = __exports.foo = ...
content = reExportLet.ReplaceAllStringFunc(content, func(match string) string {
m := reExportLet.FindStringSubmatch(match)
if len(m) < 2 {
return match
}
name := m[1]
return "let " + name + " = __exports." + name
})
// export { X, Y, Z } -> Object.assign(__exports, { X, Y, Z });
content = reExportNamed.ReplaceAllStringFunc(content, func(match string) string {
m := reExportNamed.FindStringSubmatch(match)
if len(m) < 2 {
return match
}
names := m[1]
// Parse individual names, handle "X as Y" aliases
specifiers := parseExportSpecifiers(names)
if len(specifiers) == 0 {
return ""
}
var assignments []string
for _, s := range specifiers {
exportedName := s.name
if s.alias != "" {
exportedName = s.alias
}
assignments = append(assignments, "__exports."+exportedName+" = "+s.name+";")
}
return strings.Join(assignments, " ")
})
// export * from "dep" -> Object.assign(__exports, require("dep"))
// Also add the dep to the dependency list (handled in extractImports)
content = reExportStar.ReplaceAllStringFunc(content, func(match string) string {
m := reExportStar.FindStringSubmatch(match)
if len(m) < 2 {
return match
}
dep := m[1]
return fmt.Sprintf(`Object.assign(__exports, require("%s"))`, dep)
})
// export default X -> __exports[Symbol.for("default")] = X
// Must come after other export patterns to avoid double-matching
content = reExportDefault.ReplaceAllString(content, `__exports[Symbol.for("default")] = `)
return content
}
// exportSpecifier holds a single export specifier from "export { X, Y as Z }".
type exportSpecifier struct {
name string // local name
alias string // exported name (empty if same as name)
}
// parseExportSpecifiers parses the inside of { ... } in an export statement.
// E.g. "X, Y as Z" -> [{X, ""}, {Y, "Z"}]
func parseExportSpecifiers(raw string) []exportSpecifier {
var result []exportSpecifier
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
fields := strings.Fields(part)
switch len(fields) {
case 1:
result = append(result, exportSpecifier{name: fields[0]})
case 3:
// "X as Y"
if fields[1] == "as" {
result = append(result, exportSpecifier{name: fields[0], alias: fields[2]})
}
}
}
return result
}
// wrapWithOdooDefine wraps the transpiled content in an odoo.define() call.
// Mirrors: odoo/tools/js_transpiler.py wrap_with_odoo_define()
func wrapWithOdooDefine(moduleName string, deps []string, requireLines []string, content string) string {
var b strings.Builder
// Module definition header
b.WriteString("odoo.define(\"")
b.WriteString(moduleName)
b.WriteString("\", [")
// Dependencies array
for i, dep := range deps {
if i > 0 {
b.WriteString(", ")
}
b.WriteString("\"")
b.WriteString(dep)
b.WriteString("\"")
}
b.WriteString("], function(require) {\n")
b.WriteString("\"use strict\";\n")
b.WriteString("let __exports = {};\n")
// Require statements
for _, line := range requireLines {
b.WriteString(line)
b.WriteString("\n")
}
// Original content (trimmed of leading/trailing whitespace)
trimmed := strings.TrimSpace(content)
if trimmed != "" {
b.WriteString(trimmed)
b.WriteString("\n")
}
// Return exports
b.WriteString("return __exports;\n")
b.WriteString("});\n")
return b.String()
}
// stripJSDocImports removes import/export statements that appear inside JSDoc
// block comments. Instead of stripping all /* ... */ (which breaks string literals
// containing /*), we only neutralize import/export lines that are preceded by
// a JSDoc comment start (/**) on a prior line. We detect this by checking if
// the line is inside a comment block.
func stripJSDocImports(content string) string {
lines := strings.Split(content, "\n")
inComment := false
var result []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// Track block comment state
if strings.HasPrefix(trimmed, "/*") {
inComment = true
}
if inComment {
// Neutralize import/export statements inside comments
// by replacing 'import' with '_import' and 'export' with '_export'
if strings.Contains(trimmed, "import ") || strings.Contains(trimmed, "export ") {
line = strings.Replace(line, "import ", "_import_in_comment ", 1)
line = strings.Replace(line, "export ", "_export_in_comment ", 1)
}
}
if strings.Contains(trimmed, "*/") {
inComment = false
}
result = append(result, line)
}
return strings.Join(result, "\n")
}