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:
Marc
2026-03-31 23:16:26 +02:00
parent 8741282322
commit 9c444061fd
32 changed files with 3416 additions and 148 deletions

View File

@@ -262,19 +262,19 @@ func (dc *DomainCompiler) compileSimpleCondition(column, operator string, value
return fmt.Sprintf("%q NOT IN (%s)", column, strings.Join(placeholders, ", ")), nil
case "like":
dc.params = append(dc.params, value)
dc.params = append(dc.params, wrapLikeValue(value))
return fmt.Sprintf("%q LIKE $%d", column, paramIdx), nil
case "not like":
dc.params = append(dc.params, value)
dc.params = append(dc.params, wrapLikeValue(value))
return fmt.Sprintf("%q NOT LIKE $%d", column, paramIdx), nil
case "ilike":
dc.params = append(dc.params, value)
dc.params = append(dc.params, wrapLikeValue(value))
return fmt.Sprintf("%q ILIKE $%d", column, paramIdx), nil
case "not ilike":
dc.params = append(dc.params, value)
dc.params = append(dc.params, wrapLikeValue(value))
return fmt.Sprintf("%q NOT ILIKE $%d", column, paramIdx), nil
case "=like":
@@ -369,26 +369,27 @@ func (dc *DomainCompiler) compileQualifiedCondition(qualifiedColumn, operator st
}
return fmt.Sprintf("%s %s (%s)", qualifiedColumn, op, strings.Join(placeholders, ", ")), nil
case "like", "not like", "ilike", "not ilike", "=like", "=ilike":
dc.params = append(dc.params, value)
sqlOp := strings.ToUpper(strings.TrimPrefix(operator, "="))
if strings.HasPrefix(operator, "=") {
sqlOp = strings.ToUpper(operator[1:])
}
case "like", "not like", "ilike", "not ilike":
dc.params = append(dc.params, wrapLikeValue(value))
sqlOp := "LIKE"
switch operator {
case "like":
sqlOp = "LIKE"
case "not like":
sqlOp = "NOT LIKE"
case "ilike", "=ilike":
case "ilike":
sqlOp = "ILIKE"
case "not ilike":
sqlOp = "NOT ILIKE"
case "=like":
sqlOp = "LIKE"
}
return fmt.Sprintf("%s %s $%d", qualifiedColumn, sqlOp, paramIdx), nil
case "=like":
dc.params = append(dc.params, value)
return fmt.Sprintf("%s LIKE $%d", qualifiedColumn, paramIdx), nil
case "=ilike":
dc.params = append(dc.params, value)
return fmt.Sprintf("%s ILIKE $%d", qualifiedColumn, paramIdx), nil
default:
dc.params = append(dc.params, value)
return fmt.Sprintf("%s %s $%d", qualifiedColumn, operator, paramIdx), nil
@@ -427,3 +428,18 @@ func normalizeSlice(value Value) []interface{} {
}
return nil
}
// wrapLikeValue wraps a string value with % wildcards for LIKE/ILIKE operators,
// matching Odoo's behavior where ilike/like auto-wrap the search term.
// If the value already contains %, it is left as-is.
// Mirrors: odoo/orm/domains.py _expression._unaccent_wrap (value wrapping)
func wrapLikeValue(value Value) Value {
s, ok := value.(string)
if !ok {
return value
}
if strings.Contains(s, "%") || strings.Contains(s, "_") {
return value // Already has wildcards, leave as-is
}
return "%" + s + "%"
}

View File

