Files
silo/internal/ods/writer.go
Forbes afb382b68d feat: LibreOffice Calc extension, ODS library, AI description, audit design
Calc extension (pkg/calc/):
- Python UNO ProtocolHandler with 8 toolbar commands
- SiloClient HTTP client adapted from FreeCAD workbench
- Pull BOM/Project: populates sheets with 28-col format, hidden property
  columns, row hash tracking, auto project tagging
- Push: row classification, create/update items, conflict detection
- Completion wizard: 3-step category/description/fields with PN conflict
  resolution dialog
- OpenRouter AI integration: generate standardized descriptions from seller
  text, configurable model/instructions, review dialog
- Settings: JSON persistence, env var fallbacks, OpenRouter fields
- 31 unit tests (no UNO/network required)

Go ODS library (internal/ods/):
- Pure Go ODS read/write (ZIP of XML, no headless LibreOffice)
- Writer, reader, 10 round-trip tests

Server ODS endpoints (internal/api/ods.go):
- GET /api/items/export.ods, template.ods, POST import.ods
- GET /api/items/{pn}/bom/export.ods
- GET /api/projects/{code}/sheet.ods
- POST /api/sheets/diff

Documentation:
- docs/CALC_EXTENSION.md: extension progress report
- docs/COMPONENT_AUDIT.md: web audit tool design with weighted scoring,
  assembly computed fields, batch AI assistance plan
2026-02-01 10:06:20 -06:00

324 lines
11 KiB
Go

