package server import ( "context" "encoding/json" "fmt" "log" "net/http" "strconv" "strings" ) // menuRow holds a single row from the ir_ui_menu table joined with ir_model_data. type menuRow struct { ID int Name string ParentID *int // NULL for top-level menus Sequence int Action *string // e.g. "ir.actions.act_window,1" WebIcon *string XMLID string // e.g. "contacts.menu_contacts" from ir_model_data } // handleLoadMenus returns the menu tree for the webclient. // Mirrors: odoo/addons/web/controllers/home.py Home.web_load_menus() // // Loads menus from the ir_ui_menu table, builds the parent/child tree, // and returns the JSON map keyed by string IDs that the webclient expects. func (s *Server) handleLoadMenus(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=3600") ctx := r.Context() menus, err := s.loadWebMenus(ctx) if err != nil { log.Printf("menus: error loading menus: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } json.NewEncoder(w).Encode(menus) } // loadWebMenus queries ir_ui_menu and builds the JSON structure the webclient expects. // Mirrors: odoo/addons/base/models/ir_ui_menu.py IrUiMenu.load_web_menus() func (s *Server) loadWebMenus(ctx context.Context) (map[string]interface{}, error) { // Query all active menus, joined with ir_model_data for the xmlid. rows, err := s.pool.Query(ctx, ` SELECT m.id, m.name, m.parent_id, m.sequence, m.action, m.web_icon, COALESCE(d.module || '.' || d.name, '') AS xmlid FROM ir_ui_menu m LEFT JOIN ir_model_data d ON d.model = 'ir.ui.menu' AND d.res_id = m.id WHERE m.active = true ORDER BY m.sequence, m.id`) if err != nil { return nil, fmt.Errorf("query ir_ui_menu: %w", err) } defer rows.Close() var allMenus []menuRow for rows.Next() { var mr menuRow if err := rows.Scan(&mr.ID, &mr.Name, &mr.ParentID, &mr.Sequence, &mr.Action, &mr.WebIcon, &mr.XMLID); err != nil { return nil, fmt.Errorf("scan menu row: %w", err) } allMenus = append(allMenus, mr) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate menu rows: %w", err) } // Build children map: parent_id → list of child IDs childrenOf := make(map[int][]int) for _, m := range allMenus { if m.ParentID != nil { childrenOf[*m.ParentID] = append(childrenOf[*m.ParentID], m.ID) } } // Find the root app ID for each menu by walking up to the top-level ancestor. // Top-level menus (parent_id IS NULL) are their own appID. menuByID := make(map[int]*menuRow, len(allMenus)) for i := range allMenus { menuByID[allMenus[i].ID] = &allMenus[i] } appIDOf := func(id int) int { cur := id for { m, ok := menuByID[cur] if !ok || m.ParentID == nil { return cur } cur = *m.ParentID } } // Build the result map result := make(map[string]interface{}, len(allMenus)+1) // Collect top-level menu IDs (parent_id IS NULL) for the root entry var rootChildren []int for _, m := range allMenus { if m.ParentID == nil { rootChildren = append(rootChildren, m.ID) } } // "root" pseudo-entry result["root"] = map[string]interface{}{ "id": "root", "name": "root", "children": rootChildren, "appID": false, "xmlid": "", "actionID": false, "actionModel": false, "actionPath": false, "webIcon": nil, "webIconData": nil, "webIconDataMimetype": nil, "backgroundImage": nil, } // Each menu entry for _, m := range allMenus { children := childrenOf[m.ID] if children == nil { children = []int{} } actionID, actionModel := parseMenuAction(m.Action) appID := appIDOf(m.ID) var webIcon interface{} if m.WebIcon != nil && *m.WebIcon != "" { webIcon = *m.WebIcon } result[strconv.Itoa(m.ID)] = map[string]interface{}{ "id": m.ID, "name": m.Name, "children": children, "appID": appID, "xmlid": m.XMLID, "actionID": actionID, "actionModel": actionModel, "actionPath": false, "webIcon": webIcon, "webIconData": nil, "webIconDataMimetype": nil, "backgroundImage": nil, } } return result, nil } // parseMenuAction parses the Odoo action reference format "ir.actions.act_window,123" // into an action ID (int or false) and action model (string or false). // Mirrors: odoo/addons/base/models/ir_ui_menu.py _compute_action() func parseMenuAction(action *string) (interface{}, interface{}) { if action == nil || *action == "" { return false, false } parts := strings.SplitN(*action, ",", 2) if len(parts) != 2 { return false, false } model := parts[0] id, err := strconv.Atoi(parts[1]) if err != nil { return false, false } return id, model }