package server import ( "bufio" "embed" "encoding/json" "fmt" "net/http" "os" "path/filepath" "strings" "time" "odoo-go/pkg/tools" ) //go:embed assets_js.txt var assetsJSFile embed.FS //go:embed assets_css.txt var assetsCSSFile embed.FS //go:embed assets_xml.txt var assetsXMLFile embed.FS var jsFiles []string var cssFiles []string var xmlFiles []string func init() { jsFiles = loadAssetList("assets_js.txt", assetsJSFile) cssFiles = loadAssetList("assets_css.txt", assetsCSSFile) xmlFiles = loadAssetList("assets_xml.txt", assetsXMLFile) } // 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, "/") fullPath := filepath.Join(cfg.FrontendDir, rel) data, err := os.ReadFile(fullPath) if err == nil { return string(data) } return "" } func loadAssetList(name string, fs embed.FS) []string { data, err := fs.ReadFile(name) if err != nil { return nil } var files []string scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line != "" && !strings.HasPrefix(line, "#") { files = append(files, line) } } return files } // handleWebClient serves the Odoo webclient HTML shell. // Mirrors: odoo/addons/web/controllers/home.py Home.web_client() func (s *Server) handleWebClient(w http.ResponseWriter, r *http.Request) { // Check if database needs initialization // Mirrors: odoo/addons/web/controllers/home.py ensure_db() if s.isSetupNeeded() { http.Redirect(w, r, "/web/database/manager", http.StatusFound) return } // Check authentication sess := GetSession(r) if sess == nil { // Try cookie directly cookie, err := r.Cookie("session_id") if err != nil || cookie.Value == "" { http.Redirect(w, r, "/web/login", http.StatusFound) return } sess = s.sessions.Get(cookie.Value) if sess == nil { http.Redirect(w, r, "/web/login", http.StatusFound) return } } sessionInfo := s.buildSessionInfo(sess) sessionInfoJSON, _ := json.Marshal(sessionInfo) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") // 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 := fmt.Sprintf("?v=%d", time.Now().Unix()) // 1) module_loader.js — must run first to define odoo.define/odoo.loader scriptTags.WriteString(fmt.Sprintf(" \n", cacheBuster)) // 2) Suppress transient reportErrors while the bundle loads scriptTags.WriteString(" \n") // 3) The concatenated JS bundle (all other modules + XML templates) scriptTags.WriteString(fmt.Sprintf(" \n", cacheBuster)) // 4) Restore reportErrors and run a final check for genuine errors scriptTags.WriteString(" \n") // Build link tags for CSS: compiled SCSS bundle + individual CSS files var linkTags strings.Builder // Main compiled SCSS bundle (Bootstrap + Odoo core styles) linkTags.WriteString(fmt.Sprintf(" \n", cacheBuster)) // Additional plain CSS files for _, src := range cssFiles { if strings.HasSuffix(src, ".css") { linkTags.WriteString(fmt.Sprintf(" \n", src, cacheBuster)) } } // XML templates are compiled to JS (registerTemplate calls) and included // in the JS bundle as xml_templates_bundle.js — no inline XML needed. fmt.Fprintf(w, ` Odoo %s %s `, linkTags.String(), sessionInfoJSON, scriptTags.String()) } // buildSessionInfo constructs the session_info JSON object expected by the webclient. // Mirrors: odoo/addons/web/models/ir_http.py session_info() func (s *Server) buildSessionInfo(sess *Session) map[string]interface{} { return map[string]interface{}{ "session_id": sess.ID, "uid": sess.UID, "is_system": sess.UID == 1, "is_admin": sess.UID == 1, "is_public": false, "is_internal_user": true, "user_context": map[string]interface{}{ "lang": "en_US", "tz": "UTC", "allowed_company_ids": []int64{sess.CompanyID}, }, "db": s.config.DBName, "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, "username": sess.Login, "partner_id": sess.UID + 1, // Simplified mapping "partner_display_name": sess.Login, "partner_write_date": "2026-01-01 00:00:00", "quick_login": true, "web.base.url": fmt.Sprintf("http://localhost:%d", s.config.HTTPPort), "active_ids_limit": 20000, "max_file_upload_size": 134217728, "home_action_id": 1, "current_menu": 1, "support_url": "https://www.odoo.com/help", "notification_type": "email", "display_switch_company_menu": false, "test_mode": false, "show_effect": true, "currencies": map[string]interface{}{ "1": map[string]interface{}{ "id": 1, "name": "EUR", "symbol": "€", "position": "after", "digits": []int{69, 2}, }, }, "bundle_params": map[string]interface{}{ "lang": "en_US", "debug": "assets", }, "user_companies": map[string]interface{}{ "current_company": sess.CompanyID, "allowed_companies": map[string]interface{}{ fmt.Sprintf("%d", sess.CompanyID): map[string]interface{}{ "id": sess.CompanyID, "name": "My Company", "sequence": 10, "child_ids": []int64{}, "parent_id": false, "currency_id": 1, }, }, "disallowed_ancestor_companies": map[string]interface{}{}, }, "user_settings": map[string]interface{}{ "id": 1, "user_id": map[string]interface{}{"id": sess.UID, "display_name": sess.Login}, }, "view_info": map[string]interface{}{ "list": map[string]interface{}{"display_name": "List", "icon": "oi oi-view-list", "multi_record": true}, "form": map[string]interface{}{"display_name": "Form", "icon": "fa fa-address-card", "multi_record": false}, "kanban": map[string]interface{}{"display_name": "Kanban", "icon": "oi oi-view-kanban", "multi_record": true}, "graph": map[string]interface{}{"display_name": "Graph", "icon": "fa fa-area-chart", "multi_record": true}, "pivot": map[string]interface{}{"display_name": "Pivot", "icon": "oi oi-view-pivot", "multi_record": true}, "calendar": map[string]interface{}{"display_name": "Calendar", "icon": "fa fa-calendar", "multi_record": true}, "search": map[string]interface{}{"display_name": "Search", "icon": "oi oi-search", "multi_record": true}, }, "groups": map[string]interface{}{ "base.group_allow_export": true, "base.group_user": true, "base.group_system": true, }, } } // handleTranslations returns translations for the requested language. // Mirrors: odoo/addons/web/controllers/webclient.py translations() // // The web client calls this with params like {mods: ["web","base",...], lang: "de_DE"}. // We load translations from ir_translation table and return them in Odoo's format. func (s *Server) handleTranslations(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=3600") // Determine requested language from POST body or session context lang := "en_US" if r.Method == http.MethodPost { var req JSONRPCRequest if err := json.NewDecoder(r.Body).Decode(&req); err == nil { var params struct { Mods []string `json:"mods"` Lang string `json:"lang"` } if err := json.Unmarshal(req.Params, ¶ms); err == nil && params.Lang != "" { lang = params.Lang } } } if q := r.URL.Query().Get("lang"); q != "" { lang = q } // Default lang parameters (English) langParams := map[string]interface{}{ "direction": "ltr", "date_format": "%%m/%%d/%%Y", "time_format": "%%H:%%M:%%S", "grouping": "[3,0]", "decimal_point": ".", "thousands_sep": ",", "week_start": 1, } // Try to load language parameters from res_lang var dateFormat, timeFormat, decimalPoint, thousandsSep, direction string err := s.pool.QueryRow(r.Context(), `SELECT date_format, time_format, decimal_point, thousands_sep, direction FROM res_lang WHERE code = $1 AND active = true`, lang, ).Scan(&dateFormat, &timeFormat, &decimalPoint, &thousandsSep, &direction) if err == nil { // Convert Go-style format markers to Python-style (double-%) for the web client langParams["date_format"] = dateFormat langParams["time_format"] = timeFormat langParams["decimal_point"] = decimalPoint langParams["thousands_sep"] = thousandsSep langParams["direction"] = direction } // Load translations from ir_translation modules := make(map[string]interface{}) multiLang := false // Check if translations exist for this language rows, err := s.pool.Query(r.Context(), `SELECT COALESCE(module, ''), src, value FROM ir_translation WHERE lang = $1 AND value != '' AND value IS NOT NULL ORDER BY module, name`, lang) if err == nil { defer rows.Close() // Group translations by module modMessages := make(map[string][]map[string]string) for rows.Next() { var module, src, value string if err := rows.Scan(&module, &src, &value); err != nil { continue } if module == "" { module = "web" } modMessages[module] = append(modMessages[module], map[string]string{ "id": src, "string": value, }) } for mod, msgs := range modMessages { modules[mod] = map[string]interface{}{ "messages": msgs, } multiLang = true } } // Check if more than one active language exists var langCount int if err := s.pool.QueryRow(r.Context(), `SELECT COUNT(*) FROM res_lang WHERE active = true`).Scan(&langCount); err == nil { if langCount > 1 { multiLang = true } } json.NewEncoder(w).Encode(map[string]interface{}{ "lang": lang, "hash": fmt.Sprintf("odoo-go-%s", lang), "lang_parameters": langParams, "modules": modules, "multi_lang": multiLang, }) }