package ods
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"strconv"
"strings"
"time"
)
// Write produces an ODS file as []byte from a Workbook.
func Write(wb *Workbook) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
// mimetype MUST be first entry, stored (not compressed)
mimeHeader := &zip.FileHeader{
Name: "mimetype",
Method: zip.Store,
}
mw, err := zw.CreateHeader(mimeHeader)
if err != nil {
return nil, fmt.Errorf("create mimetype: %w", err)
}
if _, err := mw.Write([]byte("application/vnd.oasis.opendocument.spreadsheet")); err != nil {
return nil, fmt.Errorf("write mimetype: %w", err)
}
// META-INF/manifest.xml
if err := writeManifest(zw); err != nil {
return nil, err
}
// meta.xml
if err := writeMeta(zw, wb.Meta); err != nil {
return nil, err
}
// styles.xml
if err := writeStyles(zw); err != nil {
return nil, err
}
// content.xml
if err := writeContent(zw, wb.Sheets); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("close zip: %w", err)
}
return buf.Bytes(), nil
}
// writeManifest writes META-INF/manifest.xml.
func writeManifest(zw *zip.Writer) error {
w, err := zw.Create("META-INF/manifest.xml")
if err != nil {
return fmt.Errorf("create manifest: %w", err)
}
const manifest = xml.Header + `<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">
<manifest:file-entry manifest:media-type="application/vnd.oasis.opendocument.spreadsheet" manifest:version="1.2" manifest:full-path="/"/>
<manifest:file-entry manifest:media-type="text/xml" manifest:full-path="content.xml"/>
<manifest:file-entry manifest:media-type="text/xml" manifest:full-path="styles.xml"/>
<manifest:file-entry manifest:media-type="text/xml" manifest:full-path="meta.xml"/>
</manifest:manifest>`
_, err = w.Write([]byte(manifest))
return err
}
// writeMeta writes meta.xml with custom properties.
func writeMeta(zw *zip.Writer, meta map[string]string) error {
w, err := zw.Create("meta.xml")
if err != nil {
return fmt.Errorf("create meta.xml: %w", err)
}
var b strings.Builder
b.WriteString(xml.Header)
b.WriteString(`<office:document-meta xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"`)
b.WriteString(` xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0"`)
b.WriteString(` office:version="1.2">`)
b.WriteString(`<office:meta>`)
b.WriteString(`<meta:generator>Silo</meta:generator>`)
b.WriteString(fmt.Sprintf(`<meta:creation-date>%s</meta:creation-date>`, time.Now().UTC().Format(time.RFC3339)))
if len(meta) > 0 {
b.WriteString(`<meta:user-defined meta:name="_silo_meta" meta:value-type="string">`)
// Encode all meta as key=value pairs separated by newlines
var pairs []string
for k, v := range meta {
pairs = append(pairs, xmlEscape(k)+"="+xmlEscape(v))
}
b.WriteString(xmlEscape(strings.Join(pairs, "\n")))
b.WriteString(`</meta:user-defined>`)
}
b.WriteString(`</office:meta>`)
b.WriteString(`</office:document-meta>`)
_, err = w.Write([]byte(b.String()))
return err
}
// writeStyles writes styles.xml with header, currency, and hidden column styles.
func writeStyles(zw *zip.Writer) error {
w, err := zw.Create("styles.xml")
if err != nil {
return fmt.Errorf("create styles.xml: %w", err)
}
const styles = xml.Header + `<office:document-styles
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
office:version="1.2">
</office:document-styles>`
_, err = w.Write([]byte(styles))
return err
}
// writeContent writes content.xml containing all sheet data.
func writeContent(zw *zip.Writer, sheets []Sheet) error {
w, err := zw.Create("content.xml")
if err != nil {
return fmt.Errorf("create content.xml: %w", err)
}
var b strings.Builder
b.WriteString(xml.Header)
b.WriteString(`<office:document-content`)
b.WriteString(` xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"`)
b.WriteString(` xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"`)
b.WriteString(` xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"`)
b.WriteString(` xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"`)
b.WriteString(` xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"`)
b.WriteString(` xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0"`)
b.WriteString(` xmlns:of="urn:oasis:names:tc:opendocument:xmlns:of:1.2"`)
b.WriteString(` office:version="1.2">`)
// Automatic styles (defined in content.xml for cell/column styles)
b.WriteString(`<office:automatic-styles>`)
// Currency data style
b.WriteString(`<number:currency-style style:name="N-USD" number:language="en" number:country="US">`)
b.WriteString(`<number:currency-symbol number:language="en" number:country="US">$</number:currency-symbol>`)
b.WriteString(`<number:number number:decimal-places="2" number:min-decimal-places="2" number:min-integer-digits="1" number:grouping="true"/>`)
b.WriteString(`</number:currency-style>`)
// Header cell style (bold)
b.WriteString(`<style:style style:name="ce-header" style:family="table-cell">`)
b.WriteString(`<style:text-properties fo:font-weight="bold"/>`)
b.WriteString(`<style:table-cell-properties fo:background-color="#d9e2f3"/>`)
b.WriteString(`</style:style>`)
// Currency cell style
b.WriteString(`<style:style style:name="ce-currency" style:family="table-cell" style:data-style-name="N-USD">`)
b.WriteString(`</style:style>`)
// Row status colors
writeColorStyle(&b, "ce-synced", "#c6efce")
writeColorStyle(&b, "ce-modified", "#ffeb9c")
writeColorStyle(&b, "ce-new", "#bdd7ee")
writeColorStyle(&b, "ce-error", "#ffc7ce")
writeColorStyle(&b, "ce-conflict", "#f4b084")
// Column styles
b.WriteString(`<style:style style:name="co-default" style:family="table-column">`)
b.WriteString(`<style:table-column-properties style:column-width="2.5cm"/>`)
b.WriteString(`</style:style>`)
b.WriteString(`<style:style style:name="co-wide" style:family="table-column">`)
b.WriteString(`<style:table-column-properties style:column-width="5cm"/>`)
b.WriteString(`</style:style>`)
b.WriteString(`<style:style style:name="co-hidden" style:family="table-column">`)
b.WriteString(`<style:table-column-properties style:column-width="2.5cm"/>`)
b.WriteString(`</style:style>`)
b.WriteString(`</office:automatic-styles>`)
// Body
b.WriteString(`<office:body>`)
b.WriteString(`<office:spreadsheet>`)
for _, sheet := range sheets {
writeSheet(&b, &sheet)
}
b.WriteString(`</office:spreadsheet>`)
b.WriteString(`</office:body>`)
b.WriteString(`</office:document-content>`)
_, err = w.Write([]byte(b.String()))
return err
}
func writeColorStyle(b *strings.Builder, name, color string) {
b.WriteString(fmt.Sprintf(`<style:style style:name="%s" style:family="table-cell">`, name))
b.WriteString(fmt.Sprintf(`<style:table-cell-properties fo:background-color="%s"/>`, color))
b.WriteString(`</style:style>`)
}
func writeSheet(b *strings.Builder, sheet *Sheet) {
b.WriteString(fmt.Sprintf(`<table:table table:name="%s">`, xmlEscape(sheet.Name)))
// Column definitions
if len(sheet.Columns) > 0 {
for _, col := range sheet.Columns {
styleName := "co-default"
if col.Width != "" {
styleName = "co-wide"
}
if col.Hidden {
b.WriteString(fmt.Sprintf(`<table:table-column table:style-name="%s" table:visibility="collapse"/>`, styleName))
} else {
b.WriteString(fmt.Sprintf(`<table:table-column table:style-name="%s"/>`, styleName))
}
}
}
// Rows
for _, row := range sheet.Rows {
if row.IsBlank {
b.WriteString(`<table:table-row>`)
b.WriteString(`<table:table-cell/>`)
b.WriteString(`</table:table-row>`)
continue
}
b.WriteString(`<table:table-row>`)
for _, cell := range row.Cells {
writeCell(b, &cell)
}
b.WriteString(`</table:table-row>`)
}
b.WriteString(`</table:table>`)
}
func writeCell(b *strings.Builder, cell *Cell) {
switch cell.Type {
case CellEmpty:
b.WriteString(`<table:table-cell/>`)
case CellFormula:
// Formula cells: the formula attribute uses the of: namespace
b.WriteString(fmt.Sprintf(`<table:table-cell table:formula="%s" office:value-type="float" table:style-name="ce-currency">`, xmlEscape(cell.Formula)))
b.WriteString(`</table:table-cell>`)
case CellFloat:
b.WriteString(fmt.Sprintf(`<table:table-cell office:value-type="float" office:value="%s">`, xmlEscape(cell.Value)))
b.WriteString(fmt.Sprintf(`<text:p>%s</text:p>`, xmlEscape(cell.Value)))
b.WriteString(`</table:table-cell>`)
case CellCurrency:
b.WriteString(fmt.Sprintf(`<table:table-cell office:value-type="currency" office:currency="USD" office:value="%s" table:style-name="ce-currency">`, xmlEscape(cell.Value)))
b.WriteString(fmt.Sprintf(`<text:p>$%s</text:p>`, xmlEscape(cell.Value)))
b.WriteString(`</table:table-cell>`)
case CellDate:
b.WriteString(fmt.Sprintf(`<table:table-cell office:value-type="date" office:date-value="%s">`, xmlEscape(cell.Value)))
b.WriteString(fmt.Sprintf(`<text:p>%s</text:p>`, xmlEscape(cell.Value)))
b.WriteString(`</table:table-cell>`)
default: // CellString
b.WriteString(`<table:table-cell office:value-type="string">`)
b.WriteString(fmt.Sprintf(`<text:p>%s</text:p>`, xmlEscape(cell.Value)))
b.WriteString(`</table:table-cell>`)
}
}
// xmlEscape escapes special XML characters.
func xmlEscape(s string) string {
var b strings.Builder
if err := xml.EscapeText(&b, []byte(s)); err != nil {
return s
}
return b.String()
}
// Helper functions for building cells
// StringCell creates a string cell.
func StringCell(value string) Cell {
return Cell{Value: value, Type: CellString}
}
// FloatCell creates a float cell.
func FloatCell(value float64) Cell {
return Cell{Value: strconv.FormatFloat(value, 'f', -1, 64), Type: CellFloat}
}
// CurrencyCell creates a currency (USD) cell.
func CurrencyCell(value float64) Cell {
return Cell{Value: fmt.Sprintf("%.2f", value), Type: CellCurrency}
}
// FormulaCell creates a formula cell.
func FormulaCell(formula string) Cell {
return Cell{Formula: formula, Type: CellFormula}
}
// EmptyCell creates an empty cell.
func EmptyCell() Cell {
return Cell{Type: CellEmpty}
}
// IntCell creates an integer cell stored as float.
func IntCell(value int) Cell {
return Cell{Value: strconv.Itoa(value), Type: CellFloat}
}
// HeaderCell creates a string cell intended for header rows.
// The header style is applied at the row level or by the caller.
func HeaderCell(value string) Cell {
return Cell{Value: value, Type: CellString}
}