Files
goodie/pkg/server/views.go
Marc 66383adf06 feat: Portal, Email Inbound, Discuss + module improvements
- Portal: /my/* routes, signup, password reset, portal user support
- Email Inbound: IMAP polling (go-imap/v2), thread matching
- Discuss: mail.channel, long-polling bus, DM, unread count
- Cron: ir.cron runner (goroutine scheduler)
- Bank Import, CSV/Excel Import
- Automation (ir.actions.server)
- Fetchmail service
- HR Payroll model
- Various fixes across account, sale, stock, purchase, crm, hr, project

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:41:57 +02:00

1054 lines
30 KiB
Go

package server
import (
"encoding/json"
"encoding/xml"
"fmt"
"net/http"
"sort"
"strings"
"odoo-go/pkg/orm"
)
// handleGetDefinitions returns field definitions for one or more models.
// Mirrors: odoo/addons/web/controllers/model.py Model.get_definitions()
func (s *Server) handleGetDefinitions(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
}
var params struct {
Models []string `json:"model_names"`
}
json.Unmarshal(req.Params, &params)
result := make(map[string]interface{})
for _, modelName := range params.Models {
result[modelName] = fieldsGetForModel(modelName)
}
s.writeJSONRPC(w, req.ID, result, nil)
}
// handleGetViews implements the get_views method.
// Mirrors: odoo/addons/base/models/ir_ui_view.py get_views()
func handleGetViews(env *orm.Environment, model string, params CallKWParams) (interface{}, *RPCError) {
// Parse views list: [[false, "list"], [false, "form"], [false, "search"]]
var viewRequests [][]interface{}
if len(params.Args) > 0 {
if vr, ok := params.Args[0].([]interface{}); ok {
viewRequests = make([][]interface{}, len(vr))
for i, v := range vr {
if pair, ok := v.([]interface{}); ok {
viewRequests[i] = pair
}
}
}
}
// Also check kwargs
if viewRequests == nil {
if vr, ok := params.KW["views"].([]interface{}); ok {
viewRequests = make([][]interface{}, len(vr))
for i, v := range vr {
if pair, ok := v.([]interface{}); ok {
viewRequests[i] = pair
}
}
}
}
views := make(map[string]interface{})
for _, vr := range viewRequests {
if len(vr) < 2 {
continue
}
viewType, _ := vr[1].(string)
if viewType == "" {
continue
}
// Try to load from ir_ui_view table
arch := loadViewArch(env, model, viewType)
if arch == "" {
// Generate default view
arch = generateDefaultView(model, viewType)
}
views[viewType] = map[string]interface{}{
"arch": arch,
"type": viewType,
"model": model,
"view_id": 0,
"field_parent": false,
}
}
// Always include search view (client expects it)
if _, hasSearch := views["search"]; !hasSearch {
arch := loadViewArch(env, model, "search")
if arch == "" {
arch = generateDefaultView(model, "search")
}
views["search"] = map[string]interface{}{
"arch": arch,
"type": "search",
"model": model,
"view_id": 0,
"field_parent": false,
}
}
// Build models dict with field metadata.
// Include the main model + all comodels referenced by relational fields.
// Mirrors: odoo/addons/web/models/models.py _get_view_fields()
models := map[string]interface{}{
model: map[string]interface{}{
"fields": fieldsGetForModel(model),
},
}
// Add comodels (needed for O2M/M2M inline views and M2O dropdowns)
m := orm.Registry.Get(model)
if m != nil {
for _, f := range m.Fields() {
if f.Comodel != "" {
if _, exists := models[f.Comodel]; !exists {
comodelFields := fieldsGetForModel(f.Comodel)
if len(comodelFields) > 0 {
models[f.Comodel] = map[string]interface{}{
"fields": comodelFields,
}
}
}
}
}
}
return map[string]interface{}{
"views": views,
"models": models,
}, nil
}
// loadViewArch tries to load a view from the ir_ui_view table.
// After loading the base view, it queries for inheriting views and merges them.
// Mirrors: odoo/addons/base/models/ir_ui_view.py _get_combined_arch()
func loadViewArch(env *orm.Environment, model, viewType string) string {
// Load base view (no parent — inherit_id IS NULL)
var arch string
var viewID int64
err := env.Tx().QueryRow(env.Ctx(),
`SELECT id, arch FROM ir_ui_view
WHERE model = $1 AND type = $2 AND active = true AND inherit_id IS NULL
ORDER BY priority LIMIT 1`,
model, viewType,
).Scan(&viewID, &arch)
if err != nil {
return ""
}
// Load inheriting views and apply them in priority order
rows, err := env.Tx().Query(env.Ctx(),
`SELECT arch FROM ir_ui_view
WHERE inherit_id = $1 AND active = true
ORDER BY priority`,
viewID,
)
if err != nil {
return arch
}
defer rows.Close()
for rows.Next() {
var inheritArch string
if err := rows.Scan(&inheritArch); err != nil {
continue
}
arch = applyViewInheritance(arch, inheritArch)
}
return arch
}
// generateDefaultView creates a minimal view XML for a model.
func generateDefaultView(modelName, viewType string) string {
m := orm.Registry.Get(modelName)
if m == nil {
return fmt.Sprintf("<%s><field name=\"id\"/></%s>", viewType, viewType)
}
switch viewType {
case "list", "tree":
return generateDefaultListView(m)
case "form":
return generateDefaultFormView(m)
case "search":
return generateDefaultSearchView(m)
case "kanban":
return generateDefaultKanbanView(m)
case "pivot":
return generateDefaultPivotView(m)
case "graph":
return generateDefaultGraphView(m)
case "calendar":
return generateDefaultCalendarView(m)
case "activity":
return generateDefaultActivityView(m)
case "dashboard":
return generateDefaultDashboardView(m)
default:
return fmt.Sprintf("<%s><field name=\"id\"/></%s>", viewType, viewType)
}
}
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
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 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.Name == "password" || f.Name == "password_crypt" ||
f.Type == orm.TypeBinary || f.Type == orm.TypeText || f.Type == orm.TypeHTML {
continue
}
fields = append(fields, fmt.Sprintf(`<field name="%s"/>`, f.Name))
added[f.Name] = true
}
return fmt.Sprintf("<list>\n %s\n</list>", strings.Join(fields, "\n "))
}
func generateDefaultFormView(m *orm.Model) string {
skip := map[string]bool{
"id": true, "create_uid": true, "write_uid": true,
"create_date": true, "write_date": true,
"password": true, "password_crypt": true,
}
// Header with action buttons and state widget
// Mirrors: odoo form views with <header><button .../><field name="state" widget="statusbar"/></header>
var header string
if f := m.GetField("state"); f != nil && f.Type == orm.TypeSelection {
var buttons []string
// Generate buttons from registered methods that look like actions
if m.Methods != nil {
actionMethods := []struct{ method, label, stateFilter string }{
{"action_confirm", "Confirm", "draft"},
{"action_post", "Post", "draft"},
{"action_done", "Done", "confirmed"},
{"action_cancel", "Cancel", ""},
{"button_cancel", "Cancel", ""},
{"button_draft", "Reset to Draft", "cancel"},
{"action_send", "Send", "posted"},
{"create_invoices", "Create Invoice", "sale"},
}
for _, am := range actionMethods {
if _, ok := m.Methods[am.method]; ok {
attrs := ""
if am.stateFilter != "" {
attrs = fmt.Sprintf(` invisible="state != '%s'"`, am.stateFilter)
}
btnClass := "btn-secondary"
if am.method == "action_confirm" || am.method == "action_post" {
btnClass = "btn-primary"
}
buttons = append(buttons, fmt.Sprintf(
` <button name="%s" string="%s" type="object" class="%s"%s/>`,
am.method, am.label, btnClass, attrs))
}
}
}
header = " <header>\n"
for _, btn := range buttons {
header += btn + "\n"
}
header += ` <field name="state" widget="statusbar" clickable="1"/>
</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
}
skip[pf] = true
line := fmt.Sprintf(` <field name="%s"/>`, pf)
if count%2 == 0 {
leftFields = append(leftFields, line)
} else {
rightFields = append(rightFields, line)
}
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
}
}
// 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
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"/>`)
}
// 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 {
nameField := "name"
if f := m.GetField("name"); f == nil {
nameField = "id"
}
// Build a richer card with available fields
var cardFields []string
// Title
cardFields = append(cardFields, fmt.Sprintf(` <field name="%s" class="fw-bold fs-5"/>`, nameField))
// Partner/customer
if f := m.GetField("partner_id"); f != nil {
cardFields = append(cardFields, ` <field name="partner_id"/>`)
}
// Revenue/amount
for _, amtField := range []string{"expected_revenue", "amount_total", "amount_untaxed"} {
if f := m.GetField(amtField); f != nil {
cardFields = append(cardFields, fmt.Sprintf(` <field name="%s"/>`, amtField))
break
}
}
// Date
for _, dateField := range []string{"date_order", "date", "date_deadline"} {
if f := m.GetField(dateField); f != nil {
cardFields = append(cardFields, fmt.Sprintf(` <field name="%s"/>`, dateField))
break
}
}
// User/assignee
if f := m.GetField("user_id"); f != nil {
cardFields = append(cardFields, ` <field name="user_id" widget="many2one_avatar_user"/>`)
}
return fmt.Sprintf(`<kanban>
<templates>
<t t-name="card">
%s
</t>
</templates>
</kanban>`, strings.Join(cardFields, "\n"))
}
// generateDefaultPivotView creates a minimal pivot view with numeric measure fields.
// Mirrors: odoo/addons/web/models/ir_ui_view.py default_view()
func generateDefaultPivotView(m *orm.Model) string {
var measures []string
for _, name := range sortedFieldNames(m) {
f := m.GetField(name)
if f == nil {
continue
}
if (f.Type == orm.TypeFloat || f.Type == orm.TypeInteger || f.Type == orm.TypeMonetary) && f.IsStored() && f.Name != "id" {
measures = append(measures, fmt.Sprintf(`<field name="%s" type="measure"/>`, f.Name))
if len(measures) >= 3 {
break
}
}
}
if len(measures) == 0 {
measures = append(measures, `<field name="id" type="measure"/>`)
}
return fmt.Sprintf("<pivot>\n %s\n</pivot>", strings.Join(measures, "\n "))
}
// generateDefaultGraphView creates a minimal graph view with a dimension and a measure.
// Mirrors: odoo/addons/web/models/ir_ui_view.py default_view()
func generateDefaultGraphView(m *orm.Model) string {
var fields []string
// First groupable field as dimension
for _, name := range sortedFieldNames(m) {
f := m.GetField(name)
if f == nil {
continue
}
if f.IsStored() && (f.Type == orm.TypeMany2one || f.Type == orm.TypeSelection) {
fields = append(fields, fmt.Sprintf(`<field name="%s"/>`, f.Name))
break
}
}
// First numeric field as measure
for _, name := range sortedFieldNames(m) {
f := m.GetField(name)
if f == nil {
continue
}
if (f.Type == orm.TypeFloat || f.Type == orm.TypeInteger || f.Type == orm.TypeMonetary) && f.IsStored() && f.Name != "id" {
fields = append(fields, fmt.Sprintf(`<field name="%s" type="measure"/>`, f.Name))
break
}
}
if len(fields) == 0 {
fields = append(fields, `<field name="id" type="measure"/>`)
}
return fmt.Sprintf("<graph>\n %s\n</graph>", strings.Join(fields, "\n "))
}
// generateDefaultCalendarView creates a calendar view with auto-detected date fields.
// The OWL CalendarArchParser requires date_start; date_stop and color are optional.
// Mirrors: odoo/addons/web/static/src/views/calendar/calendar_arch_parser.js
func generateDefaultCalendarView(m *orm.Model) string {
// Auto-detect date_start field (priority order)
dateStart := ""
for _, candidate := range []string{"start", "date_start", "date_from", "date_order", "date_begin", "date"} {
if f := m.GetField(candidate); f != nil && (f.Type == orm.TypeDatetime || f.Type == orm.TypeDate) {
dateStart = candidate
break
}
}
if dateStart == "" {
// Fallback: find any datetime/date field
for _, name := range sortedFieldNames(m) {
f := m.GetField(name)
if f != nil && (f.Type == orm.TypeDatetime || f.Type == orm.TypeDate) && f.Name != "create_date" && f.Name != "write_date" {
dateStart = name
break
}
}
}
if dateStart == "" {
// No date field found — return minimal arch that won't crash
return `<calendar date_start="create_date"><field name="display_name"/></calendar>`
}
// Auto-detect date_stop field
dateStop := ""
for _, candidate := range []string{"stop", "date_stop", "date_to", "date_end"} {
if f := m.GetField(candidate); f != nil && (f.Type == orm.TypeDatetime || f.Type == orm.TypeDate) {
dateStop = candidate
break
}
}
// Auto-detect color field (M2O fields make good color discriminators)
colorField := ""
for _, candidate := range []string{"color", "user_id", "partner_id", "stage_id"} {
if f := m.GetField(candidate); f != nil {
colorField = candidate
break
}
}
// Auto-detect all_day field
allDay := ""
for _, candidate := range []string{"allday", "all_day"} {
if f := m.GetField(candidate); f != nil && f.Type == orm.TypeBoolean {
allDay = candidate
break
}
}
// Build attributes
attrs := fmt.Sprintf(`date_start="%s"`, dateStart)
if dateStop != "" {
attrs += fmt.Sprintf(` date_stop="%s"`, dateStop)
}
if colorField != "" {
attrs += fmt.Sprintf(` color="%s"`, colorField)
}
if allDay != "" {
attrs += fmt.Sprintf(` all_day="%s"`, allDay)
}
// Pick display fields for the calendar card
var fields []string
nameField := "display_name"
if f := m.GetField("name"); f != nil {
nameField = "name"
}
fields = append(fields, fmt.Sprintf(` <field name="%s"/>`, nameField))
if f := m.GetField("partner_id"); f != nil {
fields = append(fields, ` <field name="partner_id" avatar_field="avatar_128"/>`)
}
if f := m.GetField("user_id"); f != nil && colorField != "user_id" {
fields = append(fields, ` <field name="user_id"/>`)
}
return fmt.Sprintf("<calendar %s mode=\"month\">\n%s\n</calendar>",
attrs, strings.Join(fields, "\n"))
}
// generateDefaultActivityView creates a minimal activity view.
// Mirrors: odoo/addons/mail/static/src/views/web_activity/activity_arch_parser.js
func generateDefaultActivityView(m *orm.Model) string {
nameField := "display_name"
if f := m.GetField("name"); f != nil {
nameField = "name"
}
return fmt.Sprintf(`<activity string="Activities">
<templates>
<div t-name="activity-box">
<field name="%s"/>
</div>
</templates>
</activity>`, nameField)
}
// generateDefaultDashboardView creates a dashboard view with aggregate widgets.
// Mirrors: odoo/addons/board/static/src/board_view.js
func generateDefaultDashboardView(m *orm.Model) string {
var widgets []string
// Add aggregate widgets for numeric fields
for _, name := range sortedFieldNames(m) {
f := m.GetField(name)
if f == nil {
continue
}
if (f.Type == orm.TypeFloat || f.Type == orm.TypeInteger || f.Type == orm.TypeMonetary) &&
f.IsStored() && f.Name != "id" && f.Name != "sequence" &&
f.Name != "create_uid" && f.Name != "write_uid" && f.Name != "company_id" {
widgets = append(widgets, fmt.Sprintf(
` <aggregate name="%s" field="%s" string="%s"/>`,
f.Name, f.Name, f.String))
if len(widgets) >= 6 {
break
}
}
}
// Add a graph for the first groupable dimension
var graphField string
for _, name := range sortedFieldNames(m) {
f := m.GetField(name)
if f != nil && f.IsStored() && (f.Type == orm.TypeMany2one || f.Type == orm.TypeSelection) {
graphField = name
break
}
}
var buf strings.Builder
buf.WriteString("<dashboard>\n")
if len(widgets) > 0 {
buf.WriteString(" <group>\n")
for _, w := range widgets {
buf.WriteString(w + "\n")
}
buf.WriteString(" </group>\n")
}
if graphField != "" {
buf.WriteString(fmt.Sprintf(` <view type="graph">
<graph type="bar">
<field name="%s"/>
</graph>
</view>
`, graphField))
}
buf.WriteString("</dashboard>")
return buf.String()
}
// sortedFieldNames returns field names in alphabetical order for deterministic output.
func sortedFieldNames(m *orm.Model) []string {
fields := m.Fields()
names := make([]string, 0, len(fields))
for name := range fields {
names = append(names, name)
}
sort.Strings(names)
return names
}
// --- View Inheritance ---
// applyViewInheritance merges an inheriting view's arch into a base view.
// Supports:
// - <xpath expr="//field[@name='X']" position="after|before|replace|inside">
// - <field name="X" position="after|before|replace|inside"> (shorthand)
//
// Mirrors (simplified): odoo/addons/base/models/ir_ui_view.py apply_inheritance_specs()
func applyViewInheritance(baseArch, inheritArch string) string {
directives := parseInheritDirectives(inheritArch)
if len(directives) == 0 {
return baseArch
}
for _, d := range directives {
baseArch = applyDirective(baseArch, d)
}
return baseArch
}
// inheritDirective represents a single modification instruction from an inheriting view.
type inheritDirective struct {
// target identifies what to match: tag name + attribute match
targetTag string // e.g., "field", "group", "page", "notebook", etc.
targetAttr string // attribute name to match, e.g., "name"
targetVal string // attribute value to match, e.g., "partner_id"
position string // "after", "before", "replace", "inside", "attributes"
content string // inner XML content to insert/replace
}
// parseInheritDirectives extracts modification directives from an inheriting view arch.
func parseInheritDirectives(inheritArch string) []inheritDirective {
// Wrap in a root element so the XML decoder can parse fragments
wrapped := "<_root>" + strings.TrimSpace(inheritArch) + "</_root>"
decoder := xml.NewDecoder(strings.NewReader(wrapped))
var directives []inheritDirective
// We need to find the top-level children of _root.
// Skip the outer <data> or root element if present.
// The actual directives are either <xpath> or <field> etc. with position attr.
type element struct {
name xml.Name
attrs []xml.Attr
}
depth := 0
var stack []element
var contentBuilder strings.Builder
captureDepth := -1
for {
tok, err := decoder.Token()
if err != nil {
break
}
switch t := tok.(type) {
case xml.StartElement:
depth++
// If we're capturing content for a directive, write this element
if captureDepth >= 0 && depth > captureDepth+1 {
contentBuilder.WriteString(startElementToString(t))
}
stack = append(stack, element{name: t.Name, attrs: t.Attr})
// Skip the wrapper _root and optional <data> root element
if depth == 1 && t.Name.Local == "_root" {
continue
}
if depth == 2 && t.Name.Local == "data" {
continue
}
// Check if this is a directive element (xpath or element with position)
pos := attrVal(t.Attr, "position")
if pos != "" && captureDepth < 0 {
d := inheritDirective{position: pos}
if t.Name.Local == "xpath" {
// Parse expr attribute: //field[@name='partner_id']
expr := attrVal(t.Attr, "expr")
d.targetTag, d.targetAttr, d.targetVal = parseXPathExpr(expr)
} else {
// Shorthand: <field name="X" position="after">
d.targetTag = t.Name.Local
d.targetAttr = "name"
d.targetVal = attrVal(t.Attr, "name")
}
if d.targetTag != "" {
directives = append(directives, d)
captureDepth = depth
contentBuilder.Reset()
}
}
case xml.EndElement:
if captureDepth >= 0 {
if depth == captureDepth {
// We've closed the directive element, store captured content
if len(directives) > 0 {
directives[len(directives)-1].content = strings.TrimSpace(contentBuilder.String())
}
captureDepth = -1
contentBuilder.Reset()
} else if depth > captureDepth {
contentBuilder.WriteString(fmt.Sprintf("</%s>", t.Name.Local))
}
}
depth--
if len(stack) > 0 {
stack = stack[:len(stack)-1]
}
case xml.CharData:
if captureDepth >= 0 && depth > captureDepth {
contentBuilder.Write(t)
}
}
}
return directives
}
// startElementToString serializes an xml.StartElement back to string form.
func startElementToString(el xml.StartElement) string {
var buf strings.Builder
buf.WriteString("<")
buf.WriteString(el.Name.Local)
for _, a := range el.Attr {
buf.WriteString(fmt.Sprintf(` %s="%s"`, a.Name.Local, a.Value))
}
buf.WriteString(">")
return buf.String()
}
// attrVal returns the value of a named attribute, or "".
func attrVal(attrs []xml.Attr, name string) string {
for _, a := range attrs {
if a.Name.Local == name {
return a.Value
}
}
return ""
}
// parseXPathExpr parses a simple XPath expression like //field[@name='partner_id'].
// Returns (tag, attrName, attrValue). Only supports the //tag[@attr='val'] pattern.
func parseXPathExpr(expr string) (string, string, string) {
// Pattern: //tag[@attr='val'] or //tag[@attr="val"]
expr = strings.TrimSpace(expr)
// Strip leading //
expr = strings.TrimPrefix(expr, "//")
// Find tag[
bracketIdx := strings.Index(expr, "[")
if bracketIdx < 0 {
// Simple case: //tag — match by tag name alone
tag := strings.TrimSpace(expr)
if tag != "" {
return tag, "", ""
}
return "", "", ""
}
tag := expr[:bracketIdx]
// Extract @attr='val'
inner := expr[bracketIdx+1:]
inner = strings.TrimSuffix(inner, "]")
inner = strings.TrimPrefix(inner, "@")
eqIdx := strings.Index(inner, "=")
if eqIdx < 0 {
return tag, "", ""
}
attrName := inner[:eqIdx]
attrValue := inner[eqIdx+1:]
// Remove quotes
attrValue = strings.Trim(attrValue, "'\"")
return tag, attrName, attrValue
}
// applyDirective applies a single inheritance directive to the base arch.
func applyDirective(baseArch string, d inheritDirective) string {
// Find the target element in baseArch
targetStart, targetEnd := findElement(baseArch, d.targetTag, d.targetAttr, d.targetVal)
if targetStart < 0 {
return baseArch
}
switch d.position {
case "after":
// Insert content after the target element
return baseArch[:targetEnd] + "\n" + d.content + baseArch[targetEnd:]
case "before":
// Insert content before the target element
return baseArch[:targetStart] + d.content + "\n" + baseArch[targetStart:]
case "replace":
// Replace the target element with content
if d.content == "" {
// Empty replace = remove the element
return baseArch[:targetStart] + baseArch[targetEnd:]
}
return baseArch[:targetStart] + d.content + baseArch[targetEnd:]
case "inside":
// Insert content inside the target element (before closing tag)
// Need to find the closing tag of the target
closingTag := fmt.Sprintf("</%s>", d.targetTag)
closeIdx := strings.LastIndex(baseArch[targetStart:targetEnd], closingTag)
if closeIdx >= 0 {
insertPos := targetStart + closeIdx
return baseArch[:insertPos] + "\n" + d.content + "\n" + baseArch[insertPos:]
}
// Self-closing element — convert to open/close with content
selfClose := strings.LastIndex(baseArch[targetStart:targetEnd], "/>")
if selfClose >= 0 {
insertPos := targetStart + selfClose
return baseArch[:insertPos] + ">\n" + d.content + "\n" + fmt.Sprintf("</%s>", d.targetTag) + baseArch[insertPos+2:]
}
return baseArch
case "attributes":
// Modify attributes of the target element — not implemented yet
return baseArch
default:
return baseArch
}
}
// findElement finds a target element in the XML string by tag name and optional attribute match.
// Returns (startIndex, endIndex) of the full element (including closing tag if present).
// Returns (-1, -1) if not found.
func findElement(xmlStr, tag, attrName, attrVal string) (int, int) {
searchFrom := 0
for searchFrom < len(xmlStr) {
// Find next occurrence of <tag
tagOpen := "<" + tag
idx := strings.Index(xmlStr[searchFrom:], tagOpen)
if idx < 0 {
return -1, -1
}
idx += searchFrom
// Ensure this is actually a tag start (not e.g. <fields inside <fieldset)
afterTag := idx + len(tagOpen)
if afterTag < len(xmlStr) {
ch := xmlStr[afterTag]
if ch != ' ' && ch != '/' && ch != '>' && ch != '\n' && ch != '\t' && ch != '\r' {
searchFrom = afterTag
continue
}
}
// Find end of opening tag
openEnd := strings.Index(xmlStr[idx:], ">")
if openEnd < 0 {
return -1, -1
}
openEnd += idx
openingTag := xmlStr[idx : openEnd+1]
// Check attribute match if required
if attrName != "" && attrVal != "" {
if !matchAttribute(openingTag, attrName, attrVal) {
searchFrom = openEnd + 1
continue
}
}
// Check if self-closing
if strings.HasSuffix(strings.TrimSpace(openingTag), "/>") {
return idx, openEnd + 1
}
// Find matching closing tag
closeTag := fmt.Sprintf("</%s>", tag)
// Simple approach: find the next closing tag at the same nesting level
closeIdx := findMatchingClose(xmlStr[openEnd+1:], tag)
if closeIdx < 0 {
// No closing tag — treat as self-contained up to >
return idx, openEnd + 1
}
endIdx := openEnd + 1 + closeIdx + len(closeTag)
return idx, endIdx
}
return -1, -1
}
// findMatchingClose finds the matching closing tag, handling nesting.
// Returns the offset from the start of s where the closing tag begins,
// or -1 if not found.
func findMatchingClose(s, tag string) int {
openTag := "<" + tag
closeTag := "</" + tag + ">"
depth := 1
pos := 0
for pos < len(s) {
nextOpen := strings.Index(s[pos:], openTag)
nextClose := strings.Index(s[pos:], closeTag)
if nextClose < 0 {
return -1
}
// If there's a nested open before the close, increase depth
if nextOpen >= 0 && nextOpen < nextClose {
// Verify it's actually a tag start (not a prefix of another tag)
afterIdx := pos + nextOpen + len(openTag)
if afterIdx < len(s) {
ch := s[afterIdx]
if ch == ' ' || ch == '/' || ch == '>' || ch == '\n' || ch == '\t' || ch == '\r' {
depth++
}
}
pos += nextOpen + len(openTag)
continue
}
depth--
if depth == 0 {
return pos + nextClose
}
pos += nextClose + len(closeTag)
}
return -1
}
// matchAttribute checks if an opening tag string contains attr="val" or attr='val'.
func matchAttribute(openingTag, attrName, attrValue string) bool {
// Try both quote styles
pattern1 := fmt.Sprintf(`%s="%s"`, attrName, attrValue)
pattern2 := fmt.Sprintf(`%s='%s'`, attrName, attrValue)
return strings.Contains(openingTag, pattern1) || strings.Contains(openingTag, pattern2)
}