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>
This commit is contained in:
@@ -5,6 +5,23 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// handleLoadBreadcrumbs returns breadcrumb data for the current navigation path.
|
||||
// Mirrors: odoo/addons/web/controllers/action.py Action.load_breadcrumbs()
|
||||
func (s *Server) handleLoadBreadcrumbs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req JSONRPCRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"})
|
||||
return
|
||||
}
|
||||
|
||||
s.writeJSONRPC(w, req.ID, []interface{}{}, nil)
|
||||
}
|
||||
|
||||
// handleActionLoad loads an action definition by ID.
|
||||
// Mirrors: odoo/addons/web/controllers/action.py Action.load()
|
||||
func (s *Server) handleActionLoad(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -25,22 +42,211 @@ func (s *Server) handleActionLoad(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
json.Unmarshal(req.Params, ¶ms)
|
||||
|
||||
// For now, return the Contacts action for any request
|
||||
// TODO: Load from ir_act_window table
|
||||
action := map[string]interface{}{
|
||||
"id": 1,
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Contacts",
|
||||
"res_model": "res.partner",
|
||||
"view_mode": "list,form",
|
||||
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
|
||||
"search_view_id": false,
|
||||
"domain": "[]",
|
||||
"context": "{}",
|
||||
"target": "current",
|
||||
"limit": 80,
|
||||
"help": "",
|
||||
"xml_id": "contacts.action_contacts",
|
||||
// Parse action_id from params (can be float64 from JSON or string)
|
||||
actionID := 0
|
||||
switch v := params.ActionID.(type) {
|
||||
case float64:
|
||||
actionID = int(v)
|
||||
case string:
|
||||
// Try to parse numeric string
|
||||
for _, c := range v {
|
||||
if c >= '0' && c <= '9' {
|
||||
actionID = actionID*10 + int(c-'0')
|
||||
} else {
|
||||
actionID = 0
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Action definitions by ID
|
||||
actions := map[int]map[string]interface{}{
|
||||
1: {
|
||||
"id": 1,
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Contacts",
|
||||
"res_model": "res.partner",
|
||||
"view_mode": "list,kanban,form",
|
||||
"views": [][]interface{}{{nil, "list"}, {nil, "kanban"}, {nil, "form"}},
|
||||
"search_view_id": false,
|
||||
"domain": "[]",
|
||||
"context": "{}",
|
||||
"target": "current",
|
||||
"limit": 80,
|
||||
"help": "",
|
||||
"xml_id": "contacts.action_contacts",
|
||||
},
|
||||
2: {
|
||||
"id": 2,
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Invoices",
|
||||
"res_model": "account.move",
|
||||
"view_mode": "list,form",
|
||||
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
|
||||
"search_view_id": false,
|
||||
"domain": `[("move_type","in",["out_invoice","out_refund"])]`,
|
||||
"context": "{}",
|
||||
"target": "current",
|
||||
"limit": 80,
|
||||
"help": "",
|
||||
"xml_id": "account.action_move_out_invoice_type",
|
||||
},
|
||||
3: {
|
||||
"id": 3,
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Sale Orders",
|
||||
"res_model": "sale.order",
|
||||
"view_mode": "list,form",
|
||||
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
|
||||
"search_view_id": false,
|
||||
"domain": "[]",
|
||||
"context": "{}",
|
||||
"target": "current",
|
||||
"limit": 80,
|
||||
"help": "",
|
||||
"xml_id": "sale.action_quotations_with_onboarding",
|
||||
},
|
||||
4: {
|
||||
"id": 4,
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "CRM Pipeline",
|
||||
"res_model": "crm.lead",
|
||||
"view_mode": "kanban,list,form",
|
||||
"views": [][]interface{}{{nil, "kanban"}, {nil, "list"}, {nil, "form"}},
|
||||
"search_view_id": false,
|
||||
"domain": "[]",
|
||||
"context": "{}",
|
||||
"target": "current",
|
||||
"limit": 80,
|
||||
"help": "",
|
||||
"xml_id": "crm.crm_lead_all_pipeline",
|
||||
},
|
||||
5: {
|
||||
"id": 5,
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Transfers",
|
||||
"res_model": "stock.picking",
|
||||
"view_mode": "list,form",
|
||||
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
|
||||
"search_view_id": false,
|
||||
"domain": "[]",
|
||||
"context": "{}",
|
||||
"target": "current",
|
||||
"limit": 80,
|
||||
"help": "",
|
||||
"xml_id": "stock.action_picking_tree_all",
|
||||
},
|
||||
6: {
|
||||
"id": 6,
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Products",
|
||||
"res_model": "product.template",
|
||||
"view_mode": "list,form",
|
||||
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
|
||||
"search_view_id": false,
|
||||
"domain": "[]",
|
||||
"context": "{}",
|
||||
"target": "current",
|
||||
"limit": 80,
|
||||
"help": "",
|
||||
"xml_id": "stock.action_product_template",
|
||||
},
|
||||
7: {
|
||||
"id": 7,
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Purchase Orders",
|
||||
"res_model": "purchase.order",
|
||||
"view_mode": "list,form",
|
||||
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
|
||||
"search_view_id": false,
|
||||
"domain": "[]",
|
||||
"context": "{}",
|
||||
"target": "current",
|
||||
"limit": 80,
|
||||
"help": "",
|
||||
"xml_id": "purchase.action_purchase_orders",
|
||||
},
|
||||
8: {
|
||||
"id": 8,
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Employees",
|
||||
"res_model": "hr.employee",
|
||||
"view_mode": "list,form",
|
||||
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
|
||||
"search_view_id": false,
|
||||
"domain": "[]",
|
||||
"context": "{}",
|
||||
"target": "current",
|
||||
"limit": 80,
|
||||
"help": "",
|
||||
"xml_id": "hr.action_hr_employee",
|
||||
},
|
||||
9: {
|
||||
"id": 9,
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Departments",
|
||||
"res_model": "hr.department",
|
||||
"view_mode": "list,form",
|
||||
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
|
||||
"search_view_id": false,
|
||||
"domain": "[]",
|
||||
"context": "{}",
|
||||
"target": "current",
|
||||
"limit": 80,
|
||||
"help": "",
|
||||
"xml_id": "hr.action_hr_department",
|
||||
},
|
||||
10: {
|
||||
"id": 10,
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Projects",
|
||||
"res_model": "project.project",
|
||||
"view_mode": "list,form",
|
||||
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
|
||||
"search_view_id": false,
|
||||
"domain": "[]",
|
||||
"context": "{}",
|
||||
"target": "current",
|
||||
"limit": 80,
|
||||
"help": "",
|
||||
"xml_id": "project.action_project",
|
||||
},
|
||||
11: {
|
||||
"id": 11,
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Tasks",
|
||||
"res_model": "project.task",
|
||||
"view_mode": "list,form",
|
||||
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
|
||||
"search_view_id": false,
|
||||
"domain": "[]",
|
||||
"context": "{}",
|
||||
"target": "current",
|
||||
"limit": 80,
|
||||
"help": "",
|
||||
"xml_id": "project.action_project_task",
|
||||
},
|
||||
12: {
|
||||
"id": 12,
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Vehicles",
|
||||
"res_model": "fleet.vehicle",
|
||||
"view_mode": "list,form",
|
||||
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
|
||||
"search_view_id": false,
|
||||
"domain": "[]",
|
||||
"context": "{}",
|
||||
"target": "current",
|
||||
"limit": 80,
|
||||
"help": "",
|
||||
"xml_id": "fleet.action_fleet_vehicle",
|
||||
},
|
||||
}
|
||||
|
||||
action, ok := actions[actionID]
|
||||
if !ok {
|
||||
// Default to Contacts if unknown action ID
|
||||
action = actions[1]
|
||||
}
|
||||
|
||||
s.writeJSONRPC(w, req.ID, action, nil)
|
||||
|
||||
@@ -12,21 +12,24 @@ func fieldsGetForModel(modelName string) map[string]interface{} {
|
||||
|
||||
result := make(map[string]interface{})
|
||||
for name, f := range m.Fields() {
|
||||
fType := f.Type.String()
|
||||
|
||||
fieldInfo := map[string]interface{}{
|
||||
"name": name,
|
||||
"type": f.Type.String(),
|
||||
"string": f.String,
|
||||
"help": f.Help,
|
||||
"readonly": f.Readonly,
|
||||
"required": f.Required,
|
||||
"searchable": f.IsStored(),
|
||||
"sortable": f.IsStored(),
|
||||
"store": f.IsStored(),
|
||||
"manual": false,
|
||||
"depends": f.Depends,
|
||||
"groupable": f.IsStored() && f.Type != orm.TypeText && f.Type != orm.TypeHTML,
|
||||
"exportable": true,
|
||||
"name": name,
|
||||
"type": fType,
|
||||
"string": f.String,
|
||||
"help": f.Help,
|
||||
"readonly": f.Readonly,
|
||||
"required": f.Required,
|
||||
"searchable": f.IsStored(),
|
||||
"sortable": f.IsStored(),
|
||||
"store": f.IsStored(),
|
||||
"manual": false,
|
||||
"depends": f.Depends,
|
||||
"groupable": f.IsStored() && f.Type != orm.TypeText && f.Type != orm.TypeHTML,
|
||||
"exportable": true,
|
||||
"change_default": false,
|
||||
"company_dependent": false,
|
||||
}
|
||||
|
||||
// Relational fields
|
||||
@@ -46,7 +49,24 @@ func fieldsGetForModel(modelName string) map[string]interface{} {
|
||||
fieldInfo["selection"] = sel
|
||||
}
|
||||
|
||||
// Domain & context defaults
|
||||
// Monetary fields need currency_field
|
||||
if f.Type == orm.TypeMonetary {
|
||||
cf := f.CurrencyField
|
||||
if cf == "" {
|
||||
cf = "currency_id"
|
||||
}
|
||||
fieldInfo["currency_field"] = cf
|
||||
}
|
||||
|
||||
// Computed fields
|
||||
if f.Compute != "" {
|
||||
fieldInfo["compute"] = f.Compute
|
||||
}
|
||||
if f.Related != "" {
|
||||
fieldInfo["related"] = f.Related
|
||||
}
|
||||
|
||||
// Default domain & context
|
||||
fieldInfo["domain"] = "[]"
|
||||
fieldInfo["context"] = "{}"
|
||||
|
||||
|
||||
24
pkg/server/image.go
Normal file
24
pkg/server/image.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// handleImage serves placeholder images for model records.
|
||||
// The real Odoo serves actual uploaded images from ir.attachment.
|
||||
// For now, return a 1x1 transparent PNG placeholder.
|
||||
func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
// 1x1 transparent PNG
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
// Minimal valid 1x1 transparent PNG (67 bytes)
|
||||
png := []byte{
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
|
||||
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
|
||||
0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x00, 0x00, 0x02,
|
||||
0x00, 0x01, 0xe5, 0x27, 0xde, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
|
||||
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
}
|
||||
w.Write(png)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ func (s *Server) handleLoadMenus(w http.ResponseWriter, r *http.Request) {
|
||||
"root": map[string]interface{}{
|
||||
"id": "root",
|
||||
"name": "root",
|
||||
"children": []int{1},
|
||||
"children": []int{1, 2, 3, 4, 5, 6, 7, 8, 9},
|
||||
"appID": false,
|
||||
"xmlid": "",
|
||||
"actionID": false,
|
||||
@@ -27,6 +27,7 @@ func (s *Server) handleLoadMenus(w http.ResponseWriter, r *http.Request) {
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
// Contacts
|
||||
"1": map[string]interface{}{
|
||||
"id": 1,
|
||||
"name": "Contacts",
|
||||
@@ -55,6 +56,280 @@ func (s *Server) handleLoadMenus(w http.ResponseWriter, r *http.Request) {
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
// Invoicing
|
||||
"2": map[string]interface{}{
|
||||
"id": 2,
|
||||
"name": "Invoicing",
|
||||
"children": []int{20},
|
||||
"appID": 2,
|
||||
"xmlid": "account.menu_finance",
|
||||
"actionID": 2,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": "fa-book,#71639e,#FFFFFF",
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"20": map[string]interface{}{
|
||||
"id": 20,
|
||||
"name": "Invoices",
|
||||
"children": []int{},
|
||||
"appID": 2,
|
||||
"xmlid": "account.menu_finance_invoices",
|
||||
"actionID": 2,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
// Sales
|
||||
"3": map[string]interface{}{
|
||||
"id": 3,
|
||||
"name": "Sales",
|
||||
"children": []int{30},
|
||||
"appID": 3,
|
||||
"xmlid": "sale.menu_sale_root",
|
||||
"actionID": 3,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": "fa-bar-chart,#71639e,#FFFFFF",
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"30": map[string]interface{}{
|
||||
"id": 30,
|
||||
"name": "Orders",
|
||||
"children": []int{},
|
||||
"appID": 3,
|
||||
"xmlid": "sale.menu_sale_orders",
|
||||
"actionID": 3,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
// CRM
|
||||
"4": map[string]interface{}{
|
||||
"id": 4,
|
||||
"name": "CRM",
|
||||
"children": []int{40},
|
||||
"appID": 4,
|
||||
"xmlid": "crm.menu_crm_root",
|
||||
"actionID": 4,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": "fa-star,#71639e,#FFFFFF",
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"40": map[string]interface{}{
|
||||
"id": 40,
|
||||
"name": "Pipeline",
|
||||
"children": []int{},
|
||||
"appID": 4,
|
||||
"xmlid": "crm.menu_crm_pipeline",
|
||||
"actionID": 4,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
// Inventory / Stock
|
||||
"5": map[string]interface{}{
|
||||
"id": 5,
|
||||
"name": "Inventory",
|
||||
"children": []int{50, 51},
|
||||
"appID": 5,
|
||||
"xmlid": "stock.menu_stock_root",
|
||||
"actionID": 5,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": "fa-cubes,#71639e,#FFFFFF",
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"50": map[string]interface{}{
|
||||
"id": 50,
|
||||
"name": "Transfers",
|
||||
"children": []int{},
|
||||
"appID": 5,
|
||||
"xmlid": "stock.menu_stock_transfers",
|
||||
"actionID": 5,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"51": map[string]interface{}{
|
||||
"id": 51,
|
||||
"name": "Products",
|
||||
"children": []int{},
|
||||
"appID": 5,
|
||||
"xmlid": "stock.menu_stock_products",
|
||||
"actionID": 6,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
// Purchase
|
||||
"6": map[string]interface{}{
|
||||
"id": 6,
|
||||
"name": "Purchase",
|
||||
"children": []int{60},
|
||||
"appID": 6,
|
||||
"xmlid": "purchase.menu_purchase_root",
|
||||
"actionID": 7,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": "fa-shopping-cart,#71639e,#FFFFFF",
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"60": map[string]interface{}{
|
||||
"id": 60,
|
||||
"name": "Purchase Orders",
|
||||
"children": []int{},
|
||||
"appID": 6,
|
||||
"xmlid": "purchase.menu_purchase_orders",
|
||||
"actionID": 7,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
// Employees / HR
|
||||
"7": map[string]interface{}{
|
||||
"id": 7,
|
||||
"name": "Employees",
|
||||
"children": []int{70, 71},
|
||||
"appID": 7,
|
||||
"xmlid": "hr.menu_hr_root",
|
||||
"actionID": 8,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": "fa-users,#71639e,#FFFFFF",
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"70": map[string]interface{}{
|
||||
"id": 70,
|
||||
"name": "Employees",
|
||||
"children": []int{},
|
||||
"appID": 7,
|
||||
"xmlid": "hr.menu_hr_employees",
|
||||
"actionID": 8,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"71": map[string]interface{}{
|
||||
"id": 71,
|
||||
"name": "Departments",
|
||||
"children": []int{},
|
||||
"appID": 7,
|
||||
"xmlid": "hr.menu_hr_departments",
|
||||
"actionID": 9,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
// Project
|
||||
"8": map[string]interface{}{
|
||||
"id": 8,
|
||||
"name": "Project",
|
||||
"children": []int{80, 81},
|
||||
"appID": 8,
|
||||
"xmlid": "project.menu_project_root",
|
||||
"actionID": 10,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": "fa-puzzle-piece,#71639e,#FFFFFF",
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"80": map[string]interface{}{
|
||||
"id": 80,
|
||||
"name": "Projects",
|
||||
"children": []int{},
|
||||
"appID": 8,
|
||||
"xmlid": "project.menu_projects",
|
||||
"actionID": 10,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"81": map[string]interface{}{
|
||||
"id": 81,
|
||||
"name": "Tasks",
|
||||
"children": []int{},
|
||||
"appID": 8,
|
||||
"xmlid": "project.menu_project_tasks",
|
||||
"actionID": 11,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
// Fleet
|
||||
"9": map[string]interface{}{
|
||||
"id": 9,
|
||||
"name": "Fleet",
|
||||
"children": []int{90},
|
||||
"appID": 9,
|
||||
"xmlid": "fleet.menu_fleet_root",
|
||||
"actionID": 12,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": "fa-car,#71639e,#FFFFFF",
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"90": map[string]interface{}{
|
||||
"id": 90,
|
||||
"name": "Vehicles",
|
||||
"children": []int{},
|
||||
"appID": 9,
|
||||
"xmlid": "fleet.menu_fleet_vehicles",
|
||||
"actionID": 12,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(menus)
|
||||
|
||||
@@ -2,14 +2,43 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const sessionKey contextKey = "session"
|
||||
|
||||
// LoggingMiddleware logs HTTP method, path, status code and duration for each request.
|
||||
// Static file requests are skipped to reduce noise.
|
||||
func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
// Skip logging for static files to reduce noise
|
||||
if strings.Contains(r.URL.Path, "/static/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// Wrap response writer to capture status code
|
||||
sw := &statusWriter{ResponseWriter: w, status: 200}
|
||||
next.ServeHTTP(sw, r)
|
||||
log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// AuthMiddleware checks for a valid session cookie on protected endpoints.
|
||||
func AuthMiddleware(store *SessionStore, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -612,6 +612,12 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
}
|
||||
return nameResult, nil
|
||||
|
||||
case "read_progress_bar":
|
||||
return map[string]interface{}{}, nil
|
||||
|
||||
case "activity_format":
|
||||
return []interface{}{}, nil
|
||||
|
||||
case "action_archive":
|
||||
ids := parseIDs(params.Args)
|
||||
if len(ids) > 0 {
|
||||
@@ -754,13 +760,20 @@ func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleSessionInfo(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeJSONRPC(w, nil, map[string]interface{}{
|
||||
"uid": 1,
|
||||
"is_admin": true,
|
||||
"server_version": "19.0-go",
|
||||
"server_version_info": []interface{}{19, 0, 0, "final", 0, "g"},
|
||||
"db": s.config.DBName,
|
||||
}, nil)
|
||||
// Try context first, then fall back to cookie lookup
|
||||
sess := GetSession(r)
|
||||
if sess == nil {
|
||||
if cookie, err := r.Cookie("session_id"); err == nil && cookie.Value != "" {
|
||||
sess = s.sessions.Get(cookie.Value)
|
||||
}
|
||||
}
|
||||
if sess == nil {
|
||||
s.writeJSONRPC(w, nil, nil, &RPCError{
|
||||
Code: 100, Message: "Session expired",
|
||||
})
|
||||
return
|
||||
}
|
||||
s.writeJSONRPC(w, nil, s.buildSessionInfo(sess), nil)
|
||||
}
|
||||
|
||||
func (s *Server) handleDBList(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -5,16 +5,28 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// handleSessionCheck returns null (session is valid if middleware passed).
|
||||
// handleSessionCheck verifies the session is valid and returns session info.
|
||||
func (s *Server) handleSessionCheck(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeJSONRPC(w, nil, nil, nil)
|
||||
sess := GetSession(r)
|
||||
if sess == nil {
|
||||
if cookie, err := r.Cookie("session_id"); err == nil && cookie.Value != "" {
|
||||
sess = s.sessions.Get(cookie.Value)
|
||||
}
|
||||
}
|
||||
if sess == nil {
|
||||
s.writeJSONRPC(w, nil, nil, &RPCError{
|
||||
Code: 100, Message: "Session expired",
|
||||
})
|
||||
return
|
||||
}
|
||||
s.writeJSONRPC(w, nil, s.buildSessionInfo(sess), nil)
|
||||
}
|
||||
|
||||
// handleSessionModules returns installed module names.
|
||||
func (s *Server) handleSessionModules(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeJSONRPC(w, nil, []string{
|
||||
"base", "web", "account", "sale", "stock", "purchase",
|
||||
"hr", "project", "crm", "fleet", "l10n_de", "product",
|
||||
"base", "web", "contacts", "sale", "account", "stock",
|
||||
"purchase", "crm", "hr", "project", "fleet", "product", "l10n_de",
|
||||
}, nil)
|
||||
}
|
||||
|
||||
@@ -37,8 +49,17 @@ func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
|
||||
// handleBootstrapTranslations returns empty translations for initial boot.
|
||||
func (s *Server) handleBootstrapTranslations(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeJSONRPC(w, nil, map[string]interface{}{
|
||||
"lang": "en_US",
|
||||
"hash": "empty",
|
||||
"lang": "en_US",
|
||||
"hash": "empty",
|
||||
"lang_parameters": 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,
|
||||
},
|
||||
"modules": map[string]interface{}{},
|
||||
"multi_lang": false,
|
||||
}, nil)
|
||||
|
||||
553
pkg/server/transpiler.go
Normal file
553
pkg/server/transpiler.go
Normal file
@@ -0,0 +1,553 @@
|
||||
// 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")
|
||||
}
|
||||
320
pkg/server/transpiler_test.go
Normal file
320
pkg/server/transpiler_test.go
Normal file
@@ -0,0 +1,320 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestURLToModuleName(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{"/web/static/src/core/foo.js", "@web/core/foo"},
|
||||
{"/web/static/src/env.js", "@web/env"},
|
||||
{"/web/static/src/session.js", "@web/session"},
|
||||
{"/stock/static/src/widgets/foo.js", "@stock/widgets/foo"},
|
||||
{"/web/static/lib/owl/owl.js", "@web/../lib/owl/owl"},
|
||||
{"/web/static/src/core/browser/browser.js", "@web/core/browser/browser"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.url, func(t *testing.T) {
|
||||
got := URLToModuleName(tt.url)
|
||||
if got != tt.want {
|
||||
t.Errorf("URLToModuleName(%q) = %q, want %q", tt.url, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsOdooModule(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
content string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "has import",
|
||||
url: "/web/static/src/foo.js",
|
||||
content: `import { Foo } from "@web/bar";`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "has export",
|
||||
url: "/web/static/src/foo.js",
|
||||
content: `export class Foo {}`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "has odoo-module tag",
|
||||
url: "/web/static/src/foo.js",
|
||||
content: "// @odoo-module\nconst x = 1;",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ignore directive",
|
||||
url: "/web/static/src/foo.js",
|
||||
content: "// @odoo-module ignore\nimport { X } from '@web/foo';",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "plain JS no module",
|
||||
url: "/web/static/src/foo.js",
|
||||
content: "var x = 1;\nconsole.log(x);",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "not a JS file",
|
||||
url: "/web/static/src/foo.xml",
|
||||
content: `import { Foo } from "@web/bar";`,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsOdooModule(tt.url, tt.content)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsOdooModule(%q, ...) = %v, want %v", tt.url, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractImports(t *testing.T) {
|
||||
t.Run("named imports", func(t *testing.T) {
|
||||
content := `import { Foo, Bar } from "@web/core/foo";
|
||||
import { Baz as Qux } from "@web/core/baz";
|
||||
const x = 1;`
|
||||
deps, requires, clean := extractImports(content)
|
||||
|
||||
if len(deps) != 2 {
|
||||
t.Fatalf("expected 2 deps, got %d: %v", len(deps), deps)
|
||||
}
|
||||
if deps[0] != "@web/core/foo" {
|
||||
t.Errorf("deps[0] = %q, want @web/core/foo", deps[0])
|
||||
}
|
||||
if deps[1] != "@web/core/baz" {
|
||||
t.Errorf("deps[1] = %q, want @web/core/baz", deps[1])
|
||||
}
|
||||
|
||||
if len(requires) != 2 {
|
||||
t.Fatalf("expected 2 requires, got %d", len(requires))
|
||||
}
|
||||
if !strings.Contains(requires[0], `{ Foo, Bar }`) {
|
||||
t.Errorf("requires[0] = %q, want Foo, Bar destructuring", requires[0])
|
||||
}
|
||||
if !strings.Contains(requires[1], `Baz: Qux`) {
|
||||
t.Errorf("requires[1] = %q, want Baz: Qux alias", requires[1])
|
||||
}
|
||||
|
||||
if strings.Contains(clean, "import") {
|
||||
t.Errorf("clean content still contains import statements: %s", clean)
|
||||
}
|
||||
if !strings.Contains(clean, "const x = 1;") {
|
||||
t.Errorf("clean content should still have 'const x = 1;': %s", clean)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default import", func(t *testing.T) {
|
||||
content := `import Foo from "@web/core/foo";`
|
||||
deps, requires, _ := extractImports(content)
|
||||
|
||||
if len(deps) != 1 || deps[0] != "@web/core/foo" {
|
||||
t.Errorf("deps = %v, want [@web/core/foo]", deps)
|
||||
}
|
||||
if len(requires) != 1 || !strings.Contains(requires[0], `Symbol.for("default")`) {
|
||||
t.Errorf("requires = %v, want default symbol access", requires)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("namespace import", func(t *testing.T) {
|
||||
content := `import * as utils from "@web/core/utils";`
|
||||
deps, requires, _ := extractImports(content)
|
||||
|
||||
if len(deps) != 1 || deps[0] != "@web/core/utils" {
|
||||
t.Errorf("deps = %v, want [@web/core/utils]", deps)
|
||||
}
|
||||
if len(requires) != 1 || !strings.Contains(requires[0], `const utils = require("@web/core/utils")`) {
|
||||
t.Errorf("requires = %v, want namespace require", requires)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("side-effect import", func(t *testing.T) {
|
||||
content := `import "@web/core/setup";`
|
||||
deps, requires, _ := extractImports(content)
|
||||
|
||||
if len(deps) != 1 || deps[0] != "@web/core/setup" {
|
||||
t.Errorf("deps = %v, want [@web/core/setup]", deps)
|
||||
}
|
||||
if len(requires) != 1 || requires[0] != `require("@web/core/setup");` {
|
||||
t.Errorf("requires = %v, want side-effect require", requires)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dedup deps", func(t *testing.T) {
|
||||
content := `import { Foo } from "@web/core/foo";
|
||||
import { Bar } from "@web/core/foo";`
|
||||
deps, _, _ := extractImports(content)
|
||||
|
||||
if len(deps) != 1 {
|
||||
t.Errorf("expected deduped deps, got %v", deps)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTransformExports(t *testing.T) {
|
||||
t.Run("export class", func(t *testing.T) {
|
||||
got := transformExports("export class Foo extends Bar {")
|
||||
want := "const Foo = __exports.Foo = class Foo extends Bar {"
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("export function", func(t *testing.T) {
|
||||
got := transformExports("export function doSomething(a, b) {")
|
||||
want := `__exports.doSomething = function doSomething(a, b) {`
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("export const", func(t *testing.T) {
|
||||
got := transformExports("export const MAX_SIZE = 100;")
|
||||
want := "const MAX_SIZE = __exports.MAX_SIZE = 100;"
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("export let", func(t *testing.T) {
|
||||
got := transformExports("export let counter = 0;")
|
||||
want := "let counter = __exports.counter = 0;"
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("export default", func(t *testing.T) {
|
||||
got := transformExports("export default Foo;")
|
||||
want := `__exports[Symbol.for("default")] = Foo;`
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("export named", func(t *testing.T) {
|
||||
got := transformExports("export { Foo, Bar };")
|
||||
if !strings.Contains(got, "__exports.Foo = Foo;") {
|
||||
t.Errorf("missing Foo export in: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "__exports.Bar = Bar;") {
|
||||
t.Errorf("missing Bar export in: %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("export named with alias", func(t *testing.T) {
|
||||
got := transformExports("export { Foo as default };")
|
||||
if !strings.Contains(got, "__exports.default = Foo;") {
|
||||
t.Errorf("missing aliased export in: %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTranspileJS(t *testing.T) {
|
||||
t.Run("full transpile", func(t *testing.T) {
|
||||
content := `// @odoo-module
|
||||
import { Component } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export class MyWidget extends Component {
|
||||
static template = "web.MyWidget";
|
||||
}
|
||||
|
||||
registry.category("actions").add("my_widget", MyWidget);
|
||||
`
|
||||
url := "/web/static/src/views/my_widget.js"
|
||||
result := TranspileJS(url, content)
|
||||
|
||||
// Check wrapper
|
||||
if !strings.HasPrefix(result, `odoo.define("@web/views/my_widget"`) {
|
||||
t.Errorf("missing odoo.define header: %s", result[:80])
|
||||
}
|
||||
|
||||
// Check deps
|
||||
if !strings.Contains(result, `"@odoo/owl"`) {
|
||||
t.Errorf("missing @odoo/owl dependency")
|
||||
}
|
||||
if !strings.Contains(result, `"@web/core/registry"`) {
|
||||
t.Errorf("missing @web/core/registry dependency")
|
||||
}
|
||||
|
||||
// Check require lines
|
||||
if !strings.Contains(result, `const { Component } = require("@odoo/owl");`) {
|
||||
t.Errorf("missing Component require")
|
||||
}
|
||||
if !strings.Contains(result, `const { registry } = require("@web/core/registry");`) {
|
||||
t.Errorf("missing registry require")
|
||||
}
|
||||
|
||||
// Check export transform
|
||||
if !strings.Contains(result, `const MyWidget = __exports.MyWidget = class MyWidget`) {
|
||||
t.Errorf("missing class export transform")
|
||||
}
|
||||
|
||||
// Check no raw import/export left
|
||||
if strings.Contains(result, "import {") {
|
||||
t.Errorf("raw import statement still present")
|
||||
}
|
||||
|
||||
// Check wrapper close
|
||||
if !strings.Contains(result, "return __exports;") {
|
||||
t.Errorf("missing return __exports")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-module passthrough", func(t *testing.T) {
|
||||
content := "var x = 1;\nconsole.log(x);"
|
||||
result := TranspileJS("/web/static/lib/foo.js", content)
|
||||
if result != content {
|
||||
t.Errorf("non-module content was modified")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignore directive passthrough", func(t *testing.T) {
|
||||
content := "// @odoo-module ignore\nimport { X } from '@web/foo';\nexport class Y {}"
|
||||
result := TranspileJS("/web/static/src/foo.js", content)
|
||||
if result != content {
|
||||
t.Errorf("ignored module content was modified")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseImportSpecifiers(t *testing.T) {
|
||||
tests := []struct {
|
||||
raw string
|
||||
want []importSpecifier
|
||||
}{
|
||||
{"Foo, Bar", []importSpecifier{{name: "Foo"}, {name: "Bar"}}},
|
||||
{"Foo as F, Bar", []importSpecifier{{name: "Foo", alias: "F"}, {name: "Bar"}}},
|
||||
{" X , Y , Z ", []importSpecifier{{name: "X"}, {name: "Y"}, {name: "Z"}}},
|
||||
{"", nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.raw, func(t *testing.T) {
|
||||
got := parseImportSpecifiers(tt.raw)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("got %d specifiers, want %d", len(got), len(tt.want))
|
||||
}
|
||||
for i, s := range got {
|
||||
if s.name != tt.want[i].name || s.alias != tt.want[i].alias {
|
||||
t.Errorf("specifier[%d] = %+v, want %+v", i, s, tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
48
pkg/server/upload.go
Normal file
48
pkg/server/upload.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// handleUpload handles file uploads to ir.attachment.
|
||||
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse multipart form (max 128MB)
|
||||
if err := r.ParseMultipartForm(128 << 20); err != nil {
|
||||
http.Error(w, "File too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("ufile")
|
||||
if err != nil {
|
||||
http.Error(w, "No file uploaded", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read file content
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
http.Error(w, "Read error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("upload: received %s (%d bytes, %s)", header.Filename, len(data), header.Header.Get("Content-Type"))
|
||||
|
||||
// TODO: Store in ir.attachment table or filesystem
|
||||
// For now, just acknowledge receipt
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": 1,
|
||||
"name": header.Filename,
|
||||
"size": len(data),
|
||||
})
|
||||
}
|
||||
@@ -108,54 +108,197 @@ func generateDefaultView(modelName, viewType string) string {
|
||||
}
|
||||
|
||||
func generateDefaultListView(m *orm.Model) string {
|
||||
// Prioritize important fields first
|
||||
priority := []string{"name", "display_name", "state", "partner_id", "date_order", "date",
|
||||
"amount_total", "amount_untaxed", "email", "phone", "company_id", "user_id",
|
||||
"product_id", "quantity", "price_unit", "price_subtotal"}
|
||||
|
||||
var fields []string
|
||||
count := 0
|
||||
added := make(map[string]bool)
|
||||
|
||||
// Add priority fields first
|
||||
for _, pf := range priority {
|
||||
f := m.GetField(pf)
|
||||
if f != nil && f.IsStored() && f.Type != orm.TypeBinary {
|
||||
fields = append(fields, fmt.Sprintf(`<field name="%s"/>`, pf))
|
||||
added[pf] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Fill remaining slots
|
||||
for _, f := range m.Fields() {
|
||||
if f.Name == "id" || !f.IsStored() || f.Name == "create_uid" || f.Name == "write_uid" ||
|
||||
f.Name == "create_date" || f.Name == "write_date" || f.Type == orm.TypeBinary {
|
||||
if len(fields) >= 10 {
|
||||
break
|
||||
}
|
||||
if added[f.Name] || f.Name == "id" || !f.IsStored() ||
|
||||
f.Name == "create_uid" || f.Name == "write_uid" ||
|
||||
f.Name == "create_date" || f.Name == "write_date" ||
|
||||
f.Type == orm.TypeBinary || f.Type == orm.TypeText || f.Type == orm.TypeHTML {
|
||||
continue
|
||||
}
|
||||
fields = append(fields, fmt.Sprintf(`<field name="%s"/>`, f.Name))
|
||||
count++
|
||||
if count >= 8 {
|
||||
break
|
||||
}
|
||||
added[f.Name] = true
|
||||
}
|
||||
|
||||
return fmt.Sprintf("<list>\n %s\n</list>", strings.Join(fields, "\n "))
|
||||
}
|
||||
|
||||
func generateDefaultFormView(m *orm.Model) string {
|
||||
var fields []string
|
||||
for _, f := range m.Fields() {
|
||||
if f.Name == "id" || f.Name == "create_uid" || f.Name == "write_uid" ||
|
||||
f.Name == "create_date" || f.Name == "write_date" || f.Type == orm.TypeBinary {
|
||||
skip := map[string]bool{
|
||||
"id": true, "create_uid": true, "write_uid": true,
|
||||
"create_date": true, "write_date": true,
|
||||
}
|
||||
|
||||
// Header with state widget if state field exists
|
||||
var header string
|
||||
if f := m.GetField("state"); f != nil && f.Type == orm.TypeSelection {
|
||||
header = ` <header>
|
||||
<field name="state" widget="statusbar"/>
|
||||
</header>
|
||||
`
|
||||
}
|
||||
|
||||
// Title field (name or display_name)
|
||||
var title string
|
||||
if f := m.GetField("name"); f != nil {
|
||||
title = ` <div class="oe_title">
|
||||
<h1><field name="name" placeholder="Name..."/></h1>
|
||||
</div>
|
||||
`
|
||||
skip["name"] = true
|
||||
}
|
||||
|
||||
// Split fields into left/right groups
|
||||
var leftFields, rightFields []string
|
||||
var o2mFields []string
|
||||
count := 0
|
||||
|
||||
// Prioritize important fields
|
||||
priority := []string{"partner_id", "date_order", "date", "company_id", "currency_id",
|
||||
"user_id", "journal_id", "product_id", "email", "phone"}
|
||||
|
||||
for _, pf := range priority {
|
||||
f := m.GetField(pf)
|
||||
if f == nil || skip[pf] || f.Type == orm.TypeBinary {
|
||||
continue
|
||||
}
|
||||
if f.Type == orm.TypeOne2many || f.Type == orm.TypeMany2many {
|
||||
continue // Skip relational fields in default form
|
||||
skip[pf] = true
|
||||
line := fmt.Sprintf(` <field name="%s"/>`, pf)
|
||||
if count%2 == 0 {
|
||||
leftFields = append(leftFields, line)
|
||||
} else {
|
||||
rightFields = append(rightFields, line)
|
||||
}
|
||||
fields = append(fields, fmt.Sprintf(` <field name="%s"/>`, f.Name))
|
||||
if len(fields) >= 20 {
|
||||
count++
|
||||
}
|
||||
|
||||
// Add remaining stored fields
|
||||
for _, f := range m.Fields() {
|
||||
if skip[f.Name] || !f.IsStored() || f.Type == orm.TypeBinary {
|
||||
continue
|
||||
}
|
||||
if f.Type == orm.TypeOne2many {
|
||||
o2mFields = append(o2mFields, fmt.Sprintf(` <field name="%s"/>`, f.Name))
|
||||
continue
|
||||
}
|
||||
if f.Type == orm.TypeMany2many {
|
||||
continue
|
||||
}
|
||||
line := fmt.Sprintf(` <field name="%s"/>`, f.Name)
|
||||
if len(leftFields) <= len(rightFields) {
|
||||
leftFields = append(leftFields, line)
|
||||
} else {
|
||||
rightFields = append(rightFields, line)
|
||||
}
|
||||
if len(leftFields)+len(rightFields) >= 20 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("<form>\n <sheet>\n <group>\n%s\n </group>\n </sheet>\n</form>",
|
||||
strings.Join(fields, "\n"))
|
||||
|
||||
// Build form
|
||||
var buf strings.Builder
|
||||
buf.WriteString("<form>\n")
|
||||
buf.WriteString(header)
|
||||
buf.WriteString(" <sheet>\n")
|
||||
buf.WriteString(title)
|
||||
buf.WriteString(" <group>\n")
|
||||
buf.WriteString(" <group>\n")
|
||||
buf.WriteString(strings.Join(leftFields, "\n"))
|
||||
buf.WriteString("\n </group>\n")
|
||||
buf.WriteString(" <group>\n")
|
||||
buf.WriteString(strings.Join(rightFields, "\n"))
|
||||
buf.WriteString("\n </group>\n")
|
||||
buf.WriteString(" </group>\n")
|
||||
|
||||
// O2M fields in notebook
|
||||
if len(o2mFields) > 0 {
|
||||
buf.WriteString(" <notebook>\n")
|
||||
buf.WriteString(" <page string=\"Lines\">\n")
|
||||
buf.WriteString(strings.Join(o2mFields, "\n"))
|
||||
buf.WriteString("\n </page>\n")
|
||||
buf.WriteString(" </notebook>\n")
|
||||
}
|
||||
|
||||
buf.WriteString(" </sheet>\n")
|
||||
buf.WriteString("</form>")
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func generateDefaultSearchView(m *orm.Model) string {
|
||||
var fields []string
|
||||
// Add name field if it exists
|
||||
if f := m.GetField("name"); f != nil {
|
||||
fields = append(fields, `<field name="name"/>`)
|
||||
}
|
||||
if f := m.GetField("email"); f != nil {
|
||||
fields = append(fields, `<field name="email"/>`)
|
||||
var filters []string
|
||||
|
||||
// Search fields
|
||||
searchable := []string{"name", "display_name", "email", "phone", "ref",
|
||||
"partner_id", "company_id", "user_id", "state", "date_order", "date"}
|
||||
for _, sf := range searchable {
|
||||
if f := m.GetField(sf); f != nil {
|
||||
fields = append(fields, fmt.Sprintf(`<field name="%s"/>`, sf))
|
||||
}
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
fields = append(fields, `<field name="id"/>`)
|
||||
}
|
||||
return fmt.Sprintf("<search>\n %s\n</search>", strings.Join(fields, "\n "))
|
||||
|
||||
// Auto-generate filter for state field
|
||||
if f := m.GetField("state"); f != nil && f.Type == orm.TypeSelection {
|
||||
for _, sel := range f.Selection {
|
||||
filters = append(filters, fmt.Sprintf(
|
||||
`<filter string="%s" name="filter_%s" domain="[('state','=','%s')]"/>`,
|
||||
sel.Label, sel.Value, sel.Value))
|
||||
}
|
||||
}
|
||||
|
||||
// Group-by for common fields
|
||||
var groupby []string
|
||||
groupable := []string{"partner_id", "state", "company_id", "user_id", "stage_id"}
|
||||
for _, gf := range groupable {
|
||||
if f := m.GetField(gf); f != nil {
|
||||
groupby = append(groupby, fmt.Sprintf(`<filter string="%s" name="groupby_%s" context="{'group_by': '%s'}"/>`,
|
||||
f.String, gf, gf))
|
||||
}
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
buf.WriteString("<search>\n")
|
||||
for _, f := range fields {
|
||||
buf.WriteString(" " + f + "\n")
|
||||
}
|
||||
if len(filters) > 0 {
|
||||
buf.WriteString(" <separator/>\n")
|
||||
for _, f := range filters {
|
||||
buf.WriteString(" " + f + "\n")
|
||||
}
|
||||
}
|
||||
if len(groupby) > 0 {
|
||||
buf.WriteString(" <group expand=\"0\" string=\"Group By\">\n")
|
||||
for _, g := range groupby {
|
||||
buf.WriteString(" " + g + "\n")
|
||||
}
|
||||
buf.WriteString(" </group>\n")
|
||||
}
|
||||
buf.WriteString("</search>")
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func generateDefaultKanbanView(m *orm.Model) string {
|
||||
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
@@ -12,8 +13,13 @@ import (
|
||||
func handleWebSearchRead(env *orm.Environment, model string, params CallKWParams) (interface{}, *RPCError) {
|
||||
rs := env.Model(model)
|
||||
|
||||
// Parse domain from first arg
|
||||
// Parse domain from first arg (regular search_read) or kwargs (web_search_read)
|
||||
domain := parseDomain(params.Args)
|
||||
if domain == nil {
|
||||
if domainRaw, ok := params.KW["domain"].([]interface{}); ok && len(domainRaw) > 0 {
|
||||
domain = parseDomain([]interface{}{domainRaw})
|
||||
}
|
||||
}
|
||||
|
||||
// Parse specification from kwargs
|
||||
spec, _ := params.KW["specification"].(map[string]interface{})
|
||||
@@ -45,11 +51,19 @@ func handleWebSearchRead(env *orm.Environment, model string, params CallKWParams
|
||||
order = v
|
||||
}
|
||||
|
||||
// Get total count
|
||||
// Get total count, respecting count_limit for optimization.
|
||||
// Mirrors: odoo/addons/web/models/models.py web_search_read() count_limit parameter
|
||||
countLimit := int64(0)
|
||||
if v, ok := params.KW["count_limit"].(float64); ok {
|
||||
countLimit = int64(v)
|
||||
}
|
||||
count, err := rs.SearchCount(domain)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
if countLimit > 0 && count > countLimit {
|
||||
count = countLimit
|
||||
}
|
||||
|
||||
// Search with offset/limit
|
||||
found, err := rs.Search(domain, orm.SearchOpts{
|
||||
@@ -72,6 +86,9 @@ func handleWebSearchRead(env *orm.Environment, model string, params CallKWParams
|
||||
// Format M2O fields as {id, display_name} when spec requests it
|
||||
formatM2OFields(env, model, records, spec)
|
||||
|
||||
// Format date/datetime fields to Odoo's expected string format
|
||||
formatDateFields(model, records)
|
||||
|
||||
if records == nil {
|
||||
records = []orm.Values{}
|
||||
}
|
||||
@@ -93,6 +110,18 @@ func handleWebRead(env *orm.Environment, model string, params CallKWParams) (int
|
||||
spec, _ := params.KW["specification"].(map[string]interface{})
|
||||
fields := specToFields(spec)
|
||||
|
||||
// Always include id
|
||||
hasID := false
|
||||
for _, f := range fields {
|
||||
if f == "id" {
|
||||
hasID = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasID {
|
||||
fields = append([]string{"id"}, fields...)
|
||||
}
|
||||
|
||||
rs := env.Model(model)
|
||||
records, err := rs.Browse(ids...).Read(fields)
|
||||
if err != nil {
|
||||
@@ -101,6 +130,9 @@ func handleWebRead(env *orm.Environment, model string, params CallKWParams) (int
|
||||
|
||||
formatM2OFields(env, model, records, spec)
|
||||
|
||||
// Format date/datetime fields to Odoo's expected string format
|
||||
formatDateFields(model, records)
|
||||
|
||||
if records == nil {
|
||||
records = []orm.Values{}
|
||||
}
|
||||
@@ -170,3 +202,42 @@ func formatM2OFields(env *orm.Environment, modelName string, records []orm.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatDateFields converts date/datetime values to Odoo's expected string format.
|
||||
func formatDateFields(model string, records []orm.Values) {
|
||||
m := orm.Registry.Get(model)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
for _, rec := range records {
|
||||
for fieldName, val := range rec {
|
||||
f := m.GetField(fieldName)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
if f.Type == orm.TypeDate || f.Type == orm.TypeDatetime {
|
||||
switch v := val.(type) {
|
||||
case time.Time:
|
||||
if f.Type == orm.TypeDate {
|
||||
rec[fieldName] = v.Format("2006-01-02")
|
||||
} else {
|
||||
rec[fieldName] = v.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
case string:
|
||||
// Already a string, might need reformatting
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
if f.Type == orm.TypeDate {
|
||||
rec[fieldName] = t.Format("2006-01-02")
|
||||
} else {
|
||||
rec[fieldName] = t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also convert boolean fields: Go nil → Odoo false
|
||||
if f.Type == orm.TypeBoolean && val == nil {
|
||||
rec[fieldName] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user