@@ -46,6 +46,7 @@ type Model struct {
// Hooks
BeforeCreate func(env *Environment, vals Values) error // Called before INSERT
DefaultGet func(env *Environment, fields []string) Values // Dynamic defaults (e.g., from DB)
Constraints []ConstraintFunc // Validation constraints
Methods map[string]MethodFunc // Named business methods
@@ -53,6 +54,11 @@ type Model struct {
computes map[string]ComputeFunc // field_name → compute function
dependencyMap map[string][]string // trigger_field → []computed_field_names
// Onchange handlers
// Maps field_name → handler that receives current vals and returns computed updates.
// Mirrors: @api.onchange in Odoo.
OnchangeHandlers map[string]func(env *Environment, vals Values) Values
// Resolved
parents []*Model // Resolved parent models from _inherit
allFields map[string]*Field // Including fields from parents
@@ -227,6 +233,17 @@ func (m *Model) RegisterMethod(name string, fn MethodFunc) *Model {
return m
}
// RegisterOnchange registers an onchange handler for a field.
// When the field changes on the client, the handler is called with the current
// record values and returns computed field updates.
// Mirrors: @api.onchange('field_name') in Odoo.
func (m *Model) RegisterOnchange(fieldName string, handler func(env *Environment, vals Values) Values) {
if m.OnchangeHandlers == nil {
m.OnchangeHandlers = make(map[string]func(env *Environment, vals Values) Values)
}
m.OnchangeHandlers[fieldName] = handler
}
// Extend extends this model with additional fields (like _inherit in Odoo).
// Mirrors: class MyModelExt(models.Model): _inherit = 'res.partner'
func (m *Model) Extend(fields ...*Field) *Model {

View File

@@ -97,6 +97,16 @@ func (rs *Recordset) Create(vals Values) (*Recordset, error) {
// Phase 1: Apply defaults for missing fields
ApplyDefaults(m, vals)
// Apply dynamic defaults from model's DefaultGet hook (e.g., DB lookups)
if m.DefaultGet != nil {
dynDefaults := m.DefaultGet(rs.env, nil)
for k, v := range dynDefaults {
if _, exists := vals[k]; !exists {
vals[k] = v
}
}
}
// Add magic fields
if rs.env.uid > 0 {
vals["create_uid"] = rs.env.uid
@@ -363,12 +373,13 @@ func (rs *Recordset) Read(fields []string) ([]Values, error) {
idPlaceholders[i] = fmt.Sprintf("$%d", i+1)
}
// Fetch without ORDER BY — we'll reorder to match rs.ids below.
// This preserves the caller's intended order (e.g., from Search with a custom ORDER).
query := fmt.Sprintf(
`SELECT %s FROM %q WHERE "id" IN (%s) ORDER BY %s`,
`SELECT %s FROM %q WHERE "id" IN (%s)`,
strings.Join(columns, ", "),
m.table,
strings.Join(idPlaceholders, ", "),
m.order,
)
rows, err := rs.env.tx.Query(rs.env.ctx, query, args...)
@@ -377,7 +388,8 @@ func (rs *Recordset) Read(fields []string) ([]Values, error) {
}
defer rows.Close()
var results []Values
// Collect results keyed by ID so we can reorder them.
resultsByID := make(map[int64]Values, len(rs.ids))
for rows.Next() {
scanDest := make([]interface{}, len(columns))
for i := range scanDest {
@@ -398,12 +410,22 @@ func (rs *Recordset) Read(fields []string) ([]Values, error) {
rs.env.cache.Set(m.name, id, name, val)
}
}
results = append(results, record)
if id, ok := toRecordID(record["id"]); ok {
resultsByID[id] = record
}
}
if err := rows.Err(); err != nil {
return nil, err
}
// Reorder results to match the original rs.ids order.
results := make([]Values, 0, len(rs.ids))
for _, id := range rs.ids {
if rec, ok := resultsByID[id]; ok {
results = append(results, rec)
}
}
// Post-fetch: M2M fields (from junction tables)
if len(m2mFields) > 0 && len(rs.ids) > 0 {
for _, fname := range m2mFields {
@@ -619,7 +641,7 @@ func (rs *Recordset) NameGet() (map[int64]string, error) {
result := make(map[int64]string, len(records))
for _, rec := range records {
id, _ := rec["id"].(int64)
id, _ := toRecordID(rec["id"])
name, _ := rec[recName].(string)
result[id] = name
}

View File

@@ -1,6 +1,9 @@
package orm
import "fmt"
import (
"fmt"
"log"
)
// ApplyRecordRules adds ir.rule domain filters to a search.
// Mirrors: odoo/addons/base/models/ir_rule.py IrRule._compute_domain()
@@ -10,32 +13,80 @@ import "fmt"
// - Group rules are OR-ed within the group set
// - The final domain is: global_rules AND (group_rule_1 OR group_rule_2 OR ...)
//
// For the initial implementation, we support company-based record rules:
// Records with a company_id field are filtered to the user's company.
// Implementation:
// 1. Built-in company filter (for models with company_id)
// 2. Custom ir.rule records loaded from the database
func ApplyRecordRules(env *Environment, m *Model, domain Domain) Domain {
if env.su {
return domain // Superuser bypasses record rules
}
// Auto-apply company filter if model has company_id
// 1. Auto-apply company filter if model has company_id
// Records where company_id = user's company OR company_id IS NULL (shared records)
if f := m.GetField("company_id"); f != nil && f.Type == TypeMany2one {
myCompany := Leaf("company_id", "=", env.CompanyID())
noCompany := Leaf("company_id", "=", nil)
companyFilter := Or(myCompany, noCompany)
if len(domain) == 0 {
return companyFilter
domain = companyFilter
} else {
// AND the company filter with existing domain
result := Domain{OpAnd}
result = append(result, domain...)
result = append(result, companyFilter...)
domain = result
}
// AND the company filter with existing domain
result := Domain{OpAnd}
result = append(result, domain...)
// Wrap company filter in the domain
result = append(result, companyFilter...)
return result
}
// TODO: Load custom ir.rule records from DB and compile their domains
// For now, only the built-in company filter is applied
// 2. Load custom ir.rule records from DB
// Mirrors: odoo/addons/base/models/ir_rule.py IrRule._compute_domain()
//
// Query rules that apply to this model for the current user:
// - Rule must be active and have perm_read = true
// - Either the rule has no group restriction (global rule),
// or the user belongs to one of the rule's groups.
// Use a savepoint so that a failed query (e.g., missing junction table)
// doesn't abort the parent transaction.
sp, spErr := env.tx.Begin(env.ctx)
if spErr != nil {
return domain
}
rows, err := sp.Query(env.ctx,
`SELECT r.id, r.domain_force, COALESCE(r.global, false)
FROM ir_rule r
JOIN ir_model m ON m.id = r.model_id
WHERE m.model = $1 AND r.active = true
AND r.perm_read = true`,
m.Name())
if err != nil {
sp.Rollback(env.ctx)
return domain
}
defer func() {
rows.Close()
sp.Commit(env.ctx)
}()
// Collect domain_force strings from matching rules
// TODO: parse domain_force strings into Domain objects and merge them
ruleCount := 0
for rows.Next() {
var ruleID int64
var domainForce *string
var global bool
if err := rows.Scan(&ruleID, &domainForce, &global); err != nil {
continue
}
ruleCount++
// TODO: parse domainForce (Python-style domain string) into Domain
// and AND global rules / OR group rules into the result domain.
// For now, rules are loaded but domain parsing is deferred.
_ = domainForce
_ = global
}
if ruleCount > 0 {
log.Printf("orm: loaded %d ir.rule record(s) for %s (domain parsing pending)", ruleCount, m.Name())
}
return domain
}

View File

@@ -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, &params)
// 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)

View File

@@ -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
View 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)
}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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
View 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")
}

View 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
View 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),
})
}

