PDF reports + View inheritance + Pivot/Graph views
PDF Reports: - /report/pdf/ endpoint with graceful degradation chain: wkhtmltopdf → headless Chrome/Chromium → HTML with window.print() - Print-friendly CSS (@page A4, margins, page-break rules) - ir.actions.report dispatch for client Print button View Inheritance: - loadViewArch now loads base + inheriting views (inherit_id) - applyViewInheritance with XPath support: //field[@name='X'] and <field name="X" position="..."> - Positions: after, before, replace, inside - XML parser for inherit directives using encoding/xml New View Types: - Pivot view auto-generation (numeric fields as measures) - Graph view auto-generation (M2O/Selection + numeric measure) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
250
TODO.md
250
TODO.md
@@ -1,250 +0,0 @@
|
|||||||
# odoo-go TODO — Weg zur 1:1 Paritaet mit Python Odoo
|
|
||||||
|
|
||||||
Stand: 2026-04-02 | Go: 19.925 LOC | Python Referenz: 92.733 LOC (core) + Addons
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Legende
|
|
||||||
- [ ] Offen
|
|
||||||
- [x] Erledigt
|
|
||||||
- ~70% = UI/Demo funktioniert, ~25% = Code-Tiefe vs Python
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. ORM-Kern (aktuell ~75%)
|
|
||||||
|
|
||||||
### Erledigt
|
|
||||||
- [x] CRUD (create/read/write/unlink)
|
|
||||||
- [x] Domain-Compiler (AND/OR/NOT, child_of, parent_of, any, dot-notation JOINs)
|
|
||||||
- [x] read_group mit Aggregation (sum/avg/min/max/count, Date-Granularitaet)
|
|
||||||
- [x] Computed Fields mit Dependency-Tracking
|
|
||||||
- [x] _inherits (Delegation: res.users→res.partner, product.product→product.template)
|
|
||||||
- [x] Related Fields (Read + Write-back)
|
|
||||||
- [x] Record Rules (domain_force parsing, global AND + group OR)
|
|
||||||
- [x] Copy/Duplicate mit IsCopyable
|
|
||||||
- [x] Constraints in Create + Write
|
|
||||||
- [x] Readonly-Skip in Write
|
|
||||||
- [x] active_test Auto-Filter in Search
|
|
||||||
- [x] Onchange mit Compute-Triggering
|
|
||||||
- [x] sanitizeFieldValue (false→nil fuer non-boolean)
|
|
||||||
- [x] name_search / name_create
|
|
||||||
|
|
||||||
### Offen
|
|
||||||
- [ ] _inherit (Model-Extension/Mixin) — aktuell nur neue Models, kein Erweitern bestehender
|
|
||||||
- [ ] Inverse Fields (Compute write-back via benannte Methode)
|
|
||||||
- [ ] Multi-hop Related Fields (aktuell nur single-hop "fk.field")
|
|
||||||
- [ ] Batch-Create (Liste von Values statt einzeln)
|
|
||||||
- [ ] Prefetch/Lazy Loading (aktuell alles auf einmal)
|
|
||||||
- [x] SQL Constraints (UNIQUE, CHECK)
|
|
||||||
- [ ] Field-Level Groups (Feld-Sichtbarkeit nach Gruppen)
|
|
||||||
- [x] Translatable Fields (translate=True → ir.translation Lookup)
|
|
||||||
- [ ] Properties Field (JSONB key-value)
|
|
||||||
- [ ] parent_path (Hierarchie-Optimierung)
|
|
||||||
- [ ] Flush/Recompute Ordering (Python hat _recompute_todo Queue)
|
|
||||||
- [x] Environment.ref() vollstaendig
|
|
||||||
- [ ] Recordset Arithmetic (+ - & | Operatoren vollstaendig)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Web/RPC Server (aktuell ~80%)
|
|
||||||
|
|
||||||
### Erledigt
|
|
||||||
- [x] JSON-RPC Dispatch (/web/dataset/call_kw, call_button)
|
|
||||||
- [x] web_search_read / web_read / web_save
|
|
||||||
- [x] web_read_group / read_group
|
|
||||||
- [x] O2M Child Records in web_read Response
|
|
||||||
- [x] get_views mit Comodel-Metadaten
|
|
||||||
- [x] Onchange RPC
|
|
||||||
- [x] Action Loading (/web/action/load)
|
|
||||||
- [x] Menu Loading (/web/webclient/load_menus)
|
|
||||||
- [x] Session Management (PostgreSQL-persistent)
|
|
||||||
- [x] File Upload/Download (ir.attachment)
|
|
||||||
- [x] CSV Export (/web/export/csv)
|
|
||||||
- [x] HTML Reports (/report/html/)
|
|
||||||
- [x] Translations Endpoint
|
|
||||||
- [x] call_button mit Action-Return
|
|
||||||
- [x] get_formview_action / name_create / ir.http stub
|
|
||||||
|
|
||||||
### Offen
|
|
||||||
- [ ] PDF Reports (wkhtmltopdf oder Go-PDF-Library)
|
|
||||||
- [ ] QWeb Template Engine (Report-Templates wie Python)
|
|
||||||
- [ ] /web/dataset/export (XLSX Export)
|
|
||||||
- [ ] /web/action/run (Server Actions ausfuehren)
|
|
||||||
- [ ] /web/view/edit_custom (Custom View Saves)
|
|
||||||
- [ ] /web/model/get_definitions
|
|
||||||
- [ ] Kanban auto_unfold (Gruppen standardmaessig offen mit Records)
|
|
||||||
- [ ] Binary Field Serving (Bilder in Form-Views)
|
|
||||||
- [ ] Websocket/Longpoll (Bus fuer Live-Updates)
|
|
||||||
- [ ] Mail-Integration (SMTP senden)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Views/UI (aktuell ~70%)
|
|
||||||
|
|
||||||
### Erledigt
|
|
||||||
- [x] List View (auto-generiert + stored)
|
|
||||||
- [x] Form View (auto-generiert + stored, mit Header-Buttons)
|
|
||||||
- [x] Kanban View (auto-generiert + stored)
|
|
||||||
- [x] Search View (auto-generiert mit Filters/Group-By)
|
|
||||||
- [x] Stored Views aus DB (14 Views)
|
|
||||||
- [x] Auto-generierte Views als Fallback
|
|
||||||
- [x] O2M Inline-Listen in Form-Views (editable)
|
|
||||||
- [x] Statusbar Widget mit clickable States
|
|
||||||
|
|
||||||
### Offen
|
|
||||||
- [ ] View Inheritance (XPath-basiert, inherit_id)
|
|
||||||
- [ ] Pivot View
|
|
||||||
- [ ] Graph View
|
|
||||||
- [ ] Calendar View
|
|
||||||
- [ ] Gantt View
|
|
||||||
- [ ] Activity View
|
|
||||||
- [ ] Map View
|
|
||||||
- [ ] View Customization (ir.ui.view.custom)
|
|
||||||
- [ ] Dynamic Visibility (invisible/readonly Conditions in XML)
|
|
||||||
- [ ] Widget-System (many2one_avatar, priority, statusbar etc.)
|
|
||||||
- [ ] Chatter/Mail-Thread in Form-Views
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Business-Module (aktuell ~25% Code-Tiefe)
|
|
||||||
|
|
||||||
### Account (1.072 / 40.910 LOC = 2.6%)
|
|
||||||
- [x] account.move (Invoice CRUD, action_post, button_cancel)
|
|
||||||
- [x] account.move.line (Buchungszeilen)
|
|
||||||
- [x] account.journal, account.account, account.tax
|
|
||||||
- [x] account.payment + payment.register Wizard
|
|
||||||
- [ ] Reconciliation (Payment↔Invoice Matching) — **KRITISCH**
|
|
||||||
- [ ] Bank Statements (Import, Matching)
|
|
||||||
- [ ] Tax Reports (Umsatzsteuervoranmeldung)
|
|
||||||
- [ ] Asset Management
|
|
||||||
- [ ] Aged Receivable/Payable Reports
|
|
||||||
- [ ] Fiscal Year / Period Management
|
|
||||||
- [ ] Payment Provider Integration
|
|
||||||
- [ ] Chart of Accounts Import (volle l10n_de)
|
|
||||||
|
|
||||||
### Sale (641 / 8.009 LOC = 8%)
|
|
||||||
- [x] sale.order + sale.order.line (CRUD, Confirm, Create Invoice)
|
|
||||||
- [x] Computed Amounts (untaxed, tax, total, line subtotal)
|
|
||||||
- [ ] Sale Order Line Tax-Berechnung (tax_id → tax lines)
|
|
||||||
- [ ] Pricelist-Support (Rabatte, Staffelpreise)
|
|
||||||
- [ ] Quotation Templates
|
|
||||||
- [ ] Sale Order Cancellation + Refund
|
|
||||||
- [ ] Delivery Integration (Lieferschein-Erstellung korrekt)
|
|
||||||
- [ ] Margin-Berechnung
|
|
||||||
- [ ] Sale Reports (Revenue, Pipeline)
|
|
||||||
|
|
||||||
### Stock (588 / 17.672 LOC = 3.3%)
|
|
||||||
- [x] stock.picking, stock.move, stock.location, stock.warehouse
|
|
||||||
- [x] stock.quant (Basic)
|
|
||||||
- [ ] Quant-Management (Reservation, Unreserve) — **KRITISCH**
|
|
||||||
- [ ] Stock Valuation (FIFO, Average Cost)
|
|
||||||
- [ ] Reorder Rules (stock.warehouse.orderpoint)
|
|
||||||
- [ ] Barcode/Lot Tracking
|
|
||||||
- [ ] Inventory Adjustments
|
|
||||||
- [ ] Stock Reports (Valuation, Moves)
|
|
||||||
- [ ] Multi-Warehouse Routing
|
|
||||||
- [ ] Package Management
|
|
||||||
|
|
||||||
### Purchase (350 / 3.977 LOC = 8.8%)
|
|
||||||
- [x] purchase.order + purchase.order.line (CRUD, Confirm)
|
|
||||||
- [ ] 3-Way Matching (PO → Receipt → Bill)
|
|
||||||
- [ ] Purchase Agreements
|
|
||||||
- [ ] Vendor Bills aus PO erstellen
|
|
||||||
- [ ] Purchase Reports
|
|
||||||
|
|
||||||
### CRM (300 / 5.019 LOC = 6%)
|
|
||||||
- [x] crm.lead + crm.stage + crm.lost.reason
|
|
||||||
- [x] Pipeline Kanban mit Stages
|
|
||||||
- [x] Won/Lost Actions
|
|
||||||
- [ ] Lead Scoring / Probability
|
|
||||||
- [ ] Email Integration (Lead aus Email)
|
|
||||||
- [ ] Activities / Planned Actions
|
|
||||||
- [ ] Lead Assignment Rules
|
|
||||||
- [ ] CRM Reports (Pipeline, Revenue)
|
|
||||||
|
|
||||||
### HR (200 / 4.609 LOC = 4.3%)
|
|
||||||
- [x] hr.employee, hr.department, hr.job
|
|
||||||
- [ ] Contracts (hr.contract)
|
|
||||||
- [ ] Leave Management (hr.leave)
|
|
||||||
- [ ] Attendance Tracking
|
|
||||||
- [ ] Payroll Integration
|
|
||||||
- [ ] Employee Documents
|
|
||||||
|
|
||||||
### Project (200 / 6.730 LOC = 3%)
|
|
||||||
- [x] project.project, project.task
|
|
||||||
- [ ] Task Stages / Kanban
|
|
||||||
- [ ] Timesheet Integration
|
|
||||||
- [ ] Project Updates / Milestones
|
|
||||||
- [ ] Burndown Charts
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. System/Admin (aktuell ~68%)
|
|
||||||
|
|
||||||
### Erledigt
|
|
||||||
- [x] Settings-Seite (res.config.settings mit Firmen-Fields)
|
|
||||||
- [x] Users/Companies/Groups Verwaltung
|
|
||||||
- [x] Technical Menu (Views, Actions, Parameters, Security, Logging)
|
|
||||||
- [x] Session-Persistenz (PostgreSQL)
|
|
||||||
- [x] ir.config_parameter (get_param/set_param)
|
|
||||||
- [x] ir.cron Model (registriert)
|
|
||||||
- [x] ir.logging Model
|
|
||||||
- [x] res.lang + ir.translation Models
|
|
||||||
- [x] Password Hashing (bcrypt)
|
|
||||||
- [x] change_password Methode
|
|
||||||
|
|
||||||
### Offen
|
|
||||||
- [ ] ir.cron Runner (Goroutine Scheduler der Jobs ausfuehrt)
|
|
||||||
- [ ] Mail Server (ir.mail_server, SMTP Config)
|
|
||||||
- [ ] Authentik SSO / OAuth2 (geplant, siehe memory)
|
|
||||||
- [ ] API Key Auth (Token-basierter Zugriff)
|
|
||||||
- [ ] Module Install/Upgrade Lifecycle
|
|
||||||
- [ ] XML Data Loading (aus Addon data/ Ordnern)
|
|
||||||
- [ ] User Audit Log (wer hat was geaendert)
|
|
||||||
- [ ] Database Backup/Restore
|
|
||||||
- [ ] Multi-Database Support
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Infrastruktur
|
|
||||||
|
|
||||||
### Erledigt
|
|
||||||
- [x] Docker Compose (Go Server + PostgreSQL)
|
|
||||||
- [x] OWL 19.0 Frontend eingebettet
|
|
||||||
- [x] JS Transpiler (ES6→odoo.define)
|
|
||||||
- [x] XML Template Compiler
|
|
||||||
- [x] CSS Bundling
|
|
||||||
- [x] Database Manager (/web/database/manager)
|
|
||||||
|
|
||||||
### Offen
|
|
||||||
- [ ] Tests (aktuell 0 Go-Tests fuer ORM)
|
|
||||||
- [ ] CI/CD Pipeline
|
|
||||||
- [ ] Migrations (Schema-Upgrades bei Model-Aenderungen)
|
|
||||||
- [ ] Performance-Benchmarks
|
|
||||||
- [ ] Logging Framework (strukturiert, nicht nur log.Printf)
|
|
||||||
- [ ] Configuration File (statt nur Env-Vars)
|
|
||||||
- [x] Odoo.com URLs entfernt (alle 5 Stellen)
|
|
||||||
- [ ] Health-Check erweitern (DB + Dependencies)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Priorisierung fuer naechste Sessions
|
|
||||||
|
|
||||||
### Prio 1 — Blockiert echte Nutzung
|
|
||||||
1. Account Reconciliation (Payment↔Invoice)
|
|
||||||
2. Stock Quant Management (Reservation)
|
|
||||||
3. View Inheritance (XPath)
|
|
||||||
4. PDF Reports (wkhtmltopdf)
|
|
||||||
|
|
||||||
### Prio 2 — Verbessert UX stark
|
|
||||||
5. Kanban auto_unfold
|
|
||||||
6. Binary Field Serving (Bilder)
|
|
||||||
7. Pivot/Graph Views
|
|
||||||
8. Dynamic Visibility in Views
|
|
||||||
|
|
||||||
### Prio 3 — Vervollstaendigung
|
|
||||||
9. ir.cron Runner
|
|
||||||
10. Mail Server Integration
|
|
||||||
11. Authentik SSO
|
|
||||||
12. Tests schreiben
|
|
||||||
13. Weitere Module vertiefen
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
// Report endpoint — serves HTML reports.
|
// Report endpoint — serves HTML and PDF reports.
|
||||||
// Mirrors: odoo/addons/web/controllers/report.py ReportController
|
// Mirrors: odoo/addons/web/controllers/report.py ReportController
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -89,6 +93,254 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Write([]byte(html))
|
w.Write([]byte(html))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleReportPDF serves PDF reports.
|
||||||
|
// Route: /report/pdf/<report_name>/<ids>
|
||||||
|
// Mirrors: odoo/addons/web/controllers/report.py report_download()
|
||||||
|
//
|
||||||
|
// It generates the HTML report and converts it to PDF via wkhtmltopdf or
|
||||||
|
// headless Chromium. If neither is available, it falls back to the HTML
|
||||||
|
// report with an auto-print dialog.
|
||||||
|
func (s *Server) handleReportPDF(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Parse URL: /report/pdf/account.report_invoice/1,2,3
|
||||||
|
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/")
|
||||||
|
// Expected: ["report", "pdf", "<report_name>", "<ids>"]
|
||||||
|
if len(parts) < 4 {
|
||||||
|
http.Error(w, "Invalid report URL. Expected: /report/pdf/<report_name>/<ids>", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reportName := parts[2]
|
||||||
|
idsStr := parts[3]
|
||||||
|
|
||||||
|
// Parse record IDs
|
||||||
|
var ids []int64
|
||||||
|
for _, s := range strings.Split(idsStr, ",") {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(s, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Invalid ID %q: %v", s, err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ids) == 0 {
|
||||||
|
http.Error(w, "No record IDs provided", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get session
|
||||||
|
uid := int64(1)
|
||||||
|
companyID := int64(1)
|
||||||
|
if sess := GetSession(r); sess != nil {
|
||||||
|
uid = sess.UID
|
||||||
|
companyID = sess.CompanyID
|
||||||
|
}
|
||||||
|
|
||||||
|
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
|
||||||
|
Pool: s.pool, UID: uid, CompanyID: companyID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer env.Close()
|
||||||
|
|
||||||
|
// Determine model from report name
|
||||||
|
modelName := resolveReportModel(reportName)
|
||||||
|
if modelName == "" {
|
||||||
|
http.Error(w, "Unknown report: "+reportName, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read records
|
||||||
|
rs := env.Model(modelName).Browse(ids...)
|
||||||
|
records, err := rs.Read(nil)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := env.Commit(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate HTML report
|
||||||
|
htmlContent := renderHTMLReport(reportName, modelName, records)
|
||||||
|
|
||||||
|
// Try to convert to PDF
|
||||||
|
pdfData, err := htmlToPDF(htmlContent)
|
||||||
|
if err != nil {
|
||||||
|
// Fallback: serve HTML with auto-print dialog
|
||||||
|
log.Printf("report: PDF conversion unavailable (%v), falling back to HTML", err)
|
||||||
|
htmlFallback := renderHTMLReportPrintFallback(reportName, modelName, records)
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write([]byte(htmlFallback))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/pdf")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%s.pdf", reportName))
|
||||||
|
w.Write(pdfData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// htmlToPDF converts an HTML string to PDF. It tries wkhtmltopdf first,
|
||||||
|
// then headless Chromium. Returns an error if neither is available.
|
||||||
|
func htmlToPDF(html string) ([]byte, error) {
|
||||||
|
// Try wkhtmltopdf first (most common in Odoo deployments)
|
||||||
|
if path, err := exec.LookPath("wkhtmltopdf"); err == nil {
|
||||||
|
cmd := exec.Command(path, "--quiet", "--print-media-type",
|
||||||
|
"--page-size", "A4",
|
||||||
|
"--margin-top", "10mm", "--margin-bottom", "10mm",
|
||||||
|
"--margin-left", "10mm", "--margin-right", "10mm",
|
||||||
|
"-", "-") // stdin → stdout
|
||||||
|
cmd.Stdin = strings.NewReader(html)
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
log.Printf("report: wkhtmltopdf failed: %v – %s", err, stderr.String())
|
||||||
|
} else {
|
||||||
|
return stdout.Bytes(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try headless Chromium / Chrome
|
||||||
|
return htmlToPDFChromium(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
// htmlToPDFChromium converts HTML to PDF using headless Chrome/Chromium.
|
||||||
|
func htmlToPDFChromium(html string) ([]byte, error) {
|
||||||
|
// Find browser binary
|
||||||
|
var browserPath string
|
||||||
|
for _, name := range []string{"chromium", "chromium-browser", "google-chrome", "chrome"} {
|
||||||
|
if p, err := exec.LookPath(name); err == nil {
|
||||||
|
browserPath = p
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if browserPath == "" {
|
||||||
|
return nil, fmt.Errorf("no PDF converter available (tried wkhtmltopdf, chromium, chrome)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write HTML to temp file
|
||||||
|
tmpFile, err := os.CreateTemp("", "report-*.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating temp file: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpFile.Name())
|
||||||
|
|
||||||
|
if _, err := tmpFile.WriteString(html); err != nil {
|
||||||
|
tmpFile.Close()
|
||||||
|
return nil, fmt.Errorf("writing temp file: %w", err)
|
||||||
|
}
|
||||||
|
tmpFile.Close()
|
||||||
|
|
||||||
|
pdfFile := tmpFile.Name() + ".pdf"
|
||||||
|
defer os.Remove(pdfFile)
|
||||||
|
|
||||||
|
cmd := exec.Command(browserPath, "--headless", "--disable-gpu", "--no-sandbox",
|
||||||
|
"--print-to-pdf="+pdfFile, "file://"+tmpFile.Name())
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("chromium PDF generation failed: %v – %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(pdfFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading PDF output: %w", err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderHTMLReportPrintFallback generates an HTML report with enhanced print
|
||||||
|
// CSS and an auto-trigger for the browser print dialog. Used as fallback when
|
||||||
|
// no PDF converter (wkhtmltopdf, chromium) is installed.
|
||||||
|
func renderHTMLReportPrintFallback(reportName, modelName string, records []orm.Values) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<title>Report: `)
|
||||||
|
b.WriteString(htmlEscape(reportName))
|
||||||
|
b.WriteString(`</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 40px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||||
|
th { background: #f5f5f5; font-weight: bold; }
|
||||||
|
h1 { color: #333; }
|
||||||
|
h2 { color: #555; margin-top: 30px; }
|
||||||
|
.header { border-bottom: 2px solid #875A7B; padding-bottom: 10px; margin-bottom: 20px; }
|
||||||
|
.report-info { color: #888; font-size: 0.9em; margin-bottom: 30px; }
|
||||||
|
.record-section { page-break-after: always; margin-bottom: 40px; }
|
||||||
|
.record-section:last-child { page-break-after: avoid; }
|
||||||
|
.no-print { display: none; }
|
||||||
|
@media print {
|
||||||
|
body { margin: 0; }
|
||||||
|
.no-print { display: none; }
|
||||||
|
table { page-break-inside: auto; }
|
||||||
|
tr { page-break-inside: avoid; }
|
||||||
|
}
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 15mm;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>`)
|
||||||
|
b.WriteString(htmlEscape(reportName))
|
||||||
|
b.WriteString(`</h1>
|
||||||
|
<div class="report-info">Model: `)
|
||||||
|
b.WriteString(htmlEscape(modelName))
|
||||||
|
b.WriteString(fmt.Sprintf(` | Records: %d`, len(records)))
|
||||||
|
b.WriteString(`</div>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
|
||||||
|
for _, rec := range records {
|
||||||
|
b.WriteString(`<div class="record-section">`)
|
||||||
|
title := ""
|
||||||
|
if v, ok := rec["display_name"]; ok {
|
||||||
|
title = fmt.Sprintf("%v", v)
|
||||||
|
} else if v, ok := rec["name"]; ok {
|
||||||
|
title = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
if title != "" {
|
||||||
|
b.WriteString(fmt.Sprintf("<h2>%s</h2>", htmlEscape(title)))
|
||||||
|
}
|
||||||
|
b.WriteString("<table>")
|
||||||
|
b.WriteString("<tr><th>Field</th><th>Value</th></tr>")
|
||||||
|
for key, val := range rec {
|
||||||
|
if key == "id" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
valStr := fmt.Sprintf("%v", val)
|
||||||
|
if valStr == "<nil>" {
|
||||||
|
valStr = ""
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("<tr><td>%s</td><td>%s</td></tr>",
|
||||||
|
htmlEscape(key), htmlEscape(valStr)))
|
||||||
|
}
|
||||||
|
b.WriteString("</table>")
|
||||||
|
b.WriteString("</div>")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(`<script>window.addEventListener('load', function() { window.print(); });</script>
|
||||||
|
</body></html>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
// resolveReportModel maps a report name to the ORM model it operates on.
|
// resolveReportModel maps a report name to the ORM model it operates on.
|
||||||
// Mirrors: odoo ir.actions.report → model field.
|
// Mirrors: odoo ir.actions.report → model field.
|
||||||
func resolveReportModel(reportName string) string {
|
func resolveReportModel(reportName string) string {
|
||||||
@@ -125,8 +377,14 @@ h2 { color: #555; margin-top: 30px; }
|
|||||||
.record-section { page-break-after: always; margin-bottom: 40px; }
|
.record-section { page-break-after: always; margin-bottom: 40px; }
|
||||||
.record-section:last-child { page-break-after: avoid; }
|
.record-section:last-child { page-break-after: avoid; }
|
||||||
@media print {
|
@media print {
|
||||||
body { margin: 20px; }
|
body { margin: 0; }
|
||||||
.no-print { display: none; }
|
.no-print { display: none; }
|
||||||
|
table { page-break-inside: auto; }
|
||||||
|
tr { page-break-inside: avoid; }
|
||||||
|
}
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 15mm;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -129,9 +129,10 @@ func (s *Server) registerRoutes() {
|
|||||||
// CSV export
|
// CSV export
|
||||||
s.mux.HandleFunc("/web/export/csv", s.handleExportCSV)
|
s.mux.HandleFunc("/web/export/csv", s.handleExportCSV)
|
||||||
|
|
||||||
// Reports (HTML report rendering)
|
// Reports (HTML and PDF report rendering)
|
||||||
s.mux.HandleFunc("/report/", s.handleReport)
|
s.mux.HandleFunc("/report/", s.handleReport)
|
||||||
s.mux.HandleFunc("/report/html/", s.handleReport)
|
s.mux.HandleFunc("/report/html/", s.handleReport)
|
||||||
|
s.mux.HandleFunc("/report/pdf/", s.handleReportPDF)
|
||||||
|
|
||||||
// Logout & Account
|
// Logout & Account
|
||||||
s.mux.HandleFunc("/web/session/logout", s.handleLogout)
|
s.mux.HandleFunc("/web/session/logout", s.handleLogout)
|
||||||
@@ -352,6 +353,19 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle ir.actions.report RPC calls (e.g., Print button on invoices).
|
||||||
|
// Mirrors: odoo/addons/base/models/ir_actions_report.py IrActionsReport.report_action()
|
||||||
|
if params.Model == "ir.actions.report" && params.Method == "report_action" {
|
||||||
|
if len(params.Args) > 0 {
|
||||||
|
reportName, _ := params.Args[0].(string)
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "ir.actions.report",
|
||||||
|
"report_name": reportName,
|
||||||
|
"report_type": "qweb-html",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If model is "ir.http", handle special routing methods
|
// If model is "ir.http", handle special routing methods
|
||||||
if params.Model == "ir.http" {
|
if params.Model == "ir.http" {
|
||||||
switch params.Method {
|
switch params.Method {
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"odoo-go/pkg/orm"
|
"odoo-go/pkg/orm"
|
||||||
@@ -134,15 +136,42 @@ func handleGetViews(env *orm.Environment, model string, params CallKWParams) (in
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadViewArch tries to load a view from the ir_ui_view table.
|
// 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 {
|
func loadViewArch(env *orm.Environment, model, viewType string) string {
|
||||||
|
// Load base view (no parent — inherit_id IS NULL)
|
||||||
var arch string
|
var arch string
|
||||||
|
var viewID int64
|
||||||
err := env.Tx().QueryRow(env.Ctx(),
|
err := env.Tx().QueryRow(env.Ctx(),
|
||||||
`SELECT arch FROM ir_ui_view WHERE model = $1 AND type = $2 AND active = true ORDER BY priority LIMIT 1`,
|
`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,
|
model, viewType,
|
||||||
).Scan(&arch)
|
).Scan(&viewID, &arch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
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
|
return arch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +191,10 @@ func generateDefaultView(modelName, viewType string) string {
|
|||||||
return generateDefaultSearchView(m)
|
return generateDefaultSearchView(m)
|
||||||
case "kanban":
|
case "kanban":
|
||||||
return generateDefaultKanbanView(m)
|
return generateDefaultKanbanView(m)
|
||||||
|
case "pivot":
|
||||||
|
return generateDefaultPivotView(m)
|
||||||
|
case "graph":
|
||||||
|
return generateDefaultGraphView(m)
|
||||||
default:
|
default:
|
||||||
return fmt.Sprintf("<%s><field name=\"id\"/></%s>", viewType, viewType)
|
return fmt.Sprintf("<%s><field name=\"id\"/></%s>", viewType, viewType)
|
||||||
}
|
}
|
||||||
@@ -442,3 +475,418 @@ func generateDefaultKanbanView(m *orm.Model) string {
|
|||||||
</templates>
|
</templates>
|
||||||
</kanban>`, strings.Join(cardFields, "\n"))
|
</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 "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user