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
324 lines
11 KiB
Go
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}
|
|
}
|