Eliminate Python dependency: embed frontend assets in odoo-go
- 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>
This commit is contained in:
114
pkg/server/bundle.go
Normal file
114
pkg/server/bundle.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Package server — JS bundle builder.
|
||||
//
|
||||
// Reads original Odoo JS source files from the addon directories, transpiles
|
||||
// ES modules to odoo.define() format using the Go transpiler, and
|
||||
// concatenates everything into a single JS bundle served at startup.
|
||||
//
|
||||
// This replaces the separate `go run ./cmd/transpile` build step — no
|
||||
// pre-transpiled build/js directory is needed.
|
||||
package server
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/tools"
|
||||
)
|
||||
|
||||
// buildJSBundle reads all JS files listed in assets_js.txt from the original
|
||||
// Odoo source directories, transpiles ES modules on-the-fly, and returns the
|
||||
// concatenated bundle as a single string.
|
||||
//
|
||||
// xmlTemplateBundle is the compiled XML templates JS (from compileXMLTemplates),
|
||||
// injected where xml_templates_bundle.js appears in the asset list.
|
||||
//
|
||||
// The first entry (module_loader.js) is excluded from the bundle because it
|
||||
// must load before the bundle via its own <script> tag.
|
||||
//
|
||||
// The bundle is built once at server startup and cached in memory.
|
||||
func buildJSBundle(config *tools.Config, xmlTemplateBundle string) string {
|
||||
start := time.Now()
|
||||
var buf strings.Builder
|
||||
transpiled := 0
|
||||
copied := 0
|
||||
skipped := 0
|
||||
|
||||
for _, src := range jsFiles {
|
||||
if strings.HasSuffix(src, ".scss") {
|
||||
continue
|
||||
}
|
||||
|
||||
// module_loader.js is served via its own <script> tag before the
|
||||
// bundle (it defines odoo.define/odoo.loader). Skip it here.
|
||||
if src == "/web/static/src/module_loader.js" {
|
||||
continue
|
||||
}
|
||||
|
||||
// The XML templates bundle is generated in-memory by
|
||||
// compileXMLTemplates. Inject it where the placeholder appears
|
||||
// in the asset list instead of reading a file from disk.
|
||||
if src == "/web/static/src/xml_templates_bundle.js" {
|
||||
if xmlTemplateBundle != "" {
|
||||
buf.WriteString(";\n")
|
||||
buf.WriteString(xmlTemplateBundle)
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Read source from frontend directory
|
||||
content := ""
|
||||
rel := strings.TrimPrefix(src, "/")
|
||||
if config.FrontendDir != "" {
|
||||
fullPath := filepath.Join(config.FrontendDir, rel)
|
||||
if data, err := os.ReadFile(fullPath); err == nil {
|
||||
content = string(data)
|
||||
}
|
||||
}
|
||||
|
||||
// Also try build dir as fallback (for pre-compiled assets)
|
||||
if content == "" && config.BuildDir != "" {
|
||||
buildPath := filepath.Join(config.BuildDir, rel)
|
||||
if data, err := os.ReadFile(buildPath); err == nil {
|
||||
content = string(data)
|
||||
}
|
||||
}
|
||||
|
||||
if content == "" {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Transpile ES modules to odoo.define() format
|
||||
if IsOdooModule(src, content) {
|
||||
content = TranspileJS(src, content)
|
||||
transpiled++
|
||||
} else {
|
||||
copied++
|
||||
}
|
||||
|
||||
buf.WriteString(";\n")
|
||||
buf.WriteString(content)
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
log.Printf("bundle: %d transpiled + %d copied + %d skipped in %s (%d KB)",
|
||||
transpiled, copied, skipped, time.Since(start).Round(time.Millisecond), buf.Len()/1024)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// handleJSBundle serves the concatenated JS bundle from memory.
|
||||
// The bundle is built once at startup and cached in s.jsBundle.
|
||||
func (s *Server) handleJSBundle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write([]byte(s.jsBundle))
|
||||
}
|
||||
@@ -24,6 +24,17 @@ type Server struct {
|
||||
pool *pgxpool.Pool
|
||||
mux *http.ServeMux
|
||||
sessions *SessionStore
|
||||
|
||||
// xmlTemplateBundle is the JS source for the compiled XML templates,
|
||||
// generated at startup by compileXMLTemplates(). It replaces the
|
||||
// pre-compiled build/js/web/static/src/xml_templates_bundle.js that
|
||||
// was previously produced by tools/compile_templates.py.
|
||||
xmlTemplateBundle string
|
||||
|
||||
// jsBundle is the concatenated JS bundle built at startup. It contains
|
||||
// all JS files (except module_loader.js) plus the XML template bundle,
|
||||
// served as a single file to avoid hundreds of individual HTTP requests.
|
||||
jsBundle string
|
||||
}
|
||||
|
||||
// New creates a new server instance.
|
||||
@@ -34,6 +45,16 @@ func New(cfg *tools.Config, pool *pgxpool.Pool) *Server {
|
||||
mux: http.NewServeMux(),
|
||||
sessions: NewSessionStore(24 * time.Hour),
|
||||
}
|
||||
|
||||
// Compile XML templates to JS at startup, replacing the Python build step.
|
||||
log.Println("odoo: compiling XML templates...")
|
||||
s.xmlTemplateBundle = compileXMLTemplates(cfg.FrontendDir)
|
||||
|
||||
// Concatenate all JS files into a single bundle served at /web/assets/bundle.js.
|
||||
// This reduces ~539 individual <script> requests to 1.
|
||||
log.Println("odoo: building JS bundle...")
|
||||
s.jsBundle = buildJSBundle(cfg, s.xmlTemplateBundle)
|
||||
|
||||
s.registerRoutes()
|
||||
return s
|
||||
}
|
||||
@@ -69,6 +90,7 @@ func (s *Server) registerRoutes() {
|
||||
|
||||
// Action loading
|
||||
s.mux.HandleFunc("/web/action/load", s.handleActionLoad)
|
||||
s.mux.HandleFunc("/web/action/load_breadcrumbs", s.handleLoadBreadcrumbs)
|
||||
|
||||
// Database endpoints
|
||||
s.mux.HandleFunc("/web/database/list", s.handleDBList)
|
||||
@@ -77,9 +99,19 @@ func (s *Server) registerRoutes() {
|
||||
s.mux.HandleFunc("/web/setup", s.handleSetup)
|
||||
s.mux.HandleFunc("/web/setup/install", s.handleSetupInstall)
|
||||
|
||||
// Image serving (placeholder for uploaded images)
|
||||
s.mux.HandleFunc("/web/image", s.handleImage)
|
||||
s.mux.HandleFunc("/web/image/", s.handleImage)
|
||||
|
||||
// Concatenated JS bundle (all modules in one file)
|
||||
s.mux.HandleFunc("/web/assets/bundle.js", s.handleJSBundle)
|
||||
|
||||
// PWA manifest
|
||||
s.mux.HandleFunc("/web/manifest.webmanifest", s.handleManifest)
|
||||
|
||||
// File upload
|
||||
s.mux.HandleFunc("/web/binary/upload_attachment", s.handleUpload)
|
||||
|
||||
// Health check
|
||||
s.mux.HandleFunc("/health", s.handleHealth)
|
||||
|
||||
@@ -109,7 +141,7 @@ func (s *Server) Start() error {
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: AuthMiddleware(s.sessions, s.mux),
|
||||
Handler: LoggingMiddleware(AuthMiddleware(s.sessions, s.mux)),
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
@@ -308,18 +340,140 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
case "fields_get":
|
||||
return fieldsGetForModel(params.Model), nil
|
||||
|
||||
case "web_read_group", "read_group":
|
||||
// Basic implementation: if groupby is provided, return one group with all records
|
||||
groupby := []string{}
|
||||
if gb, ok := params.KW["groupby"].([]interface{}); ok {
|
||||
for _, g := range gb {
|
||||
if s, ok := g.(string); ok {
|
||||
groupby = append(groupby, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(groupby) == 0 {
|
||||
// No groupby → return empty groups
|
||||
return map[string]interface{}{
|
||||
"groups": []interface{}{},
|
||||
"length": 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// With groupby: return all records in one "ungrouped" group
|
||||
domain := parseDomain(params.Args)
|
||||
if domain == nil {
|
||||
if domainRaw, ok := params.KW["domain"].([]interface{}); ok && len(domainRaw) > 0 {
|
||||
domain = parseDomain([]interface{}{domainRaw})
|
||||
}
|
||||
}
|
||||
count, _ := rs.SearchCount(domain)
|
||||
|
||||
return map[string]interface{}{
|
||||
"groups": []interface{}{
|
||||
map[string]interface{}{
|
||||
"__domain": []interface{}{},
|
||||
"__count": count,
|
||||
groupby[0]: false,
|
||||
"__records": []interface{}{},
|
||||
},
|
||||
},
|
||||
"length": 1,
|
||||
}, nil
|
||||
|
||||
case "web_search_read":
|
||||
return handleWebSearchRead(env, params.Model, params)
|
||||
|
||||
case "web_read":
|
||||
return handleWebRead(env, params.Model, params)
|
||||
|
||||
case "web_save":
|
||||
// Combined create-or-update used by the Odoo web client.
|
||||
// Mirrors: odoo/addons/web/models/models.py web_save()
|
||||
ids := parseIDs(params.Args)
|
||||
vals := parseValuesAt(params.Args, 1)
|
||||
spec, _ := params.KW["specification"].(map[string]interface{})
|
||||
|
||||
if len(ids) > 0 && ids[0] > 0 {
|
||||
// Update existing record(s)
|
||||
err := rs.Browse(ids...).Write(vals)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
} else {
|
||||
// Create new record
|
||||
created, err := rs.Create(vals)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
ids = created.IDs()
|
||||
}
|
||||
|
||||
// Return the saved record via web_read format
|
||||
readParams := CallKWParams{
|
||||
Model: params.Model,
|
||||
Method: "web_read",
|
||||
Args: []interface{}{ids},
|
||||
KW: map[string]interface{}{"specification": spec},
|
||||
}
|
||||
return handleWebRead(env, params.Model, readParams)
|
||||
|
||||
case "get_views":
|
||||
return handleGetViews(env, params.Model, params)
|
||||
|
||||
case "onchange":
|
||||
// Basic onchange: return empty value dict
|
||||
return map[string]interface{}{"value": map[string]interface{}{}}, nil
|
||||
// Return default values and run onchange handlers for changed fields.
|
||||
// Mirrors: odoo/orm/models.py BaseModel.onchange()
|
||||
model := orm.Registry.Get(params.Model)
|
||||
defaults := make(orm.Values)
|
||||
if model != nil {
|
||||
orm.ApplyDefaults(model, defaults)
|
||||
// Call model-specific DefaultGet for dynamic defaults (DB lookups etc.)
|
||||
if model.DefaultGet != nil {
|
||||
for k, v := range model.DefaultGet(env, nil) {
|
||||
if _, exists := defaults[k]; !exists {
|
||||
defaults[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
// Run onchange handlers for changed fields
|
||||
// args[1] = current record values, args[2] = list of changed field names
|
||||
if len(params.Args) >= 3 {
|
||||
if vals, ok := params.Args[1].(map[string]interface{}); ok {
|
||||
if fieldNames, ok := params.Args[2].([]interface{}); ok {
|
||||
for _, fn := range fieldNames {
|
||||
fname, _ := fn.(string)
|
||||
if handler, exists := model.OnchangeHandlers[fname]; exists {
|
||||
updates := handler(env, vals)
|
||||
for k, v := range updates {
|
||||
defaults[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"value": defaults,
|
||||
}, nil
|
||||
|
||||
case "default_get":
|
||||
// Return default values for the requested fields.
|
||||
// Mirrors: odoo/orm/models.py BaseModel.default_get()
|
||||
model := orm.Registry.Get(params.Model)
|
||||
defaults := make(orm.Values)
|
||||
if model != nil {
|
||||
orm.ApplyDefaults(model, defaults)
|
||||
// Call model-specific DefaultGet for dynamic defaults (DB lookups etc.)
|
||||
if model.DefaultGet != nil {
|
||||
for k, v := range model.DefaultGet(env, nil) {
|
||||
if _, exists := defaults[k]; !exists {
|
||||
defaults[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaults, nil
|
||||
|
||||
case "search_read":
|
||||
domain := parseDomain(params.Args)
|
||||
@@ -364,6 +518,24 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
}
|
||||
return true, nil
|
||||
|
||||
case "search":
|
||||
domain := parseDomain(params.Args)
|
||||
opts := orm.SearchOpts{}
|
||||
if v, ok := params.KW["limit"].(float64); ok {
|
||||
opts.Limit = int(v)
|
||||
}
|
||||
if v, ok := params.KW["offset"].(float64); ok {
|
||||
opts.Offset = int(v)
|
||||
}
|
||||
if v, ok := params.KW["order"].(string); ok {
|
||||
opts.Order = v
|
||||
}
|
||||
found, err := rs.Search(domain, opts)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
return found.IDs(), nil
|
||||
|
||||
case "search_count":
|
||||
domain := parseDomain(params.Args)
|
||||
count, err := rs.SearchCount(domain)
|
||||
@@ -386,16 +558,43 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
return result, nil
|
||||
|
||||
case "name_search":
|
||||
// Basic name_search: search by name, return [[id, "name"], ...]
|
||||
// name_search: search by name, return [[id, "name"], ...]
|
||||
// Mirrors: odoo/orm/models.py BaseModel._name_search()
|
||||
nameStr := ""
|
||||
if len(params.Args) > 0 {
|
||||
nameStr, _ = params.Args[0].(string)
|
||||
}
|
||||
limit := 8
|
||||
domain := orm.Domain{}
|
||||
if nameStr != "" {
|
||||
domain = orm.And(orm.Leaf("name", "ilike", nameStr))
|
||||
// Also accept name from kwargs
|
||||
if nameStr == "" {
|
||||
if v, ok := params.KW["name"].(string); ok {
|
||||
nameStr = v
|
||||
}
|
||||
}
|
||||
|
||||
limit := 8
|
||||
if v, ok := params.KW["limit"].(float64); ok {
|
||||
limit = int(v)
|
||||
}
|
||||
|
||||
operator := "ilike"
|
||||
if v, ok := params.KW["operator"].(string); ok {
|
||||
operator = v
|
||||
}
|
||||
|
||||
// Build domain: name condition + additional args domain
|
||||
var nodes []orm.DomainNode
|
||||
if nameStr != "" {
|
||||
nodes = append(nodes, orm.Leaf("name", operator, nameStr))
|
||||
}
|
||||
// Parse extra domain from kwargs "args"
|
||||
if extraDomainRaw, ok := params.KW["args"].([]interface{}); ok && len(extraDomainRaw) > 0 {
|
||||
extraDomain := parseDomain([]interface{}{extraDomainRaw})
|
||||
for _, n := range extraDomain {
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
}
|
||||
|
||||
domain := orm.And(nodes...)
|
||||
found, err := rs.Search(domain, orm.SearchOpts{Limit: limit})
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
@@ -408,8 +607,52 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
for id, name := range names {
|
||||
nameResult = append(nameResult, []interface{}{id, name})
|
||||
}
|
||||
if nameResult == nil {
|
||||
nameResult = [][]interface{}{}
|
||||
}
|
||||
return nameResult, nil
|
||||
|
||||
case "action_archive":
|
||||
ids := parseIDs(params.Args)
|
||||
if len(ids) > 0 {
|
||||
rs.Browse(ids...).Write(orm.Values{"active": false})
|
||||
}
|
||||
return true, nil
|
||||
|
||||
case "action_unarchive":
|
||||
ids := parseIDs(params.Args)
|
||||
if len(ids) > 0 {
|
||||
rs.Browse(ids...).Write(orm.Values{"active": true})
|
||||
}
|
||||
return true, nil
|
||||
|
||||
case "copy":
|
||||
ids := parseIDs(params.Args)
|
||||
if len(ids) == 0 {
|
||||
return nil, &RPCError{Code: -32000, Message: "No record to copy"}
|
||||
}
|
||||
// Read the original record
|
||||
records, err := rs.Browse(ids[0]).Read(nil)
|
||||
if err != nil || len(records) == 0 {
|
||||
return nil, &RPCError{Code: -32000, Message: "Record not found"}
|
||||
}
|
||||
// Remove id and unique fields, create copy
|
||||
vals := records[0]
|
||||
delete(vals, "id")
|
||||
delete(vals, "create_uid")
|
||||
delete(vals, "write_uid")
|
||||
delete(vals, "create_date")
|
||||
delete(vals, "write_date")
|
||||
// Append "(copy)" to name if it exists
|
||||
if name, ok := vals["name"].(string); ok {
|
||||
vals["name"] = name + " (copy)"
|
||||
}
|
||||
created, err := rs.Create(vals)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
return created.ID(), nil
|
||||
|
||||
default:
|
||||
// Try registered business methods on the model
|
||||
model := orm.Registry.Get(params.Model)
|
||||
@@ -546,6 +789,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
// --- Helpers ---
|
||||
|
||||
func (s *Server) writeJSONRPC(w http.ResponseWriter, id interface{}, result interface{}, rpcErr *RPCError) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
resp := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
@@ -614,11 +858,17 @@ func parseIDs(args []interface{}) []int64 {
|
||||
ids[i] = int64(n)
|
||||
case int64:
|
||||
ids[i] = n
|
||||
case int32:
|
||||
ids[i] = int64(n)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
case []int64:
|
||||
return v
|
||||
case float64:
|
||||
return []int64{int64(v)}
|
||||
case int64:
|
||||
return []int64{v}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// handleStatic serves static files from Odoo addon directories.
|
||||
@@ -16,6 +17,16 @@ func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the compiled XML templates bundle from memory (generated at
|
||||
// startup by compileXMLTemplates) instead of reading the pre-compiled
|
||||
// file from the build directory. This replaces the Python build step.
|
||||
if r.URL.Path == "/web/static/src/xml_templates_bundle.js" {
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write([]byte(s.xmlTemplateBundle))
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
parts := strings.SplitN(path, "/", 3)
|
||||
if len(parts) < 3 || parts[1] != "static" {
|
||||
@@ -32,8 +43,8 @@ func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// For JS/CSS files: check build dir first (transpiled/compiled files)
|
||||
if s.config.BuildDir != "" && (strings.HasSuffix(filePath, ".js") || strings.HasSuffix(filePath, ".css")) {
|
||||
// For CSS files: check build dir first (compiled SCSS -> CSS)
|
||||
if s.config.BuildDir != "" && strings.HasSuffix(filePath, ".css") {
|
||||
buildPath := filepath.Join(s.config.BuildDir, addonName, "static", filePath)
|
||||
if _, err := os.Stat(buildPath); err == nil {
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
@@ -42,9 +53,9 @@ func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Search through addon paths (original files)
|
||||
for _, addonsDir := range s.config.OdooAddonsPath {
|
||||
fullPath := filepath.Join(addonsDir, addonName, "static", filePath)
|
||||
// Search in frontend directory
|
||||
if s.config.FrontendDir != "" {
|
||||
fullPath := filepath.Join(s.config.FrontendDir, addonName, "static", filePath)
|
||||
if _, err := os.Stat(fullPath); err == nil {
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
|
||||
@@ -56,6 +67,36 @@ func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Transpile ES module JS files on-the-fly when served
|
||||
// individually (e.g. debug mode). The main bundle already
|
||||
// contains transpiled versions, but individual file
|
||||
// requests still need transpilation.
|
||||
if strings.HasSuffix(fullPath, ".js") {
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
content := string(data)
|
||||
urlPath := "/" + addonName + "/static/" + filePath
|
||||
if IsOdooModule(urlPath, content) {
|
||||
content = TranspileJS(urlPath, content)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
http.ServeContent(w, r, filePath, time.Time{}, strings.NewReader(content))
|
||||
return
|
||||
}
|
||||
|
||||
http.ServeFile(w, r, fullPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: build dir for pre-compiled vendor assets
|
||||
if s.config.BuildDir != "" {
|
||||
fullPath := filepath.Join(s.config.BuildDir, addonName, "static", filePath)
|
||||
if _, err := os.Stat(fullPath); err == nil {
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
http.ServeFile(w, r, fullPath)
|
||||
return
|
||||
}
|
||||
|
||||
397
pkg/server/templates.go
Normal file
397
pkg/server/templates.go
Normal file
@@ -0,0 +1,397 @@
|
||||
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, ", ") + "]"
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/tools"
|
||||
)
|
||||
@@ -32,15 +33,16 @@ func init() {
|
||||
xmlFiles = loadAssetList("assets_xml.txt", assetsXMLFile)
|
||||
}
|
||||
|
||||
// loadXMLTemplate reads an XML template file from the Odoo addons paths.
|
||||
// loadXMLTemplate reads an XML template file from the frontend directory.
|
||||
func loadXMLTemplate(cfg *tools.Config, urlPath string) string {
|
||||
if cfg.FrontendDir == "" {
|
||||
return ""
|
||||
}
|
||||
rel := strings.TrimPrefix(urlPath, "/")
|
||||
for _, addonsDir := range cfg.OdooAddonsPath {
|
||||
fullPath := filepath.Join(addonsDir, rel)
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err == nil {
|
||||
return string(data)
|
||||
}
|
||||
fullPath := filepath.Join(cfg.FrontendDir, rel)
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err == nil {
|
||||
return string(data)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -92,15 +94,25 @@ func (s *Server) handleWebClient(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
// Build script tags for all JS files (with cache buster)
|
||||
// Build script tags: module_loader must load first (defines odoo.loader),
|
||||
// then the concatenated bundle serves everything else in one request.
|
||||
// We suppress transient missing-dependency errors during loading by
|
||||
// temporarily replacing reportErrors with a no-op, then restore it
|
||||
// after the bundle has loaded.
|
||||
var scriptTags strings.Builder
|
||||
cacheBuster := "?v=odoo-go-1"
|
||||
for _, src := range jsFiles {
|
||||
if strings.HasSuffix(src, ".scss") {
|
||||
continue
|
||||
}
|
||||
scriptTags.WriteString(fmt.Sprintf(" <script type=\"text/javascript\" src=\"%s%s\"></script>\n", src, cacheBuster))
|
||||
}
|
||||
cacheBuster := fmt.Sprintf("?v=%d", time.Now().Unix())
|
||||
|
||||
// 1) module_loader.js — must run first to define odoo.define/odoo.loader
|
||||
scriptTags.WriteString(fmt.Sprintf(" <script type=\"text/javascript\" src=\"/web/static/src/module_loader.js%s\"></script>\n", cacheBuster))
|
||||
|
||||
// 2) Suppress transient reportErrors while the bundle loads
|
||||
scriptTags.WriteString(" <script>if (odoo.loader) { odoo.loader.__origReportErrors = odoo.loader.reportErrors.bind(odoo.loader); odoo.loader.reportErrors = function() {}; }</script>\n")
|
||||
|
||||
// 3) The concatenated JS bundle (all other modules + XML templates)
|
||||
scriptTags.WriteString(fmt.Sprintf(" <script type=\"text/javascript\" src=\"/web/assets/bundle.js%s\"></script>\n", cacheBuster))
|
||||
|
||||
// 4) Restore reportErrors and run a final check for genuine errors
|
||||
scriptTags.WriteString(" <script>if (odoo.loader && odoo.loader.__origReportErrors) { odoo.loader.reportErrors = odoo.loader.__origReportErrors; odoo.loader.reportErrors(odoo.loader.findErrors()); }</script>\n")
|
||||
|
||||
// Build link tags for CSS: compiled SCSS bundle + individual CSS files
|
||||
var linkTags strings.Builder
|
||||
@@ -140,20 +152,23 @@ func (s *Server) handleWebClient(w http.ResponseWriter, r *http.Request) {
|
||||
};
|
||||
odoo.loadMenusPromise = odoo.reloadMenus();
|
||||
|
||||
// Catch unhandled errors and log them
|
||||
window.addEventListener('unhandledrejection', function(e) {
|
||||
console.error('[odoo-go] Unhandled rejection:', e.reason);
|
||||
});
|
||||
|
||||
// Patch OWL to prevent infinite error-dialog recursion.
|
||||
// When ErrorDialog itself fails to render, stop retrying.
|
||||
window.__errorDialogCount = 0;
|
||||
var _origHandleError = null;
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (typeof owl !== 'undefined' && owl.App) {
|
||||
_origHandleError = owl.App.prototype.handleError;
|
||||
var _orig = owl.App.prototype.handleError;
|
||||
owl.App.prototype.handleError = function() {
|
||||
window.__errorDialogCount++;
|
||||
if (window.__errorDialogCount > 3) {
|
||||
console.error('[odoo-go] Error dialog recursion stopped. Check earlier errors for root cause.');
|
||||
console.error('[odoo-go] Error dialog recursion stopped.');
|
||||
return;
|
||||
}
|
||||
return _origHandleError.apply(this, arguments);
|
||||
return _orig.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -180,7 +195,7 @@ func (s *Server) buildSessionInfo(sess *Session) map[string]interface{} {
|
||||
"allowed_company_ids": []int64{sess.CompanyID},
|
||||
},
|
||||
"db": s.config.DBName,
|
||||
"registry_hash": "odoo-go-static",
|
||||
"registry_hash": fmt.Sprintf("odoo-go-%d", time.Now().Unix()),
|
||||
"server_version": "19.0-go",
|
||||
"server_version_info": []interface{}{19, 0, 0, "final", 0, "g"},
|
||||
"name": sess.Login,
|
||||
@@ -192,7 +207,8 @@ func (s *Server) buildSessionInfo(sess *Session) map[string]interface{} {
|
||||
"web.base.url": fmt.Sprintf("http://localhost:%d", s.config.HTTPPort),
|
||||
"active_ids_limit": 20000,
|
||||
"max_file_upload_size": 134217728,
|
||||
"home_action_id": false,
|
||||
"home_action_id": 1,
|
||||
"current_menu": 1,
|
||||
"support_url": "",
|
||||
"test_mode": false,
|
||||
"show_effect": true,
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds the server configuration.
|
||||
@@ -27,10 +26,10 @@ type Config struct {
|
||||
DataDir string
|
||||
|
||||
// Modules
|
||||
AddonsPath []string
|
||||
OdooAddonsPath []string // Paths to Odoo source addon directories (for static files)
|
||||
BuildDir string // Directory for compiled assets (SCSS→CSS)
|
||||
WithoutDemo bool
|
||||
AddonsPath []string
|
||||
FrontendDir string // Directory containing embedded frontend assets (JS/CSS/XML/fonts)
|
||||
BuildDir string // Directory for compiled assets (SCSS→CSS)
|
||||
WithoutDemo bool
|
||||
|
||||
// Logging
|
||||
LogLevel string
|
||||
@@ -99,8 +98,8 @@ func (c *Config) LoadFromEnv() {
|
||||
if v := os.Getenv("ODOO_LOG_LEVEL"); v != "" {
|
||||
c.LogLevel = v
|
||||
}
|
||||
if v := os.Getenv("ODOO_ADDONS_PATH"); v != "" {
|
||||
c.OdooAddonsPath = strings.Split(v, ",")
|
||||
if v := os.Getenv("ODOO_FRONTEND_DIR"); v != "" {
|
||||
c.FrontendDir = v
|
||||
}
|
||||
if v := os.Getenv("ODOO_BUILD_DIR"); v != "" {
|
||||
c.BuildDir = v
|
||||
|
||||
Reference in New Issue
Block a user