View File

@@ -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 {

View File

@@ -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
}
}
}
}

68
pkg/service/cron.go Normal file
View File

@@ -0,0 +1,68 @@
package service
import (
"context"
"log"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// CronJob defines a scheduled task.
type CronJob struct {
Name string
Interval time.Duration
Handler func(ctx context.Context, pool *pgxpool.Pool) error
running bool
}
// CronScheduler manages periodic jobs.
type CronScheduler struct {
jobs []*CronJob
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
}
// NewCronScheduler creates a new scheduler.
func NewCronScheduler() *CronScheduler {
ctx, cancel := context.WithCancel(context.Background())
return &CronScheduler{ctx: ctx, cancel: cancel}
}
// Register adds a job to the scheduler.
func (s *CronScheduler) Register(job *CronJob) {
s.mu.Lock()
defer s.mu.Unlock()
s.jobs = append(s.jobs, job)
}
// Start begins running all registered jobs.
func (s *CronScheduler) Start(pool *pgxpool.Pool) {
for _, job := range s.jobs {
go s.runJob(job, pool)
}
log.Printf("cron: started %d jobs", len(s.jobs))
}
// Stop cancels all running jobs.
func (s *CronScheduler) Stop() {
s.cancel()
}
func (s *CronScheduler) runJob(job *CronJob, pool *pgxpool.Pool) {
ticker := time.NewTicker(job.Interval)
defer ticker.Stop()
for {
select {
case <-s.ctx.Done():
return
case <-ticker.C:
if err := job.Handler(s.ctx, pool); err != nil {
log.Printf("cron: %s error: %v", job.Name, err)
}
}
}
}

View File

@@ -269,6 +269,9 @@ func SeedWithSetup(ctx context.Context, pool *pgxpool.Pool, cfg SetupConfig) err
SELECT setval('account_journal_id_seq', (SELECT COALESCE(MAX(id),0) FROM account_journal));
SELECT setval('account_account_id_seq', (SELECT COALESCE(MAX(id),0) FROM account_account));
SELECT setval('account_tax_id_seq', (SELECT COALESCE(MAX(id),0) FROM account_tax));
SELECT setval('sale_order_id_seq', (SELECT COALESCE(MAX(id),0) FROM sale_order));
SELECT setval('sale_order_line_id_seq', (SELECT COALESCE(MAX(id),0) FROM sale_order_line));
SELECT setval('account_move_id_seq', (SELECT COALESCE(MAX(id),0) FROM account_move));
`)
if err := tx.Commit(ctx); err != nil {
@@ -347,7 +350,45 @@ func seedViews(ctx context.Context, tx pgx.Tx) {
<field name="date_order"/>
<field name="state"/>
<field name="amount_total"/>
</list>', 16, true, 'primary')
</list>', 16, true, 'primary'),
-- crm.lead views
('lead.list', 'crm.lead', 'list', '<list>
<field name="name"/>
<field name="partner_name"/>
<field name="email_from"/>
<field name="phone"/>
<field name="stage_id"/>
<field name="expected_revenue"/>
<field name="user_id"/>
</list>', 16, true, 'primary'),
-- res.partner kanban
('partner.kanban', 'res.partner', 'kanban', '<kanban>
<templates>
<t t-name="card">
<div class="oe_kanban_global_click">
<strong><field name="name"/></strong>
<div><field name="email"/></div>
<div><field name="phone"/></div>
<div><field name="city"/></div>
</div>
</t>
</templates>
</kanban>', 16, true, 'primary'),
-- crm.lead kanban (pipeline)
('lead.kanban', 'crm.lead', 'kanban', '<kanban default_group_by="stage_id">
<templates>
<t t-name="card">
<div class="oe_kanban_global_click">
<strong><field name="name"/></strong>
<div><field name="partner_name"/></div>
<div>Revenue: <field name="expected_revenue"/></div>
</div>
</t>
</templates>
</kanban>', 16, true, 'primary')
ON CONFLICT DO NOTHING`)
log.Println("db: UI views seeded")
@@ -373,7 +414,30 @@ func seedDemoData(ctx context.Context, tx pgx.Tx) {
('Peter Weber', false, true, 'contact', 'peter@weber-elektro.de', '+49 69 5551234', 'de_DE')
ON CONFLICT DO NOTHING`)
log.Println("db: demo data loaded (8 demo contacts)")
// Demo sale orders
tx.Exec(ctx, `INSERT INTO sale_order (name, partner_id, company_id, currency_id, state, date_order, amount_untaxed, amount_total) VALUES
('AG0001', 3, 1, 1, 'sale', '2026-03-15 10:00:00', 18100, 21539),
('AG0002', 4, 1, 1, 'draft', '2026-03-20 14:30:00', 6000, 7140),
('AG0003', 5, 1, 1, 'sale', '2026-03-25 09:15:00', 11700, 13923)
ON CONFLICT DO NOTHING`)
// Demo sale order lines
tx.Exec(ctx, `INSERT INTO sale_order_line (order_id, name, product_uom_qty, price_unit, sequence) VALUES
((SELECT id FROM sale_order WHERE name='AG0001'), 'Baustelleneinrichtung', 1, 12500, 10),
((SELECT id FROM sale_order WHERE name='AG0001'), 'Erdarbeiten', 3, 2800, 20),
((SELECT id FROM sale_order WHERE name='AG0002'), 'Beratung IT-Infrastruktur', 40, 150, 10),
((SELECT id FROM sale_order WHERE name='AG0003'), 'Elektroinstallation', 1, 8500, 10),
((SELECT id FROM sale_order WHERE name='AG0003'), 'Material Kabel/Dosen', 1, 3200, 20)
ON CONFLICT DO NOTHING`)
// Demo invoices (account.move)
tx.Exec(ctx, `INSERT INTO account_move (name, move_type, state, date, invoice_date, partner_id, journal_id, company_id, currency_id, amount_total, amount_untaxed) VALUES
('RE/2026/0001', 'out_invoice', 'posted', '2026-03-10', '2026-03-10', 3, 1, 1, 1, 14875, 12500),
('RE/2026/0002', 'out_invoice', 'draft', '2026-03-20', '2026-03-20', 4, 1, 1, 1, 7140, 6000),
('RE/2026/0003', 'out_invoice', 'posted', '2026-03-25', '2026-03-25', 5, 1, 1, 1, 13923, 11700)
ON CONFLICT DO NOTHING`)
log.Println("db: demo data loaded (8 contacts, 3 sale orders, 3 invoices)")
}
// SeedBaseData is the legacy function — redirects to setup with defaults.

95
pkg/service/migrate.go Normal file
View File

@@ -0,0 +1,95 @@
// Package service — schema migration support.
// Mirrors: odoo/modules/migration.py (safe subset)
package service
import (
"context"
"fmt"
"log"
"github.com/jackc/pgx/v5/pgxpool"
"odoo-go/pkg/orm"
)
// MigrateSchema compares registered model fields with existing database columns
// and adds any missing columns. This is a safe, additive-only migration:
// it does NOT remove columns, change types, or drop tables.
//
// Mirrors: odoo/modules/loading.py _auto_init() — the part that adds new
// columns when a model gains a field after the initial CREATE TABLE.
func MigrateSchema(ctx context.Context, pool *pgxpool.Pool) error {
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("migrate: begin: %w", err)
}
defer tx.Rollback(ctx)
added := 0
for _, m := range orm.Registry.Models() {
if m.IsAbstract() {
continue
}
// Check if the table exists at all
var tableExists bool
err := tx.QueryRow(ctx,
`SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = $1 AND table_schema = 'public'
)`, m.Table()).Scan(&tableExists)
if err != nil || !tableExists {
continue // Table doesn't exist yet; InitDatabase will create it
}
// Get existing columns for this table
existing := make(map[string]bool)
rows, err := tx.Query(ctx,
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND table_schema = 'public'`,
m.Table())
if err != nil {
continue
}
for rows.Next() {
var col string
rows.Scan(&col)
existing[col] = true
}
rows.Close()
// Add missing columns
for _, f := range m.StoredFields() {
if f.Name == "id" {
continue
}
if existing[f.Column()] {
continue
}
sqlType := f.SQLType()
if sqlType == "" {
continue
}
alter := fmt.Sprintf(`ALTER TABLE %q ADD COLUMN %q %s`,
m.Table(), f.Column(), sqlType)
if _, err := tx.Exec(ctx, alter); err != nil {
log.Printf("migrate: warning: add column %s.%s: %v", m.Table(), f.Column(), err)
} else {
log.Printf("migrate: added column %s.%s (%s)", m.Table(), f.Column(), sqlType)
added++
}
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("migrate: commit: %w", err)
}
if added > 0 {
log.Printf("migrate: %d column(s) added", added)
} else {
log.Println("migrate: schema up to date")
}
return nil
}

45
pkg/tools/email.go Normal file
View File

@@ -0,0 +1,45 @@
package tools
import (
"fmt"
"log"
"net/smtp"
"os"
)
// SMTPConfig holds email server configuration.
type SMTPConfig struct {
Host string
Port int
User string
Password string
From string
}
// LoadSMTPConfig loads SMTP settings from environment variables.
func LoadSMTPConfig() *SMTPConfig {
cfg := &SMTPConfig{
Host: os.Getenv("SMTP_HOST"),
Port: 587,
User: os.Getenv("SMTP_USER"),
Password: os.Getenv("SMTP_PASSWORD"),
From: os.Getenv("SMTP_FROM"),
}
return cfg
}
// SendEmail sends a simple email. Returns error if SMTP is not configured.
func SendEmail(cfg *SMTPConfig, to, subject, body string) error {
if cfg.Host == "" {
log.Printf("email: SMTP not configured, would send to=%s subject=%s", to, subject)
return nil // Silently succeed if not configured
}
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/html; charset=utf-8\r\n\r\n%s",
cfg.From, to, subject, body)
auth := smtp.PlainAuth("", cfg.User, cfg.Password, cfg.Host)
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
return smtp.SendMail(addr, auth, cfg.From, []string{to}, []byte(msg))
}