- 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>
554 lines
17 KiB
Go
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")
|
|
}
|