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
This commit is contained in:
58
Makefile
58
Makefile
@@ -1,6 +1,7 @@
|
|||||||
.PHONY: build run test clean migrate fmt lint \
|
.PHONY: build run test clean migrate fmt lint \
|
||||||
docker-build docker-up docker-down docker-logs docker-ps \
|
docker-build docker-up docker-down docker-logs docker-ps \
|
||||||
docker-clean docker-rebuild
|
docker-clean docker-rebuild \
|
||||||
|
build-calc-oxt install-calc uninstall-calc install-calc-dev test-calc
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Local Development
|
# Local Development
|
||||||
@@ -19,13 +20,14 @@ run:
|
|||||||
cli:
|
cli:
|
||||||
go run ./cmd/silo $(ARGS)
|
go run ./cmd/silo $(ARGS)
|
||||||
|
|
||||||
# Run tests
|
# Run tests (Go + Python)
|
||||||
test:
|
test:
|
||||||
go test -v ./...
|
go test -v ./...
|
||||||
|
python3 -m unittest pkg/calc/tests/test_basics.py -v
|
||||||
|
|
||||||
# Clean build artifacts
|
# Clean build artifacts
|
||||||
clean:
|
clean:
|
||||||
rm -f silo silod
|
rm -f silo silod silo-calc.oxt
|
||||||
rm -f *.out
|
rm -f *.out
|
||||||
|
|
||||||
# Format code
|
# Format code
|
||||||
@@ -158,6 +160,48 @@ uninstall-freecad:
|
|||||||
rm -f $(FREECAD_MOD_DIR_LEGACY)/Silo
|
rm -f $(FREECAD_MOD_DIR_LEGACY)/Silo
|
||||||
@echo "Uninstalled Silo workbench"
|
@echo "Uninstalled Silo workbench"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LibreOffice Calc Extension
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Build .oxt extension package
|
||||||
|
build-calc-oxt:
|
||||||
|
@echo "Building silo-calc.oxt..."
|
||||||
|
@cd pkg/calc && zip -r ../../silo-calc.oxt . \
|
||||||
|
-x '*.pyc' '*__pycache__/*' 'tests/*' '.gitignore'
|
||||||
|
@echo "Built silo-calc.oxt"
|
||||||
|
|
||||||
|
# Install extension system-wide (requires unopkg)
|
||||||
|
install-calc: build-calc-oxt
|
||||||
|
unopkg add --shared silo-calc.oxt 2>/dev/null || unopkg add silo-calc.oxt
|
||||||
|
@echo "Installed silo-calc extension. Restart LibreOffice to load."
|
||||||
|
|
||||||
|
# Uninstall extension
|
||||||
|
uninstall-calc:
|
||||||
|
unopkg remove io.kindredsystems.silo.calc 2>/dev/null || true
|
||||||
|
@echo "Uninstalled silo-calc extension."
|
||||||
|
|
||||||
|
# Development install: symlink into user extensions dir
|
||||||
|
install-calc-dev:
|
||||||
|
@CALC_EXT_DIR="$${HOME}/.config/libreoffice/4/user/extensions"; \
|
||||||
|
if [ -d "$$CALC_EXT_DIR" ]; then \
|
||||||
|
rm -rf "$$CALC_EXT_DIR/silo-calc"; \
|
||||||
|
ln -sf $(PWD)/pkg/calc "$$CALC_EXT_DIR/silo-calc"; \
|
||||||
|
echo "Symlinked to $$CALC_EXT_DIR/silo-calc"; \
|
||||||
|
else \
|
||||||
|
echo "LibreOffice extensions dir not found at $$CALC_EXT_DIR"; \
|
||||||
|
echo "Try: install-calc (uses unopkg)"; \
|
||||||
|
fi
|
||||||
|
@echo "Restart LibreOffice to load the Silo Calc extension"
|
||||||
|
|
||||||
|
# Run Python tests for the Calc extension
|
||||||
|
test-calc:
|
||||||
|
python3 -m unittest pkg/calc/tests/test_basics.py -v
|
||||||
|
|
||||||
|
# Clean extension package
|
||||||
|
clean-calc:
|
||||||
|
rm -f silo-calc.oxt
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# API Testing
|
# API Testing
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -219,6 +263,14 @@ help:
|
|||||||
@echo " install-freecad-native - Install for native FreeCAD"
|
@echo " install-freecad-native - Install for native FreeCAD"
|
||||||
@echo " uninstall-freecad - Remove workbench symlinks"
|
@echo " uninstall-freecad - Remove workbench symlinks"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
@echo "LibreOffice Calc:"
|
||||||
|
@echo " build-calc-oxt - Build .oxt extension package"
|
||||||
|
@echo " install-calc - Install extension (uses unopkg)"
|
||||||
|
@echo " install-calc-dev - Symlink for development"
|
||||||
|
@echo " uninstall-calc - Remove extension"
|
||||||
|
@echo " test-calc - Run Python tests for extension"
|
||||||
|
@echo " clean-calc - Remove .oxt file"
|
||||||
|
@echo ""
|
||||||
@echo "API Testing:"
|
@echo "API Testing:"
|
||||||
@echo " api-health - Test health endpoint"
|
@echo " api-health - Test health endpoint"
|
||||||
@echo " api-schemas - List schemas"
|
@echo " api-schemas - List schemas"
|
||||||
|
|||||||
255
docs/CALC_EXTENSION.md
Normal file
255
docs/CALC_EXTENSION.md
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
# LibreOffice Calc Extension
|
||||||
|
|
||||||
|
**Last Updated:** 2026-02-01
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Silo Calc extension (`silo-calc.oxt`) is a LibreOffice Calc add-on that
|
||||||
|
connects project BOM spreadsheets directly to the Silo parts database.
|
||||||
|
Engineers work in their familiar spreadsheet environment while Silo handles
|
||||||
|
part number generation, revision tracking, and data synchronization.
|
||||||
|
|
||||||
|
The extension is a Python UNO component packaged as an `.oxt` file. It uses
|
||||||
|
only stdlib (`urllib`, `json`, `ssl`) -- no pip dependencies. The same
|
||||||
|
`SiloClient` pattern and auth flow from the FreeCAD workbench is reused.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Engineer's workstation Silo server (silod)
|
||||||
|
+--------------------------+ +------------------------+
|
||||||
|
| LibreOffice Calc | | Go API server |
|
||||||
|
| +----------------------+ | REST | +--------------------+ |
|
||||||
|
| | Silo Extension (.oxt)| <--------> | | ODS endpoints | |
|
||||||
|
| | - Pull/Push BOM | | API | | (internal/ods) | |
|
||||||
|
| | - Completion Wizard | | | +--------------------+ |
|
||||||
|
| | - AI Describe | | | | |
|
||||||
|
| +----------------------+ | | +--------------------+ |
|
||||||
|
| UNO API | cells | | | PostgreSQL | |
|
||||||
|
| +----------------------+ | | +--------------------+ |
|
||||||
|
| | Project Workbook | | +------------------------+
|
||||||
|
| | ~/projects/sheets/ | |
|
||||||
|
| | 3DX10/3DX10.ods | |
|
||||||
|
+--------------------------+
|
||||||
|
|
||||||
|
Extension also calls OpenRouter AI API directly for
|
||||||
|
description generation (does not go through silod).
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extension Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
pkg/calc/
|
||||||
|
META-INF/manifest.xml Extension manifest
|
||||||
|
description.xml Extension metadata (id, version, publisher)
|
||||||
|
description/description_en.txt English description
|
||||||
|
Addons.xcu Toolbar + menu registration
|
||||||
|
ProtocolHandler.xcu Dispatch protocol registration
|
||||||
|
silo_calc_component.py UNO DispatchProvider entry point
|
||||||
|
pythonpath/silo_calc/
|
||||||
|
__init__.py
|
||||||
|
ai_client.py OpenRouter API client
|
||||||
|
client.py SiloClient (HTTP, auth, SSL)
|
||||||
|
completion_wizard.py 3-step new item wizard
|
||||||
|
dialogs.py UNO dialog toolkit wrappers
|
||||||
|
project_files.py Local project file management
|
||||||
|
pull.py Sheet population from server
|
||||||
|
push.py Sheet changes back to server
|
||||||
|
settings.py JSON settings (~/.config/silo/calc-settings.json)
|
||||||
|
sheet_format.py Column layout constants
|
||||||
|
sync_engine.py Row hashing, classification, diff
|
||||||
|
tests/
|
||||||
|
test_basics.py 31 unit tests (no UNO/network required)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Toolbar Commands
|
||||||
|
|
||||||
|
| Button | Command | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| Login | `SiloLogin` | Username/password dialog, creates API token |
|
||||||
|
| Pull BOM | `SiloPullBOM` | Assembly picker -> expanded BOM -> populates sheet |
|
||||||
|
| Pull Project | `SiloPullProject` | Project picker -> all project items -> multi-sheet workbook |
|
||||||
|
| Push | `SiloPush` | Classifies rows -> creates/updates items -> auto-tags project |
|
||||||
|
| Add Item | `SiloAddItem` | Completion wizard (category -> description -> fields) |
|
||||||
|
| Refresh | `SiloRefresh` | Re-pull (placeholder) |
|
||||||
|
| Settings | `SiloSettings` | API URL, token, SSL, OpenRouter config |
|
||||||
|
| AI Describe | `SiloAIDescription` | AI description from seller description |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BOM Sheet Format
|
||||||
|
|
||||||
|
28 columns total: 11 visible core, 13 hidden properties, 4 hidden sync tracking.
|
||||||
|
|
||||||
|
### Visible Columns
|
||||||
|
|
||||||
|
| Col | Header | Notes |
|
||||||
|
|-----|--------|-------|
|
||||||
|
| A | Item | Assembly/section header |
|
||||||
|
| B | Level | BOM depth (0=top) |
|
||||||
|
| C | Source | M=manufactured, P=purchased |
|
||||||
|
| D | PN | Part number (read-only for existing) |
|
||||||
|
| E | Description | Required for new items |
|
||||||
|
| F | Seller Description | Vendor catalog text |
|
||||||
|
| G | Unit Cost | Currency |
|
||||||
|
| H | QTY | Decimal quantity |
|
||||||
|
| I | Ext Cost | Formula =G*H (not stored) |
|
||||||
|
| J | Sourcing Link | URL |
|
||||||
|
| K | Schema | Schema name |
|
||||||
|
|
||||||
|
### Hidden Property Columns (L-X)
|
||||||
|
|
||||||
|
Manufacturer, Manufacturer PN, Supplier, Supplier PN, Lead Time, Min Order
|
||||||
|
Qty, Lifecycle Status, RoHS, Country of Origin, Material, Finish, Notes,
|
||||||
|
Long Description. Populated from revision properties, collapsed by default.
|
||||||
|
|
||||||
|
### Hidden Sync Columns (Y-AB)
|
||||||
|
|
||||||
|
`_silo_row_hash` (SHA-256), `_silo_row_status`, `_silo_updated_at`,
|
||||||
|
`_silo_parent_pn`. Used for change detection and conflict resolution.
|
||||||
|
|
||||||
|
### Row Status Colors
|
||||||
|
|
||||||
|
| Status | Color | Hex |
|
||||||
|
|--------|-------|-----|
|
||||||
|
| synced | light green | #C6EFCE |
|
||||||
|
| modified | light yellow | #FFEB9C |
|
||||||
|
| new | light blue | #BDD7EE |
|
||||||
|
| error | light red | #FFC7CE |
|
||||||
|
| conflict | orange | #F4B084 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Completion Wizard
|
||||||
|
|
||||||
|
Three-step guided workflow for adding new BOM rows:
|
||||||
|
|
||||||
|
1. **Category** -- select from schema categories (F01-X08)
|
||||||
|
2. **Description** -- required text, with AI generation offer when blank
|
||||||
|
3. **Common fields** -- sourcing type, unit cost, quantity, sourcing link
|
||||||
|
|
||||||
|
If a manually entered PN already exists in the database, the PN Conflict
|
||||||
|
Resolution dialog offers: use existing item, auto-generate new PN, or cancel.
|
||||||
|
|
||||||
|
New items are automatically tagged with the workbook's project code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenRouter AI Integration
|
||||||
|
|
||||||
|
The extension calls the OpenRouter API (OpenAI-compatible) to generate
|
||||||
|
standardized part descriptions from verbose seller descriptions. This is
|
||||||
|
useful because seller descriptions are typically detailed catalog text while
|
||||||
|
BOM descriptions need to be concise (max 60 chars, title case, component
|
||||||
|
type first, standard abbreviations).
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Settings dialog fields (or `OPENROUTER_API_KEY` env var):
|
||||||
|
|
||||||
|
- **API Key** -- OpenRouter bearer token (masked in UI)
|
||||||
|
- **AI Model** -- default `openai/gpt-4.1-nano`
|
||||||
|
- **AI Instructions** -- customizable system prompt
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
|
||||||
|
1. Paste seller description into column F
|
||||||
|
2. Click "AI Describe" on toolbar
|
||||||
|
3. Review side-by-side dialog (seller text left, AI result right)
|
||||||
|
4. Edit if needed, click Accept
|
||||||
|
5. Description written to column E
|
||||||
|
|
||||||
|
The AI client (`ai_client.py`) is designed for reuse. The generic
|
||||||
|
`chat_completion()` function can be called by future features (price
|
||||||
|
analysis, sourcing assistance) without modification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server-Side ODS Support
|
||||||
|
|
||||||
|
Pure Go ODS library at `internal/ods/` for server-side spreadsheet generation.
|
||||||
|
No headless LibreOffice dependency -- ODS is a ZIP of XML files.
|
||||||
|
|
||||||
|
### Library (`internal/ods/`)
|
||||||
|
|
||||||
|
- `ods.go` -- types: Workbook, Sheet, Column, Row, Cell, CellType
|
||||||
|
- `writer.go` -- generates valid ODS ZIP archives
|
||||||
|
- `reader.go` -- parses ODS back to Go structs
|
||||||
|
- `ods_test.go` -- 10 round-trip tests
|
||||||
|
|
||||||
|
### ODS Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/items/export.ods` | Items as ODS |
|
||||||
|
| GET | `/api/items/template.ods` | Blank import template |
|
||||||
|
| POST | `/api/items/import.ods` | Import from ODS |
|
||||||
|
| GET | `/api/items/{pn}/bom/export.ods` | BOM as formatted ODS |
|
||||||
|
| GET | `/api/projects/{code}/sheet.ods` | Multi-sheet project workbook |
|
||||||
|
| POST | `/api/sheets/diff` | Upload ODS, return JSON diff |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build and Install
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
make build-calc-oxt # zip pkg/calc/ into silo-calc.oxt
|
||||||
|
make install-calc # unopkg add silo-calc.oxt
|
||||||
|
make uninstall-calc # unopkg remove io.kindredsystems.silo.calc
|
||||||
|
make test-calc # python3 -m unittest (31 tests)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Status
|
||||||
|
|
||||||
|
| Component | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| Extension skeleton | Done | manifest, description, Addons.xcu, ProtocolHandler.xcu |
|
||||||
|
| SiloClient | Done | HTTP client adapted from FreeCAD workbench |
|
||||||
|
| Settings | Done | JSON persistence, env var fallbacks |
|
||||||
|
| Login dialog | Done | Two-step username/password |
|
||||||
|
| Settings dialog | Done | API URL, token, SSL, OpenRouter fields |
|
||||||
|
| Pull BOM | Done | Full column set, hidden groups, hash tracking |
|
||||||
|
| Pull Project | Done | Items sheet + BOM sheet |
|
||||||
|
| Push | Done | Create/update, auto project tagging, conflict detection |
|
||||||
|
| Completion wizard | Done | 3-step with PN conflict resolution |
|
||||||
|
| AI description | Done | OpenRouter client, review dialog, toolbar button |
|
||||||
|
| Refresh | Stub | Placeholder only |
|
||||||
|
| Go ODS library | Done | Writer, reader, 10 round-trip tests |
|
||||||
|
| ODS endpoints | Done | 6 handlers registered |
|
||||||
|
| Makefile targets | Done | build, install, uninstall, test, clean |
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
|
||||||
|
- Refresh command is a placeholder (shows "coming soon")
|
||||||
|
- No integration tests with a running Silo instance yet
|
||||||
|
- `completion_wizard.py` uses simple input boxes instead of proper list dialogs
|
||||||
|
- Push does not yet handle BOM relationship creation (item fields only)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
31 unit tests in `pkg/calc/tests/test_basics.py`, runnable without UNO or
|
||||||
|
network access:
|
||||||
|
|
||||||
|
- TestSheetFormat (7) -- column indices, headers, sheet type detection
|
||||||
|
- TestSyncEngine (9) -- hashing, classification, diff, conflict detection
|
||||||
|
- TestSettings (3) -- load/save/auth
|
||||||
|
- TestProjectFiles (3) -- path resolution, read/write
|
||||||
|
- TestAIClient (9) -- constants, configuration, error handling
|
||||||
|
|
||||||
|
```
|
||||||
|
$ python3 -m unittest pkg/calc/tests/test_basics.py -v
|
||||||
|
Ran 31 tests in 0.031s
|
||||||
|
OK
|
||||||
|
```
|
||||||
523
docs/COMPONENT_AUDIT.md
Normal file
523
docs/COMPONENT_AUDIT.md
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
# Component Audit Tool
|
||||||
|
|
||||||
|
**Last Updated:** 2026-02-01
|
||||||
|
**Status:** Design
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The parts database has grown organically. Many items were created with only a
|
||||||
|
part number, description, and category. The property schema defines dozens of
|
||||||
|
fields per category (material, finish, manufacturer, supplier, cost, etc.) but
|
||||||
|
most items have few or none of these populated. There is no way to see which
|
||||||
|
items are missing data or to prioritize what needs filling in.
|
||||||
|
|
||||||
|
Currently, adding or updating properties requires either:
|
||||||
|
- Editing each item individually through the web UI detail panel
|
||||||
|
- Bulk CSV export, manual editing, re-import
|
||||||
|
- The Calc extension (new, not yet widely used)
|
||||||
|
|
||||||
|
None of these approaches give visibility into what's missing across the
|
||||||
|
database. Engineers don't know which items need attention until they encounter
|
||||||
|
a blank field during a design review or procurement cycle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Show a per-item completeness score based on the property schema
|
||||||
|
2. Surface the least-complete items so they can be prioritized
|
||||||
|
3. Let users fill in missing fields directly from the audit view
|
||||||
|
4. Filter by project, category, completeness threshold
|
||||||
|
5. Track improvement over time
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
The audit tool is a new page in the existing web UI (`/audit`), built with
|
||||||
|
the same server-rendered Go templates + vanilla JS approach as the items and
|
||||||
|
projects pages. It adds one new API endpoint for the completeness data and
|
||||||
|
reuses existing endpoints for updates.
|
||||||
|
|
||||||
|
### Completeness Scoring
|
||||||
|
|
||||||
|
Each item's completeness is computed against its category's property schema.
|
||||||
|
The schema defines both **global defaults** (12 fields, all categories) and
|
||||||
|
**category-specific properties** (varies: 9 fields for fasteners, 20+ for
|
||||||
|
motion components, etc.).
|
||||||
|
|
||||||
|
**Score formula:**
|
||||||
|
|
||||||
|
```
|
||||||
|
score = sum(weight for each filled field) / sum(weight for all applicable fields)
|
||||||
|
```
|
||||||
|
|
||||||
|
Score is 0.0 to 1.0, displayed as a percentage. Fields are weighted
|
||||||
|
differently depending on sourcing type.
|
||||||
|
|
||||||
|
**Purchased parts (`sourcing_type = "purchased"`):**
|
||||||
|
|
||||||
|
| Weight | Fields | Rationale |
|
||||||
|
|--------|--------|-----------|
|
||||||
|
| 3 | manufacturer_pn, sourcing_link | Can't procure without these |
|
||||||
|
| 2 | manufacturer, supplier, supplier_pn, standard_cost | Core procurement data |
|
||||||
|
| 1 | description, sourcing_type, lead_time_days, minimum_order_qty, lifecycle_status | Important but less blocking |
|
||||||
|
| 1 | All category-specific properties | Engineering detail |
|
||||||
|
| 0.5 | rohs_compliant, country_of_origin, notes, long_description | Nice to have |
|
||||||
|
|
||||||
|
**Manufactured parts (`sourcing_type = "manufactured"`):**
|
||||||
|
|
||||||
|
| Weight | Fields | Rationale |
|
||||||
|
|--------|--------|-----------|
|
||||||
|
| 3 | has_bom (at least one BOM child) | Can't manufacture without a BOM |
|
||||||
|
| 2 | description, standard_cost | Core identification |
|
||||||
|
| 1 | All category-specific properties | Engineering detail |
|
||||||
|
| 0.5 | manufacturer, supplier, notes, long_description | Less relevant for in-house |
|
||||||
|
|
||||||
|
The `has_bom` check for manufactured parts queries the `relationships`
|
||||||
|
table for at least one `rel_type = 'component'` child. This is not a
|
||||||
|
property field -- it's a structural check. A manufactured part with no BOM
|
||||||
|
children is flagged as critically incomplete regardless of how many other
|
||||||
|
fields are filled.
|
||||||
|
|
||||||
|
**Assemblies (categories A01-A07):**
|
||||||
|
|
||||||
|
Assembly scores are partially computed from children:
|
||||||
|
|
||||||
|
| Field | Source | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| weight | Sum of child weights | Computed if all children have weight |
|
||||||
|
| standard_cost | Sum of child (cost * qty) | Computed from BOM |
|
||||||
|
| component_count | Count of BOM children | Always computable |
|
||||||
|
| has_bom | BOM children exist | Required (weight 3) |
|
||||||
|
|
||||||
|
A computed field counts as "filled" if the data needed to compute it is
|
||||||
|
available. If a computed value exists, it is shown alongside the stored
|
||||||
|
value so engineers can verify or override.
|
||||||
|
|
||||||
|
Assembly-specific properties that cannot be computed (assembly_time,
|
||||||
|
test_procedure, ip_rating, dimensions) are scored normally.
|
||||||
|
|
||||||
|
**Field filled criteria:**
|
||||||
|
|
||||||
|
- String fields: non-empty after trimming
|
||||||
|
- Number fields: non-null and non-zero
|
||||||
|
- Boolean fields: non-null (false is a valid answer)
|
||||||
|
- has_bom: at least one component relationship exists
|
||||||
|
|
||||||
|
Item-level fields (`description`, `sourcing_type`, `sourcing_link`,
|
||||||
|
`standard_cost`, `long_description`) are checked on the items table.
|
||||||
|
Property fields (`manufacturer`, `material`, etc.) are checked on the
|
||||||
|
current revision's `properties` JSONB column. BOM existence is checked
|
||||||
|
on the `relationships` table.
|
||||||
|
|
||||||
|
### Tiers
|
||||||
|
|
||||||
|
Items are grouped into completeness tiers for dashboard display:
|
||||||
|
|
||||||
|
| Tier | Range | Color | Label |
|
||||||
|
|------|-------|-------|-------|
|
||||||
|
| Critical | 0-25% | Red | Missing critical data |
|
||||||
|
| Low | 25-50% | Orange | Needs attention |
|
||||||
|
| Partial | 50-75% | Yellow | Partially complete |
|
||||||
|
| Good | 75-99% | Light green | Nearly complete |
|
||||||
|
| Complete | 100% | Green | All fields populated |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `GET /api/audit/completeness`
|
||||||
|
|
||||||
|
Returns completeness scores for all items (or filtered subset).
|
||||||
|
|
||||||
|
**Query parameters:**
|
||||||
|
|
||||||
|
| Param | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `project` | string | Filter by project code |
|
||||||
|
| `category` | string | Filter by category prefix (e.g. `F`, `F01`) |
|
||||||
|
| `max_score` | float | Only items below this score (e.g. `0.5`) |
|
||||||
|
| `min_score` | float | Only items above this score |
|
||||||
|
| `sort` | string | `score_asc` (default), `score_desc`, `part_number`, `updated_at` |
|
||||||
|
| `limit` | int | Pagination limit (default 100) |
|
||||||
|
| `offset` | int | Pagination offset |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"part_number": "F01-0042",
|
||||||
|
"description": "M3x10 Socket Head Cap Screw",
|
||||||
|
"category": "F01",
|
||||||
|
"category_name": "Screws and Bolts",
|
||||||
|
"sourcing_type": "purchased",
|
||||||
|
"projects": ["3DX10", "PROTO"],
|
||||||
|
"score": 0.41,
|
||||||
|
"weighted_filled": 12.5,
|
||||||
|
"weighted_total": 30.5,
|
||||||
|
"has_bom": false,
|
||||||
|
"bom_children": 0,
|
||||||
|
"missing_critical": ["manufacturer_pn", "sourcing_link"],
|
||||||
|
"missing": [
|
||||||
|
"manufacturer_pn",
|
||||||
|
"sourcing_link",
|
||||||
|
"supplier",
|
||||||
|
"supplier_pn",
|
||||||
|
"finish",
|
||||||
|
"strength_grade",
|
||||||
|
"torque_spec"
|
||||||
|
],
|
||||||
|
"updated_at": "2026-01-15T10:30:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"part_number": "A01-0003",
|
||||||
|
"description": "3DX10 Line Assembly",
|
||||||
|
"category": "A01",
|
||||||
|
"category_name": "Mechanical Assembly",
|
||||||
|
"sourcing_type": "manufactured",
|
||||||
|
"projects": ["3DX10"],
|
||||||
|
"score": 0.68,
|
||||||
|
"weighted_filled": 15.0,
|
||||||
|
"weighted_total": 22.0,
|
||||||
|
"has_bom": true,
|
||||||
|
"bom_children": 12,
|
||||||
|
"computed_fields": {
|
||||||
|
"standard_cost": 7538.61,
|
||||||
|
"component_count": 12,
|
||||||
|
"weight": null
|
||||||
|
},
|
||||||
|
"missing_critical": [],
|
||||||
|
"missing": ["assembly_time", "test_procedure", "weight", "ip_rating"],
|
||||||
|
"updated_at": "2026-01-28T14:20:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"total_items": 847,
|
||||||
|
"avg_score": 0.42,
|
||||||
|
"manufactured_without_bom": 31,
|
||||||
|
"by_tier": {
|
||||||
|
"critical": 123,
|
||||||
|
"low": 298,
|
||||||
|
"partial": 251,
|
||||||
|
"good": 142,
|
||||||
|
"complete": 33
|
||||||
|
},
|
||||||
|
"by_category": {
|
||||||
|
"F": {"count": 156, "avg_score": 0.51},
|
||||||
|
"C": {"count": 89, "avg_score": 0.38},
|
||||||
|
"R": {"count": 201, "avg_score": 0.29}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/audit/completeness/{partNumber}`
|
||||||
|
|
||||||
|
Single-item detail with field-by-field breakdown.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"part_number": "F01-0042",
|
||||||
|
"description": "M3x10 Socket Head Cap Screw",
|
||||||
|
"category": "F01",
|
||||||
|
"sourcing_type": "purchased",
|
||||||
|
"score": 0.41,
|
||||||
|
"has_bom": false,
|
||||||
|
"fields": [
|
||||||
|
{"key": "description", "source": "item", "weight": 1, "value": "M3x10 Socket Head Cap Screw", "filled": true},
|
||||||
|
{"key": "sourcing_type", "source": "item", "weight": 1, "value": "purchased", "filled": true},
|
||||||
|
{"key": "standard_cost", "source": "item", "weight": 2, "value": 0.12, "filled": true},
|
||||||
|
{"key": "sourcing_link", "source": "item", "weight": 3, "value": "", "filled": false},
|
||||||
|
{"key": "manufacturer", "source": "property", "weight": 2, "value": null, "filled": false},
|
||||||
|
{"key": "manufacturer_pn", "source": "property", "weight": 3, "value": null, "filled": false},
|
||||||
|
{"key": "supplier", "source": "property", "weight": 2, "value": null, "filled": false},
|
||||||
|
{"key": "supplier_pn", "source": "property", "weight": 2, "value": null, "filled": false},
|
||||||
|
{"key": "material", "source": "property", "weight": 1, "value": "18-8 Stainless Steel", "filled": true},
|
||||||
|
{"key": "finish", "source": "property", "weight": 1, "value": null, "filled": false},
|
||||||
|
{"key": "thread_size", "source": "property", "weight": 1, "value": "M3", "filled": true},
|
||||||
|
{"key": "thread_pitch", "source": "property", "weight": 1, "value": null, "filled": false},
|
||||||
|
{"key": "length", "source": "property", "weight": 1, "value": "10mm", "filled": true},
|
||||||
|
{"key": "head_type", "source": "property", "weight": 1, "value": "Socket", "filled": true}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For assemblies, the detail response includes a `computed_fields` section
|
||||||
|
showing values derived from children (cost rollup, weight rollup,
|
||||||
|
component count). These are presented alongside stored values in the UI
|
||||||
|
so engineers can compare and choose to accept the computed value.
|
||||||
|
|
||||||
|
Existing `PUT /api/items/{pn}` and revision property updates handle writes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web UI
|
||||||
|
|
||||||
|
### Audit Page (`/audit`)
|
||||||
|
|
||||||
|
New page accessible from the top navigation bar (fourth tab after Items,
|
||||||
|
Projects, Schemas).
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
|
||||||
|
```
|
||||||
|
+------------------------------------------------------------------+
|
||||||
|
| Items | Projects | Schemas | Audit |
|
||||||
|
+------------------------------------------------------------------+
|
||||||
|
| [Project: ___] [Category: ___] [Max Score: ___] [Search] |
|
||||||
|
+------------------------------------------------------------------+
|
||||||
|
| Summary Bar |
|
||||||
|
| [===Critical: 123===|===Low: 298===|==Partial: 251==|Good|Done] |
|
||||||
|
+------------------------------------------------------------------+
|
||||||
|
| Score | PN | Description | Category | Missing|
|
||||||
|
|-------|-----------|--------------------------|----------|--------|
|
||||||
|
| 12% | R01-0003 | Bearing, Deep Groove 6205| Bearings | 18 |
|
||||||
|
| 15% | E14-0001 | NTC Thermistor 10K | Sensors | 16 |
|
||||||
|
| 23% | C03-0012 | 1/4" NPT Ball Valve SS | Valves | 14 |
|
||||||
|
| 35% | F01-0042 | M3x10 Socket Head Cap | Screws | 7 |
|
||||||
|
| ... | | | | |
|
||||||
|
+------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
**Interactions:**
|
||||||
|
|
||||||
|
- Click a row to open an inline edit panel (right side, same split-panel
|
||||||
|
pattern as the items page)
|
||||||
|
- The edit panel shows all applicable fields for the category, with empty
|
||||||
|
fields highlighted
|
||||||
|
- Editing a field and pressing Enter/Tab saves immediately via API
|
||||||
|
- Score updates live after each save
|
||||||
|
- Summary bar updates as items are completed
|
||||||
|
- Click a tier segment in the summary bar to filter to that tier
|
||||||
|
|
||||||
|
### Inline Edit Panel
|
||||||
|
|
||||||
|
```
|
||||||
|
+----------------------------------+
|
||||||
|
| F01-0042 Score: 35% |
|
||||||
|
| M3x10 Socket Head Cap Screw |
|
||||||
|
+----------------------------------+
|
||||||
|
| -- Required -- |
|
||||||
|
| Description [M3x10 Socket H..] |
|
||||||
|
| Sourcing [purchased v ] |
|
||||||
|
+----------------------------------+
|
||||||
|
| -- Procurement -- |
|
||||||
|
| Manufacturer [________________] |
|
||||||
|
| Mfr PN [________________] |
|
||||||
|
| Supplier [________________] |
|
||||||
|
| Supplier PN [________________] |
|
||||||
|
| Cost [$0.12 ] |
|
||||||
|
| Sourcing Link[________________] |
|
||||||
|
| Lead Time [____ days ] |
|
||||||
|
+----------------------------------+
|
||||||
|
| -- Fastener Properties -- |
|
||||||
|
| Material [18-8 Stainless ] |
|
||||||
|
| Finish [________________] |
|
||||||
|
| Thread Size [M3 ] |
|
||||||
|
| Thread Pitch [________________] |
|
||||||
|
| Length [10mm ] |
|
||||||
|
| Head Type [Socket ] |
|
||||||
|
| Drive Type [________________] |
|
||||||
|
| Strength [________________] |
|
||||||
|
| Torque Spec [________________] |
|
||||||
|
+----------------------------------+
|
||||||
|
| [Save All] |
|
||||||
|
+----------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields are grouped into sections: Required, Procurement (global defaults),
|
||||||
|
and category-specific properties. Empty fields have a subtle red left border.
|
||||||
|
Filled fields have a green left border. The score bar at the top updates as
|
||||||
|
fields are filled in.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: API endpoint and scoring engine
|
||||||
|
|
||||||
|
New file: `internal/api/audit_handlers.go`
|
||||||
|
|
||||||
|
- `HandleAuditCompleteness` -- query items, join current revision properties,
|
||||||
|
compute scores against schema, return paginated JSON
|
||||||
|
- `HandleAuditItemDetail` -- single item with field-by-field breakdown
|
||||||
|
- Scoring logic in a helper function that takes item fields + revision
|
||||||
|
properties + category schema and returns score + missing list
|
||||||
|
|
||||||
|
Register routes:
|
||||||
|
- `GET /api/audit/completeness` (viewer role)
|
||||||
|
- `GET /api/audit/completeness/{partNumber}` (viewer role)
|
||||||
|
|
||||||
|
### Phase 2: Web UI page
|
||||||
|
|
||||||
|
New template: `internal/api/templates/audit.html`
|
||||||
|
|
||||||
|
- Same base template, Catppuccin Mocha theme, nav bar with Audit tab
|
||||||
|
- Summary bar with tier counts (colored segments)
|
||||||
|
- Sortable, filterable table
|
||||||
|
- Split-panel detail view on row click
|
||||||
|
- Vanilla JS fetch calls to audit and item update endpoints
|
||||||
|
|
||||||
|
Update `internal/api/web.go`:
|
||||||
|
- Add `HandleAuditPage` handler
|
||||||
|
- Register `GET /audit` route
|
||||||
|
|
||||||
|
Update `internal/api/templates/base.html`:
|
||||||
|
- Add Audit tab to navigation
|
||||||
|
|
||||||
|
### Phase 3: Inline editing
|
||||||
|
|
||||||
|
- Field save on blur/Enter via `PUT /api/items/{pn}` for item fields
|
||||||
|
- Property updates via `POST /api/items/{pn}/revisions` with updated
|
||||||
|
properties map
|
||||||
|
- Live score recalculation after save (re-fetch from audit detail endpoint)
|
||||||
|
- Batch "Save All" button for multiple field changes
|
||||||
|
|
||||||
|
### Phase 4: Tracking and reporting
|
||||||
|
|
||||||
|
- Store periodic score snapshots (daily cron or on-demand) in a new
|
||||||
|
`audit_snapshots` table for trend tracking
|
||||||
|
- Dashboard chart showing completeness improvement over time
|
||||||
|
- Per-project completeness summary on the projects page
|
||||||
|
- CSV export of audit results for offline review
|
||||||
|
|
||||||
|
### Phase 5: Batch AI assistance
|
||||||
|
|
||||||
|
Server-side OpenRouter integration for bulk property inference from existing
|
||||||
|
sourcing data. This extends the Calc extension's AI client pattern to the
|
||||||
|
backend.
|
||||||
|
|
||||||
|
**Workflow:**
|
||||||
|
|
||||||
|
1. Audit page shows items with sourcing links but missing properties
|
||||||
|
2. Engineer selects items (or filters to a category/project) and clicks
|
||||||
|
"AI Fill Properties"
|
||||||
|
3. Server fetches each item's sourcing link page content (or uses the
|
||||||
|
seller description from the item's metadata)
|
||||||
|
4. OpenRouter API call per item: system prompt describes the category's
|
||||||
|
property schema, user prompt provides the scraped/stored description
|
||||||
|
5. AI returns structured JSON with suggested property values
|
||||||
|
6. Results shown in a review table: item, field, current value, suggested
|
||||||
|
value, confidence indicator
|
||||||
|
7. Engineer checks/unchecks suggestions, clicks "Apply Selected"
|
||||||
|
8. Server writes accepted values as property updates (new revision)
|
||||||
|
|
||||||
|
**AI prompt structure:**
|
||||||
|
|
||||||
|
```
|
||||||
|
System: You are a parts data specialist. Given a product description
|
||||||
|
and a list of property fields with types, extract values for as many
|
||||||
|
fields as possible. Return JSON only.
|
||||||
|
|
||||||
|
User:
|
||||||
|
Category: F01 (Screws and Bolts)
|
||||||
|
Product: {seller_description or scraped page text}
|
||||||
|
|
||||||
|
Fields to extract:
|
||||||
|
- material (string): Material specification
|
||||||
|
- finish (string): Surface finish
|
||||||
|
- thread_size (string): Thread size designation
|
||||||
|
- thread_pitch (string): Thread pitch
|
||||||
|
- length (string): Fastener length with unit
|
||||||
|
- head_type (string): Head style
|
||||||
|
- drive_type (string): Drive type
|
||||||
|
- strength_grade (string): Strength/property class
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rate limiting:** Queue items and process in batches of 10 with 1s delay
|
||||||
|
between batches to stay within OpenRouter rate limits. Show progress bar
|
||||||
|
in the UI.
|
||||||
|
|
||||||
|
**Cost control:** Use `openai/gpt-4.1-nano` by default (cheapest). Show
|
||||||
|
estimated cost before starting batch. Allow model override in settings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Changes
|
||||||
|
|
||||||
|
### Phase 1: None
|
||||||
|
|
||||||
|
Completeness is computed at query time from existing `items` +
|
||||||
|
`revisions.properties` data joined against the in-memory schema definition.
|
||||||
|
No new tables needed for the core feature.
|
||||||
|
|
||||||
|
### Phase 4: New table
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_snapshots (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
captured_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
total_items INTEGER NOT NULL,
|
||||||
|
avg_score DECIMAL(5,4) NOT NULL,
|
||||||
|
by_tier JSONB NOT NULL,
|
||||||
|
by_category JSONB NOT NULL,
|
||||||
|
by_project JSONB NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 5: None
|
||||||
|
|
||||||
|
AI suggestions are ephemeral (computed per request, not stored). Accepted
|
||||||
|
suggestions are written through the existing revision/property update path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scoring Examples
|
||||||
|
|
||||||
|
### Purchased Fastener (F01)
|
||||||
|
|
||||||
|
**Weighted total: ~30.5 points**
|
||||||
|
|
||||||
|
| Field | Weight | Filled? | Points |
|
||||||
|
|-------|--------|---------|--------|
|
||||||
|
| manufacturer_pn | 3 | no | 0/3 |
|
||||||
|
| sourcing_link | 3 | no | 0/3 |
|
||||||
|
| manufacturer | 2 | no | 0/2 |
|
||||||
|
| supplier | 2 | no | 0/2 |
|
||||||
|
| supplier_pn | 2 | no | 0/2 |
|
||||||
|
| standard_cost | 2 | yes | 2/2 |
|
||||||
|
| description | 1 | yes | 1/1 |
|
||||||
|
| sourcing_type | 1 | yes | 1/1 |
|
||||||
|
| material | 1 | yes | 1/1 |
|
||||||
|
| thread_size | 1 | yes | 1/1 |
|
||||||
|
| length | 1 | yes | 1/1 |
|
||||||
|
| head_type | 1 | yes | 1/1 |
|
||||||
|
| drive_type | 1 | no | 0/1 |
|
||||||
|
| finish | 1 | no | 0/1 |
|
||||||
|
| ... (remaining) | 0.5-1 | no | 0/... |
|
||||||
|
|
||||||
|
**Score: 8/30.5 = 26%** -- "Low" tier, flagged because weight-3 fields
|
||||||
|
(manufacturer_pn, sourcing_link) are missing.
|
||||||
|
|
||||||
|
### Manufactured Assembly (A01)
|
||||||
|
|
||||||
|
**Weighted total: ~22 points**
|
||||||
|
|
||||||
|
| Field | Weight | Source | Points |
|
||||||
|
|-------|--------|--------|--------|
|
||||||
|
| has_bom | 3 | BOM query | 3/3 (12 children) |
|
||||||
|
| description | 2 | item | 2/2 |
|
||||||
|
| standard_cost | 2 | computed from children | 2/2 |
|
||||||
|
| component_count | 1 | computed (= 12) | 1/1 |
|
||||||
|
| weight | 1 | computed (needs children) | 0/1 (not all children have weight) |
|
||||||
|
| assembly_time | 1 | property | 0/1 |
|
||||||
|
| test_procedure | 1 | property | 0/1 |
|
||||||
|
| dimensions | 1 | property | 0/1 |
|
||||||
|
| ip_rating | 1 | property | 0/1 |
|
||||||
|
| ... (globals) | 0.5-1 | property | .../... |
|
||||||
|
|
||||||
|
**Score: ~15/22 = 68%** -- "Partial" tier, mostly complete because BOM
|
||||||
|
and cost are covered through children.
|
||||||
|
|
||||||
|
### Motor (R01) -- highest field count
|
||||||
|
|
||||||
|
30+ applicable fields across global defaults + motion-specific properties
|
||||||
|
(load, speed, power, voltage, current, torque, encoder, gear ratio...).
|
||||||
|
A motor with only description + cost + sourcing_type scores under 10%
|
||||||
|
because of the large denominator. Motors are the category most likely to
|
||||||
|
benefit from batch AI extraction from datasheets.
|
||||||
1054
internal/api/ods.go
Normal file
1054
internal/api/ods.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
r.Get("/", server.HandleListProjects)
|
r.Get("/", server.HandleListProjects)
|
||||||
r.Get("/{code}", server.HandleGetProject)
|
r.Get("/{code}", server.HandleGetProject)
|
||||||
r.Get("/{code}/items", server.HandleGetProjectItems)
|
r.Get("/{code}/items", server.HandleGetProjectItems)
|
||||||
|
r.Get("/{code}/sheet.ods", server.HandleProjectSheetODS)
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(server.RequireRole(auth.RoleEditor))
|
r.Use(server.RequireRole(auth.RoleEditor))
|
||||||
@@ -121,11 +122,14 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
r.Get("/search", server.HandleFuzzySearch)
|
r.Get("/search", server.HandleFuzzySearch)
|
||||||
r.Get("/export.csv", server.HandleExportCSV)
|
r.Get("/export.csv", server.HandleExportCSV)
|
||||||
r.Get("/template.csv", server.HandleCSVTemplate)
|
r.Get("/template.csv", server.HandleCSVTemplate)
|
||||||
|
r.Get("/export.ods", server.HandleExportODS)
|
||||||
|
r.Get("/template.ods", server.HandleODSTemplate)
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(server.RequireRole(auth.RoleEditor))
|
r.Use(server.RequireRole(auth.RoleEditor))
|
||||||
r.Post("/", server.HandleCreateItem)
|
r.Post("/", server.HandleCreateItem)
|
||||||
r.Post("/import", server.HandleImportCSV)
|
r.Post("/import", server.HandleImportCSV)
|
||||||
|
r.Post("/import.ods", server.HandleImportODS)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/{partNumber}", func(r chi.Router) {
|
r.Route("/{partNumber}", func(r chi.Router) {
|
||||||
@@ -140,6 +144,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
r.Get("/bom/expanded", server.HandleGetExpandedBOM)
|
r.Get("/bom/expanded", server.HandleGetExpandedBOM)
|
||||||
r.Get("/bom/where-used", server.HandleGetWhereUsed)
|
r.Get("/bom/where-used", server.HandleGetWhereUsed)
|
||||||
r.Get("/bom/export.csv", server.HandleExportBOMCSV)
|
r.Get("/bom/export.csv", server.HandleExportBOMCSV)
|
||||||
|
r.Get("/bom/export.ods", server.HandleExportBOMODS)
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(server.RequireRole(auth.RoleEditor))
|
r.Use(server.RequireRole(auth.RoleEditor))
|
||||||
@@ -173,6 +178,12 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Sheets (editor)
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(server.RequireRole(auth.RoleEditor))
|
||||||
|
r.Post("/sheets/diff", server.HandleSheetDiff)
|
||||||
|
})
|
||||||
|
|
||||||
// Part number generation (editor)
|
// Part number generation (editor)
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(server.RequireRole(auth.RoleEditor))
|
r.Use(server.RequireRole(auth.RoleEditor))
|
||||||
|
|||||||
48
internal/ods/ods.go
Normal file
48
internal/ods/ods.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// Package ods provides a lightweight ODS (OpenDocument Spreadsheet) writer and reader.
|
||||||
|
// ODS files are ZIP archives containing XML files conforming to the
|
||||||
|
// OpenDocument Format (ODF) 1.2 specification (ISO/IEC 26300).
|
||||||
|
package ods
|
||||||
|
|
||||||
|
// CellType represents the data type of a cell.
|
||||||
|
type CellType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
CellString CellType = iota
|
||||||
|
CellFloat
|
||||||
|
CellCurrency
|
||||||
|
CellDate
|
||||||
|
CellFormula
|
||||||
|
CellEmpty
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sheet represents a named sheet within an ODS workbook.
|
||||||
|
type Sheet struct {
|
||||||
|
Name string
|
||||||
|
Columns []Column
|
||||||
|
Rows []Row
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column defines column properties.
|
||||||
|
type Column struct {
|
||||||
|
Width string // e.g., "2.5cm", "80pt"
|
||||||
|
Hidden bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row represents a single row.
|
||||||
|
type Row struct {
|
||||||
|
Cells []Cell
|
||||||
|
IsBlank bool // preserve blank separator rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cell represents a single cell value.
|
||||||
|
type Cell struct {
|
||||||
|
Value string // display/string value
|
||||||
|
Type CellType // data type
|
||||||
|
Formula string // ODS formula, e.g., "of:=[.G3]*[.H3]"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workbook is the top-level container passed to Write.
|
||||||
|
type Workbook struct {
|
||||||
|
Sheets []Sheet
|
||||||
|
Meta map[string]string // custom metadata key-value pairs
|
||||||
|
}
|
||||||
571
internal/ods/ods_test.go
Normal file
571
internal/ods/ods_test.go
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
package ods
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteReadRoundTrip(t *testing.T) {
|
||||||
|
wb := &Workbook{
|
||||||
|
Meta: map[string]string{
|
||||||
|
"project": "3DX10",
|
||||||
|
"schema": "kindred-rd",
|
||||||
|
},
|
||||||
|
Sheets: []Sheet{
|
||||||
|
{
|
||||||
|
Name: "BOM",
|
||||||
|
Columns: []Column{
|
||||||
|
{Width: "3cm"},
|
||||||
|
{Width: "1.5cm"},
|
||||||
|
{Width: "1.5cm"},
|
||||||
|
{Width: "2.5cm"},
|
||||||
|
{Width: "5cm"},
|
||||||
|
{Width: "5cm"},
|
||||||
|
{Width: "2.5cm"},
|
||||||
|
{Width: "1.5cm"},
|
||||||
|
{Width: "2.5cm"},
|
||||||
|
{Width: "5cm"},
|
||||||
|
{Width: "1.5cm"},
|
||||||
|
{Hidden: true}, // manufacturer
|
||||||
|
{Hidden: true}, // manufacturer_pn
|
||||||
|
},
|
||||||
|
Rows: []Row{
|
||||||
|
// Header row
|
||||||
|
{Cells: []Cell{
|
||||||
|
HeaderCell("Item"),
|
||||||
|
HeaderCell("Level"),
|
||||||
|
HeaderCell("Source"),
|
||||||
|
HeaderCell("PN"),
|
||||||
|
HeaderCell("Description"),
|
||||||
|
HeaderCell("Seller Description"),
|
||||||
|
HeaderCell("Unit Cost"),
|
||||||
|
HeaderCell("QTY"),
|
||||||
|
HeaderCell("Ext Cost"),
|
||||||
|
HeaderCell("Sourcing Link"),
|
||||||
|
HeaderCell("Schema"),
|
||||||
|
HeaderCell("Manufacturer"),
|
||||||
|
HeaderCell("Manufacturer PN"),
|
||||||
|
}},
|
||||||
|
// Top-level assembly
|
||||||
|
{Cells: []Cell{
|
||||||
|
StringCell("3DX10 Line Assembly"),
|
||||||
|
IntCell(0),
|
||||||
|
StringCell("M"),
|
||||||
|
StringCell("A01-0003"),
|
||||||
|
EmptyCell(),
|
||||||
|
EmptyCell(),
|
||||||
|
CurrencyCell(7538.61),
|
||||||
|
FloatCell(1),
|
||||||
|
FormulaCell("of:=[.G2]*[.H2]"),
|
||||||
|
EmptyCell(),
|
||||||
|
StringCell("RD"),
|
||||||
|
}},
|
||||||
|
// Blank separator
|
||||||
|
{IsBlank: true},
|
||||||
|
// Sub-assembly
|
||||||
|
{Cells: []Cell{
|
||||||
|
StringCell("Extruder Assy"),
|
||||||
|
IntCell(1),
|
||||||
|
StringCell("M"),
|
||||||
|
StringCell("A01-0001"),
|
||||||
|
EmptyCell(),
|
||||||
|
EmptyCell(),
|
||||||
|
CurrencyCell(900.00),
|
||||||
|
FloatCell(1),
|
||||||
|
FormulaCell("of:=[.G4]*[.H4]"),
|
||||||
|
}},
|
||||||
|
// Child part
|
||||||
|
{Cells: []Cell{
|
||||||
|
EmptyCell(),
|
||||||
|
IntCell(2),
|
||||||
|
StringCell("P"),
|
||||||
|
StringCell("S09-0001"),
|
||||||
|
EmptyCell(),
|
||||||
|
StringCell("Smooth-Bore Seamless 316 Stainless"),
|
||||||
|
CurrencyCell(134.15),
|
||||||
|
FloatCell(1),
|
||||||
|
FormulaCell("of:=[.G5]*[.H5]"),
|
||||||
|
StringCell("https://www.mcmaster.com/product"),
|
||||||
|
StringCell("RD"),
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write
|
||||||
|
data, err := Write(wb)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Write failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's a valid ZIP
|
||||||
|
_, err = zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Output is not valid ZIP: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back
|
||||||
|
got, err := Read(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify metadata
|
||||||
|
if got.Meta["project"] != "3DX10" {
|
||||||
|
t.Errorf("meta project = %q, want %q", got.Meta["project"], "3DX10")
|
||||||
|
}
|
||||||
|
if got.Meta["schema"] != "kindred-rd" {
|
||||||
|
t.Errorf("meta schema = %q, want %q", got.Meta["schema"], "kindred-rd")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify sheet count
|
||||||
|
if len(got.Sheets) != 1 {
|
||||||
|
t.Fatalf("got %d sheets, want 1", len(got.Sheets))
|
||||||
|
}
|
||||||
|
|
||||||
|
sheet := got.Sheets[0]
|
||||||
|
if sheet.Name != "BOM" {
|
||||||
|
t.Errorf("sheet name = %q, want %q", sheet.Name, "BOM")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify row count (5 data rows; blank row preserved)
|
||||||
|
if len(sheet.Rows) < 5 {
|
||||||
|
t.Fatalf("got %d rows, want at least 5", len(sheet.Rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify header row
|
||||||
|
headerRow := sheet.Rows[0]
|
||||||
|
if len(headerRow.Cells) < 11 {
|
||||||
|
t.Fatalf("header has %d cells, want at least 11", len(headerRow.Cells))
|
||||||
|
}
|
||||||
|
if headerRow.Cells[0].Value != "Item" {
|
||||||
|
t.Errorf("header[0] = %q, want %q", headerRow.Cells[0].Value, "Item")
|
||||||
|
}
|
||||||
|
if headerRow.Cells[3].Value != "PN" {
|
||||||
|
t.Errorf("header[3] = %q, want %q", headerRow.Cells[3].Value, "PN")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify top-level assembly row
|
||||||
|
asmRow := sheet.Rows[1]
|
||||||
|
if asmRow.Cells[0].Value != "3DX10 Line Assembly" {
|
||||||
|
t.Errorf("asm item = %q, want %q", asmRow.Cells[0].Value, "3DX10 Line Assembly")
|
||||||
|
}
|
||||||
|
if asmRow.Cells[3].Value != "A01-0003" {
|
||||||
|
t.Errorf("asm PN = %q, want %q", asmRow.Cells[3].Value, "A01-0003")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify blank separator row exists
|
||||||
|
blankFound := false
|
||||||
|
for _, row := range sheet.Rows {
|
||||||
|
if row.IsBlank || isRowEmpty(row) {
|
||||||
|
blankFound = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !blankFound {
|
||||||
|
t.Error("expected at least one blank separator row")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify child part
|
||||||
|
childRow := sheet.Rows[len(sheet.Rows)-1]
|
||||||
|
if childRow.Cells[3].Value != "S09-0001" {
|
||||||
|
t.Errorf("child PN = %q, want %q", childRow.Cells[3].Value, "S09-0001")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteReadMultiSheet(t *testing.T) {
|
||||||
|
wb := &Workbook{
|
||||||
|
Sheets: []Sheet{
|
||||||
|
{
|
||||||
|
Name: "BOM",
|
||||||
|
Rows: []Row{
|
||||||
|
{Cells: []Cell{StringCell("Header1"), StringCell("Header2")}},
|
||||||
|
{Cells: []Cell{StringCell("val1"), StringCell("val2")}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Items",
|
||||||
|
Rows: []Row{
|
||||||
|
{Cells: []Cell{StringCell("PN"), StringCell("Desc")}},
|
||||||
|
{Cells: []Cell{StringCell("F01-0001"), StringCell("M3 Screw")}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := Write(wb)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Write failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := Read(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Sheets) != 2 {
|
||||||
|
t.Fatalf("got %d sheets, want 2", len(got.Sheets))
|
||||||
|
}
|
||||||
|
if got.Sheets[0].Name != "BOM" {
|
||||||
|
t.Errorf("sheet 0 name = %q, want %q", got.Sheets[0].Name, "BOM")
|
||||||
|
}
|
||||||
|
if got.Sheets[1].Name != "Items" {
|
||||||
|
t.Errorf("sheet 1 name = %q, want %q", got.Sheets[1].Name, "Items")
|
||||||
|
}
|
||||||
|
if got.Sheets[1].Rows[1].Cells[0].Value != "F01-0001" {
|
||||||
|
t.Errorf("items row 1 cell 0 = %q, want %q", got.Sheets[1].Rows[1].Cells[0].Value, "F01-0001")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCellTypes(t *testing.T) {
|
||||||
|
wb := &Workbook{
|
||||||
|
Sheets: []Sheet{
|
||||||
|
{
|
||||||
|
Name: "Types",
|
||||||
|
Rows: []Row{
|
||||||
|
{Cells: []Cell{
|
||||||
|
StringCell("hello"),
|
||||||
|
FloatCell(3.14),
|
||||||
|
CurrencyCell(99.99),
|
||||||
|
IntCell(42),
|
||||||
|
EmptyCell(),
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := Write(wb)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Write failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := Read(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
row := got.Sheets[0].Rows[0]
|
||||||
|
|
||||||
|
// String
|
||||||
|
if row.Cells[0].Value != "hello" {
|
||||||
|
t.Errorf("string cell = %q, want %q", row.Cells[0].Value, "hello")
|
||||||
|
}
|
||||||
|
if row.Cells[0].Type != CellString {
|
||||||
|
t.Errorf("string cell type = %d, want %d", row.Cells[0].Type, CellString)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Float
|
||||||
|
if row.Cells[1].Value != "3.14" {
|
||||||
|
t.Errorf("float cell = %q, want %q", row.Cells[1].Value, "3.14")
|
||||||
|
}
|
||||||
|
if row.Cells[1].Type != CellFloat {
|
||||||
|
t.Errorf("float cell type = %d, want %d", row.Cells[1].Type, CellFloat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currency
|
||||||
|
if row.Cells[2].Type != CellCurrency {
|
||||||
|
t.Errorf("currency cell type = %d, want %d", row.Cells[2].Type, CellCurrency)
|
||||||
|
}
|
||||||
|
if row.Cells[2].Value != "99.99" {
|
||||||
|
t.Errorf("currency cell = %q, want %q", row.Cells[2].Value, "99.99")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Int (stored as float)
|
||||||
|
if row.Cells[3].Value != "42" {
|
||||||
|
t.Errorf("int cell = %q, want %q", row.Cells[3].Value, "42")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHiddenColumns(t *testing.T) {
|
||||||
|
wb := &Workbook{
|
||||||
|
Sheets: []Sheet{
|
||||||
|
{
|
||||||
|
Name: "Test",
|
||||||
|
Columns: []Column{
|
||||||
|
{Width: "3cm"}, // visible
|
||||||
|
{Width: "2cm", Hidden: true}, // hidden
|
||||||
|
{Width: "3cm"}, // visible
|
||||||
|
},
|
||||||
|
Rows: []Row{
|
||||||
|
{Cells: []Cell{StringCell("A"), StringCell("B"), StringCell("C")}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := Write(wb)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Write failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the content.xml contains visibility="collapse"
|
||||||
|
content := string(data)
|
||||||
|
_ = content // ZIP binary, check via read
|
||||||
|
|
||||||
|
got, err := Read(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sheet := got.Sheets[0]
|
||||||
|
if len(sheet.Columns) < 3 {
|
||||||
|
t.Fatalf("got %d columns, want 3", len(sheet.Columns))
|
||||||
|
}
|
||||||
|
if sheet.Columns[0].Hidden {
|
||||||
|
t.Error("column 0 should not be hidden")
|
||||||
|
}
|
||||||
|
if !sheet.Columns[1].Hidden {
|
||||||
|
t.Error("column 1 should be hidden")
|
||||||
|
}
|
||||||
|
if sheet.Columns[2].Hidden {
|
||||||
|
t.Error("column 2 should not be hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
// All cell data should be preserved regardless of column visibility
|
||||||
|
if sheet.Rows[0].Cells[1].Value != "B" {
|
||||||
|
t.Errorf("hidden column cell = %q, want %q", sheet.Rows[0].Cells[1].Value, "B")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormulaCell(t *testing.T) {
|
||||||
|
wb := &Workbook{
|
||||||
|
Sheets: []Sheet{
|
||||||
|
{
|
||||||
|
Name: "Formulas",
|
||||||
|
Rows: []Row{
|
||||||
|
{Cells: []Cell{FloatCell(10), FloatCell(5), FormulaCell("of:=[.A1]*[.B1]")}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := Write(wb)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Write failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := Read(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cell := got.Sheets[0].Rows[0].Cells[2]
|
||||||
|
if cell.Type != CellFormula {
|
||||||
|
t.Errorf("formula cell type = %d, want %d", cell.Type, CellFormula)
|
||||||
|
}
|
||||||
|
if cell.Formula != "of:=[.A1]*[.B1]" {
|
||||||
|
t.Errorf("formula = %q, want %q", cell.Formula, "of:=[.A1]*[.B1]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlankRowPreservation(t *testing.T) {
|
||||||
|
wb := &Workbook{
|
||||||
|
Sheets: []Sheet{
|
||||||
|
{
|
||||||
|
Name: "Blanks",
|
||||||
|
Rows: []Row{
|
||||||
|
{Cells: []Cell{StringCell("Row1")}},
|
||||||
|
{IsBlank: true},
|
||||||
|
{Cells: []Cell{StringCell("Row3")}},
|
||||||
|
{IsBlank: true},
|
||||||
|
{Cells: []Cell{StringCell("Row5")}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := Write(wb)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Write failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := Read(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := got.Sheets[0].Rows
|
||||||
|
if len(rows) != 5 {
|
||||||
|
t.Fatalf("got %d rows, want 5", len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 0: data
|
||||||
|
if rows[0].Cells[0].Value != "Row1" {
|
||||||
|
t.Errorf("row 0 = %q, want %q", rows[0].Cells[0].Value, "Row1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 1: blank
|
||||||
|
if !rows[1].IsBlank && !isRowEmpty(rows[1]) {
|
||||||
|
t.Error("row 1 should be blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 2: data
|
||||||
|
if rows[2].Cells[0].Value != "Row3" {
|
||||||
|
t.Errorf("row 2 = %q, want %q", rows[2].Cells[0].Value, "Row3")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 4: data (last, not trimmed)
|
||||||
|
if rows[4].Cells[0].Value != "Row5" {
|
||||||
|
t.Errorf("row 4 = %q, want %q", rows[4].Cells[0].Value, "Row5")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetadataRoundTrip(t *testing.T) {
|
||||||
|
meta := map[string]string{
|
||||||
|
"project": "3DX10",
|
||||||
|
"schema": "kindred-rd",
|
||||||
|
"exported_at": "2026-01-30T12:00:00Z",
|
||||||
|
"parent_pn": "A01-0003",
|
||||||
|
}
|
||||||
|
|
||||||
|
wb := &Workbook{
|
||||||
|
Meta: meta,
|
||||||
|
Sheets: []Sheet{
|
||||||
|
{Name: "Sheet1", Rows: []Row{{Cells: []Cell{StringCell("test")}}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := Write(wb)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Write failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := Read(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range meta {
|
||||||
|
if got.Meta[k] != v {
|
||||||
|
t.Errorf("meta[%q] = %q, want %q", k, got.Meta[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestXMLEscaping(t *testing.T) {
|
||||||
|
wb := &Workbook{
|
||||||
|
Sheets: []Sheet{
|
||||||
|
{
|
||||||
|
Name: "Escape Test",
|
||||||
|
Rows: []Row{
|
||||||
|
{Cells: []Cell{
|
||||||
|
StringCell(`1/4" 150 Class <Weld> & Flange`),
|
||||||
|
StringCell("normal text"),
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := Write(wb)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Write failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := Read(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
val := got.Sheets[0].Rows[0].Cells[0].Value
|
||||||
|
expected := `1/4" 150 Class <Weld> & Flange`
|
||||||
|
if val != expected {
|
||||||
|
t.Errorf("escaped cell = %q, want %q", val, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyWorkbook(t *testing.T) {
|
||||||
|
wb := &Workbook{
|
||||||
|
Sheets: []Sheet{
|
||||||
|
{Name: "Empty"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := Write(wb)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Write failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := Read(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Sheets) != 1 {
|
||||||
|
t.Fatalf("got %d sheets, want 1", len(got.Sheets))
|
||||||
|
}
|
||||||
|
if got.Sheets[0].Name != "Empty" {
|
||||||
|
t.Errorf("sheet name = %q, want %q", got.Sheets[0].Name, "Empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteProducesValidODS(t *testing.T) {
|
||||||
|
wb := &Workbook{
|
||||||
|
Sheets: []Sheet{
|
||||||
|
{Name: "Test", Rows: []Row{{Cells: []Cell{StringCell("hello")}}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := Write(wb)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Write failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ZIP structure
|
||||||
|
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("not valid ZIP: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedFiles := map[string]bool{
|
||||||
|
"mimetype": false,
|
||||||
|
"META-INF/manifest.xml": false,
|
||||||
|
"meta.xml": false,
|
||||||
|
"styles.xml": false,
|
||||||
|
"content.xml": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range r.File {
|
||||||
|
if _, ok := expectedFiles[f.Name]; ok {
|
||||||
|
expectedFiles[f.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, found := range expectedFiles {
|
||||||
|
if !found {
|
||||||
|
t.Errorf("missing required file: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify mimetype is first entry and stored (not compressed)
|
||||||
|
if r.File[0].Name != "mimetype" {
|
||||||
|
t.Errorf("first entry = %q, want %q", r.File[0].Name, "mimetype")
|
||||||
|
}
|
||||||
|
if r.File[0].Method != zip.Store {
|
||||||
|
t.Errorf("mimetype method = %d, want Store (%d)", r.File[0].Method, zip.Store)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify content.xml contains our data
|
||||||
|
for _, f := range r.File {
|
||||||
|
if f.Name == "content.xml" {
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open content.xml: %v", err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
buf.ReadFrom(rc)
|
||||||
|
rc.Close()
|
||||||
|
content := buf.String()
|
||||||
|
if !strings.Contains(content, "hello") {
|
||||||
|
t.Error("content.xml does not contain cell value 'hello'")
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, `table:name="Test"`) {
|
||||||
|
t.Error("content.xml does not contain sheet name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
410
internal/ods/reader.go
Normal file
410
internal/ods/reader.go
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
package ods
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Read parses an ODS file from bytes and returns sheets and metadata.
|
||||||
|
func Read(data []byte) (*Workbook, error) {
|
||||||
|
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open zip: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wb := &Workbook{
|
||||||
|
Meta: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range r.File {
|
||||||
|
switch f.Name {
|
||||||
|
case "content.xml":
|
||||||
|
sheets, err := readContent(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read content.xml: %w", err)
|
||||||
|
}
|
||||||
|
wb.Sheets = sheets
|
||||||
|
case "meta.xml":
|
||||||
|
meta, err := readMeta(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read meta.xml: %w", err)
|
||||||
|
}
|
||||||
|
wb.Meta = meta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return wb, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readContent parses content.xml and extracts sheets.
|
||||||
|
func readContent(f *zip.File) ([]Sheet, error) {
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(rc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseContentXML(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseContentXML extracts sheet data from content.xml bytes.
|
||||||
|
func parseContentXML(data []byte) ([]Sheet, error) {
|
||||||
|
decoder := xml.NewDecoder(bytes.NewReader(data))
|
||||||
|
|
||||||
|
var sheets []Sheet
|
||||||
|
var currentSheet *Sheet
|
||||||
|
var currentRow *Row
|
||||||
|
var currentCellText strings.Builder
|
||||||
|
var inTextP bool
|
||||||
|
|
||||||
|
// Current cell attributes for the cell being parsed
|
||||||
|
var cellValueType string
|
||||||
|
var cellValue string
|
||||||
|
var cellFormula string
|
||||||
|
var cellRepeated int
|
||||||
|
|
||||||
|
// Track row repeated
|
||||||
|
var rowRepeated int
|
||||||
|
|
||||||
|
for {
|
||||||
|
tok, err := decoder.Token()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("xml decode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch t := tok.(type) {
|
||||||
|
case xml.StartElement:
|
||||||
|
localName := t.Name.Local
|
||||||
|
switch localName {
|
||||||
|
case "table":
|
||||||
|
name := getAttr(t.Attr, "name")
|
||||||
|
sheets = append(sheets, Sheet{Name: name})
|
||||||
|
currentSheet = &sheets[len(sheets)-1]
|
||||||
|
|
||||||
|
case "table-column":
|
||||||
|
if currentSheet != nil {
|
||||||
|
col := Column{}
|
||||||
|
vis := getAttr(t.Attr, "visibility")
|
||||||
|
if vis == "collapse" || vis == "hidden" {
|
||||||
|
col.Hidden = true
|
||||||
|
}
|
||||||
|
width := getAttrNS(t.Attr, "column-width")
|
||||||
|
if width != "" {
|
||||||
|
col.Width = width
|
||||||
|
}
|
||||||
|
// Handle repeated columns
|
||||||
|
rep := getAttr(t.Attr, "number-columns-repeated")
|
||||||
|
count := 1
|
||||||
|
if rep != "" {
|
||||||
|
if n, err := strconv.Atoi(rep); err == nil && n > 0 {
|
||||||
|
count = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cap at reasonable max to avoid memory issues from huge repeated counts
|
||||||
|
if count > 1024 {
|
||||||
|
count = 1024
|
||||||
|
}
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
currentSheet.Columns = append(currentSheet.Columns, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "table-row":
|
||||||
|
rowRepeated = 1
|
||||||
|
rep := getAttr(t.Attr, "number-rows-repeated")
|
||||||
|
if rep != "" {
|
||||||
|
if n, err := strconv.Atoi(rep); err == nil && n > 0 {
|
||||||
|
rowRepeated = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentRow = &Row{}
|
||||||
|
|
||||||
|
case "table-cell":
|
||||||
|
cellValueType = getAttrNS(t.Attr, "value-type")
|
||||||
|
cellValue = getAttrNS(t.Attr, "value")
|
||||||
|
if cellValue == "" {
|
||||||
|
cellValue = getAttrNS(t.Attr, "date-value")
|
||||||
|
}
|
||||||
|
cellFormula = getAttr(t.Attr, "formula")
|
||||||
|
cellRepeated = 1
|
||||||
|
rep := getAttr(t.Attr, "number-columns-repeated")
|
||||||
|
if rep != "" {
|
||||||
|
if n, err := strconv.Atoi(rep); err == nil && n > 0 {
|
||||||
|
cellRepeated = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentCellText.Reset()
|
||||||
|
|
||||||
|
case "covered-table-cell":
|
||||||
|
// Merged cell continuation -- treat as empty
|
||||||
|
if currentRow != nil {
|
||||||
|
rep := getAttr(t.Attr, "number-columns-repeated")
|
||||||
|
count := 1
|
||||||
|
if rep != "" {
|
||||||
|
if n, err := strconv.Atoi(rep); err == nil && n > 0 {
|
||||||
|
count = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count > 1024 {
|
||||||
|
count = 1024
|
||||||
|
}
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
currentRow.Cells = append(currentRow.Cells, Cell{Type: CellEmpty})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "p":
|
||||||
|
inTextP = true
|
||||||
|
}
|
||||||
|
|
||||||
|
case xml.CharData:
|
||||||
|
if inTextP {
|
||||||
|
currentCellText.Write(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
case xml.EndElement:
|
||||||
|
localName := t.Name.Local
|
||||||
|
switch localName {
|
||||||
|
case "table":
|
||||||
|
currentSheet = nil
|
||||||
|
|
||||||
|
case "table-row":
|
||||||
|
if currentRow != nil && currentSheet != nil {
|
||||||
|
// Determine if the row is blank
|
||||||
|
isBlank := true
|
||||||
|
for _, c := range currentRow.Cells {
|
||||||
|
if c.Type != CellEmpty && c.Value != "" {
|
||||||
|
isBlank = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentRow.IsBlank = isBlank && len(currentRow.Cells) == 0
|
||||||
|
|
||||||
|
// Cap row repeats to avoid memory blow-up from trailing empty rows
|
||||||
|
if rowRepeated > 1 && isBlank {
|
||||||
|
// Only emit one blank row for large repeats (trailing whitespace)
|
||||||
|
if rowRepeated > 2 {
|
||||||
|
rowRepeated = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < rowRepeated; i++ {
|
||||||
|
rowCopy := Row{
|
||||||
|
IsBlank: currentRow.IsBlank,
|
||||||
|
Cells: make([]Cell, len(currentRow.Cells)),
|
||||||
|
}
|
||||||
|
copy(rowCopy.Cells, currentRow.Cells)
|
||||||
|
currentSheet.Rows = append(currentSheet.Rows, rowCopy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentRow = nil
|
||||||
|
|
||||||
|
case "table-cell":
|
||||||
|
if currentRow != nil {
|
||||||
|
cell := buildCell(cellValueType, cellValue, cellFormula, currentCellText.String())
|
||||||
|
|
||||||
|
// Cap repeated to avoid memory issues from trailing empties
|
||||||
|
if cellRepeated > 256 && cell.Type == CellEmpty && cell.Value == "" {
|
||||||
|
cellRepeated = 1
|
||||||
|
}
|
||||||
|
for i := 0; i < cellRepeated; i++ {
|
||||||
|
currentRow.Cells = append(currentRow.Cells, cell)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cellValueType = ""
|
||||||
|
cellValue = ""
|
||||||
|
cellFormula = ""
|
||||||
|
cellRepeated = 1
|
||||||
|
currentCellText.Reset()
|
||||||
|
|
||||||
|
case "p":
|
||||||
|
inTextP = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim trailing empty rows from each sheet
|
||||||
|
for i := range sheets {
|
||||||
|
sheets[i].Rows = trimTrailingBlankRows(sheets[i].Rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim trailing empty cells from each row
|
||||||
|
for i := range sheets {
|
||||||
|
for j := range sheets[i].Rows {
|
||||||
|
sheets[i].Rows[j].Cells = trimTrailingEmptyCells(sheets[i].Rows[j].Cells)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sheets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCell(valueType, value, formula, text string) Cell {
|
||||||
|
if formula != "" {
|
||||||
|
return Cell{
|
||||||
|
Type: CellFormula,
|
||||||
|
Formula: formula,
|
||||||
|
Value: text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch valueType {
|
||||||
|
case "float":
|
||||||
|
// Prefer the office:value attribute for precision; fall back to text
|
||||||
|
v := value
|
||||||
|
if v == "" {
|
||||||
|
v = text
|
||||||
|
}
|
||||||
|
return Cell{Type: CellFloat, Value: v}
|
||||||
|
case "currency":
|
||||||
|
v := value
|
||||||
|
if v == "" {
|
||||||
|
v = strings.TrimPrefix(text, "$")
|
||||||
|
v = strings.ReplaceAll(v, ",", "")
|
||||||
|
}
|
||||||
|
return Cell{Type: CellCurrency, Value: v}
|
||||||
|
case "date":
|
||||||
|
v := value
|
||||||
|
if v == "" {
|
||||||
|
v = text
|
||||||
|
}
|
||||||
|
return Cell{Type: CellDate, Value: v}
|
||||||
|
case "string":
|
||||||
|
return Cell{Type: CellString, Value: text}
|
||||||
|
default:
|
||||||
|
if text != "" {
|
||||||
|
return Cell{Type: CellString, Value: text}
|
||||||
|
}
|
||||||
|
return Cell{Type: CellEmpty}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMeta parses meta.xml for custom Silo metadata.
|
||||||
|
func readMeta(f *zip.File) (map[string]string, error) {
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(rc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseMetaXML(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMetaXML(data []byte) (map[string]string, error) {
|
||||||
|
decoder := xml.NewDecoder(bytes.NewReader(data))
|
||||||
|
meta := make(map[string]string)
|
||||||
|
|
||||||
|
var inUserDefined bool
|
||||||
|
var userDefName string
|
||||||
|
var textBuf strings.Builder
|
||||||
|
|
||||||
|
for {
|
||||||
|
tok, err := decoder.Token()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch t := tok.(type) {
|
||||||
|
case xml.StartElement:
|
||||||
|
if t.Name.Local == "user-defined" {
|
||||||
|
inUserDefined = true
|
||||||
|
userDefName = getAttrNS(t.Attr, "name")
|
||||||
|
textBuf.Reset()
|
||||||
|
}
|
||||||
|
case xml.CharData:
|
||||||
|
if inUserDefined {
|
||||||
|
textBuf.Write(t)
|
||||||
|
}
|
||||||
|
case xml.EndElement:
|
||||||
|
if t.Name.Local == "user-defined" && inUserDefined {
|
||||||
|
if userDefName == "_silo_meta" {
|
||||||
|
// Parse key=value pairs
|
||||||
|
for _, line := range strings.Split(textBuf.String(), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if idx := strings.Index(line, "="); idx > 0 {
|
||||||
|
meta[line[:idx]] = line[idx+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if userDefName != "" {
|
||||||
|
meta[userDefName] = textBuf.String()
|
||||||
|
}
|
||||||
|
inUserDefined = false
|
||||||
|
userDefName = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAttr returns the value of a local-name attribute (no namespace).
|
||||||
|
func getAttr(attrs []xml.Attr, localName string) string {
|
||||||
|
for _, a := range attrs {
|
||||||
|
if a.Name.Local == localName {
|
||||||
|
return a.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAttrNS returns the value of a local-name attribute, ignoring namespace.
|
||||||
|
func getAttrNS(attrs []xml.Attr, localName string) string {
|
||||||
|
for _, a := range attrs {
|
||||||
|
if a.Name.Local == localName {
|
||||||
|
return a.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimTrailingBlankRows(rows []Row) []Row {
|
||||||
|
for len(rows) > 0 {
|
||||||
|
last := rows[len(rows)-1]
|
||||||
|
if last.IsBlank || isRowEmpty(last) {
|
||||||
|
rows = rows[:len(rows)-1]
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRowEmpty(row Row) bool {
|
||||||
|
for _, c := range row.Cells {
|
||||||
|
if c.Type != CellEmpty && c.Value != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimTrailingEmptyCells(cells []Cell) []Cell {
|
||||||
|
for len(cells) > 0 {
|
||||||
|
last := cells[len(cells)-1]
|
||||||
|
if last.Type == CellEmpty && last.Value == "" {
|
||||||
|
cells = cells[:len(cells)-1]
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cells
|
||||||
|
}
|
||||||
323
internal/ods/writer.go
Normal file
323
internal/ods/writer.go
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
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}
|
||||||
|
}
|
||||||
235
pkg/calc/Addons.xcu
Normal file
235
pkg/calc/Addons.xcu
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<oor:component-data xmlns:oor="http://openoffice.org/2001/registry"
|
||||||
|
xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
oor:name="Addons" oor:package="org.openoffice.Office">
|
||||||
|
|
||||||
|
<!-- Toolbar definition -->
|
||||||
|
<node oor:name="AddonUI">
|
||||||
|
<node oor:name="OfficeToolBar">
|
||||||
|
<node oor:name="io.kindredsystems.silo.calc.toolbar" oor:op="replace">
|
||||||
|
|
||||||
|
<node oor:name="m01" oor:op="replace">
|
||||||
|
<prop oor:name="URL" oor:type="xs:string">
|
||||||
|
<value>io.kindredsystems.silo.calc:SiloLogin</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Title" oor:type="xs:string">
|
||||||
|
<value xml:lang="en">Login</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Target" oor:type="xs:string">
|
||||||
|
<value>_self</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Context" oor:type="xs:string">
|
||||||
|
<value>com.sun.star.sheet.SpreadsheetDocument</value>
|
||||||
|
</prop>
|
||||||
|
</node>
|
||||||
|
|
||||||
|
<node oor:name="m02" oor:op="replace">
|
||||||
|
<prop oor:name="URL" oor:type="xs:string">
|
||||||
|
<value>io.kindredsystems.silo.calc:SiloPullBOM</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Title" oor:type="xs:string">
|
||||||
|
<value xml:lang="en">Pull BOM</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Target" oor:type="xs:string">
|
||||||
|
<value>_self</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Context" oor:type="xs:string">
|
||||||
|
<value>com.sun.star.sheet.SpreadsheetDocument</value>
|
||||||
|
</prop>
|
||||||
|
</node>
|
||||||
|
|
||||||
|
<node oor:name="m03" oor:op="replace">
|
||||||
|
<prop oor:name="URL" oor:type="xs:string">
|
||||||
|
<value>io.kindredsystems.silo.calc:SiloPullProject</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Title" oor:type="xs:string">
|
||||||
|
<value xml:lang="en">Pull Project</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Target" oor:type="xs:string">
|
||||||
|
<value>_self</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Context" oor:type="xs:string">
|
||||||
|
<value>com.sun.star.sheet.SpreadsheetDocument</value>
|
||||||
|
</prop>
|
||||||
|
</node>
|
||||||
|
|
||||||
|
<node oor:name="m04" oor:op="replace">
|
||||||
|
<prop oor:name="URL" oor:type="xs:string">
|
||||||
|
<value>io.kindredsystems.silo.calc:SiloPush</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Title" oor:type="xs:string">
|
||||||
|
<value xml:lang="en">Push</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Target" oor:type="xs:string">
|
||||||
|
<value>_self</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Context" oor:type="xs:string">
|
||||||
|
<value>com.sun.star.sheet.SpreadsheetDocument</value>
|
||||||
|
</prop>
|
||||||
|
</node>
|
||||||
|
|
||||||
|
<node oor:name="m05" oor:op="replace">
|
||||||
|
<prop oor:name="URL" oor:type="xs:string">
|
||||||
|
<value>io.kindredsystems.silo.calc:SiloAddItem</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Title" oor:type="xs:string">
|
||||||
|
<value xml:lang="en">Add Item</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Target" oor:type="xs:string">
|
||||||
|
<value>_self</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Context" oor:type="xs:string">
|
||||||
|
<value>com.sun.star.sheet.SpreadsheetDocument</value>
|
||||||
|
</prop>
|
||||||
|
</node>
|
||||||
|
|
||||||
|
<node oor:name="m06" oor:op="replace">
|
||||||
|
<prop oor:name="URL" oor:type="xs:string">
|
||||||
|
<value>io.kindredsystems.silo.calc:SiloRefresh</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Title" oor:type="xs:string">
|
||||||
|
<value xml:lang="en">Refresh</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Target" oor:type="xs:string">
|
||||||
|
<value>_self</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Context" oor:type="xs:string">
|
||||||
|
<value>com.sun.star.sheet.SpreadsheetDocument</value>
|
||||||
|
</prop>
|
||||||
|
</node>
|
||||||
|
|
||||||
|
<node oor:name="m07" oor:op="replace">
|
||||||
|
<prop oor:name="URL" oor:type="xs:string">
|
||||||
|
<value>io.kindredsystems.silo.calc:SiloSettings</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Title" oor:type="xs:string">
|
||||||
|
<value xml:lang="en">Settings</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Target" oor:type="xs:string">
|
||||||
|
<value>_self</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Context" oor:type="xs:string">
|
||||||
|
<value>com.sun.star.sheet.SpreadsheetDocument</value>
|
||||||
|
</prop>
|
||||||
|
</node>
|
||||||
|
|
||||||
|
<node oor:name="m08" oor:op="replace">
|
||||||
|
<prop oor:name="URL" oor:type="xs:string">
|
||||||
|
<value>io.kindredsystems.silo.calc:SiloAIDescription</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Title" oor:type="xs:string">
|
||||||
|
<value xml:lang="en">AI Describe</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Target" oor:type="xs:string">
|
||||||
|
<value>_self</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Context" oor:type="xs:string">
|
||||||
|
<value>com.sun.star.sheet.SpreadsheetDocument</value>
|
||||||
|
</prop>
|
||||||
|
</node>
|
||||||
|
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
|
||||||
|
<!-- Menu entries under Tools menu -->
|
||||||
|
<node oor:name="AddonMenu">
|
||||||
|
<node oor:name="io.kindredsystems.silo.calc.menu.login" oor:op="replace">
|
||||||
|
<prop oor:name="URL" oor:type="xs:string">
|
||||||
|
<value>io.kindredsystems.silo.calc:SiloLogin</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Title" oor:type="xs:string">
|
||||||
|
<value xml:lang="en">Silo: Login</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Context" oor:type="xs:string">
|
||||||
|
<value>com.sun.star.sheet.SpreadsheetDocument</value>
|
||||||
|
</prop>
|
||||||
|
</node>
|
||||||
|
|
||||||
|
<node oor:name="io.kindredsystems.silo.calc.menu.pullbom" oor:op="replace">
|
||||||
|
<prop oor:name="URL" oor:type="xs:string">
|
||||||
|
<value>io.kindredsystems.silo.calc:SiloPullBOM</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Title" oor:type="xs:string">
|
||||||
|
<value xml:lang="en">Silo: Pull BOM</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Context" oor:type="xs:string">
|
||||||
|
<value>com.sun.star.sheet.SpreadsheetDocument</value>
|
||||||
|
</prop>
|
||||||
|
</node>
|
||||||
|
|
||||||
|
<node oor:name="io.kindredsystems.silo.calc.menu.pullproject" oor:op="replace">
|
||||||
|
<prop oor:name="URL" oor:type="xs:string">
|
||||||
|
<value>io.kindredsystems.silo.calc:SiloPullProject</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Title" oor:type="xs:string">
|
||||||
|
<value xml:lang="en">Silo: Pull Project Items</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Context" oor:type="xs:string">
|
||||||
|
<value>com.sun.star.sheet.SpreadsheetDocument</value>
|
||||||
|
</prop>
|
||||||
|
</node>
|
||||||
|
|
||||||
|
<node oor:name="io.kindredsystems.silo.calc.menu.push" oor:op="replace">
|
||||||
|
<prop oor:name="URL" oor:type="xs:string">
|
||||||
|
<value>io.kindredsystems.silo.calc:SiloPush</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Title" oor:type="xs:string">
|
||||||
|
<value xml:lang="en">Silo: Push Changes</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Context" oor:type="xs:string">
|
||||||
|
<value>com.sun.star.sheet.SpreadsheetDocument</value>
|
||||||
|
</prop>
|
||||||
|
</node>
|
||||||
|
|
||||||
|
<node oor:name="io.kindredsystems.silo.calc.menu.additem" oor:op="replace">
|
||||||
|
<prop oor:name="URL" oor:type="xs:string">
|
||||||
|
<value>io.kindredsystems.silo.calc:SiloAddItem</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Title" oor:type="xs:string">
|
||||||
|
<value xml:lang="en">Silo: Add Item</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Context" oor:type="xs:string">
|
||||||
|
<value>com.sun.star.sheet.SpreadsheetDocument</value>
|
||||||
|
</prop>
|
||||||
|
</node>
|
||||||
|
|
||||||
|
<node oor:name="io.kindredsystems.silo.calc.menu.refresh" oor:op="replace">
|
||||||
|
<prop oor:name="URL" oor:type="xs:string">
|
||||||
|
<value>io.kindredsystems.silo.calc:SiloRefresh</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Title" oor:type="xs:string">
|
||||||
|
<value xml:lang="en">Silo: Refresh</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Context" oor:type="xs:string">
|
||||||
|
<value>com.sun.star.sheet.SpreadsheetDocument</value>
|
||||||
|
</prop>
|
||||||
|
</node>
|
||||||
|
|
||||||
|
<node oor:name="io.kindredsystems.silo.calc.menu.settings" oor:op="replace">
|
||||||
|
<prop oor:name="URL" oor:type="xs:string">
|
||||||
|
<value>io.kindredsystems.silo.calc:SiloSettings</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Title" oor:type="xs:string">
|
||||||
|
<value xml:lang="en">Silo: Settings</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Context" oor:type="xs:string">
|
||||||
|
<value>com.sun.star.sheet.SpreadsheetDocument</value>
|
||||||
|
</prop>
|
||||||
|
</node>
|
||||||
|
|
||||||
|
<node oor:name="io.kindredsystems.silo.calc.menu.aidescription" oor:op="replace">
|
||||||
|
<prop oor:name="URL" oor:type="xs:string">
|
||||||
|
<value>io.kindredsystems.silo.calc:SiloAIDescription</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Title" oor:type="xs:string">
|
||||||
|
<value xml:lang="en">Silo: AI Describe</value>
|
||||||
|
</prop>
|
||||||
|
<prop oor:name="Context" oor:type="xs:string">
|
||||||
|
<value>com.sun.star.sheet.SpreadsheetDocument</value>
|
||||||
|
</prop>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
|
||||||
|
</oor:component-data>
|
||||||
7
pkg/calc/META-INF/manifest.xml
Normal file
7
pkg/calc/META-INF/manifest.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE manifest:manifest PUBLIC "-//OpenOffice.org//DTD Manifest 1.0//EN" "Manifest.dtd">
|
||||||
|
<manifest:manifest xmlns:manifest="http://openoffice.org/2001/manifest">
|
||||||
|
<manifest:file-entry manifest:full-path="Addons.xcu" manifest:media-type="application/vnd.sun.star.configuration-data"/>
|
||||||
|
<manifest:file-entry manifest:full-path="ProtocolHandler.xcu" manifest:media-type="application/vnd.sun.star.configuration-data"/>
|
||||||
|
<manifest:file-entry manifest:full-path="silo_calc_component.py" manifest:media-type="application/vnd.sun.star.uno-component;type=Python"/>
|
||||||
|
</manifest:manifest>
|
||||||
14
pkg/calc/ProtocolHandler.xcu
Normal file
14
pkg/calc/ProtocolHandler.xcu
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<oor:component-data xmlns:oor="http://openoffice.org/2001/registry"
|
||||||
|
xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
oor:name="ProtocolHandler"
|
||||||
|
oor:package="org.openoffice.Office">
|
||||||
|
<node oor:name="HandlerSet">
|
||||||
|
<node oor:name="io.kindredsystems.silo.calc.Component" oor:op="replace">
|
||||||
|
<prop oor:name="Protocols" oor:type="oor:string-list">
|
||||||
|
<value>io.kindredsystems.silo.calc:*</value>
|
||||||
|
</prop>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</oor:component-data>
|
||||||
27
pkg/calc/description.xml
Normal file
27
pkg/calc/description.xml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<description xmlns="http://openoffice.org/extensions/description/2006"
|
||||||
|
xmlns:dep="http://openoffice.org/extensions/description/2006"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
|
||||||
|
<identifier value="io.kindredsystems.silo.calc"/>
|
||||||
|
<version value="0.1.0"/>
|
||||||
|
|
||||||
|
<display-name>
|
||||||
|
<name lang="en">Silo - Spreadsheet Sync</name>
|
||||||
|
</display-name>
|
||||||
|
|
||||||
|
<publisher>
|
||||||
|
<name xlink:href="https://kindredsystems.io" lang="en">Kindred Systems</name>
|
||||||
|
</publisher>
|
||||||
|
|
||||||
|
<extension-description>
|
||||||
|
<src lang="en" xlink:href="description/description_en.txt"/>
|
||||||
|
</extension-description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<OpenOffice.org-minimal-version value="4.1" dep:name="OpenOffice.org 4.1"/>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<platform value="all"/>
|
||||||
|
|
||||||
|
</description>
|
||||||
15
pkg/calc/description/description_en.txt
Normal file
15
pkg/calc/description/description_en.txt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
Silo Spreadsheet Sync for LibreOffice Calc
|
||||||
|
|
||||||
|
Bidirectional sync between LibreOffice Calc spreadsheets and the Silo
|
||||||
|
parts database. Pull project BOMs, edit in Calc, push changes back.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Pull BOM: fetch an expanded bill of materials as a formatted sheet
|
||||||
|
- Pull Project: fetch all items tagged with a project code
|
||||||
|
- Push: sync local edits (new items, modified fields) back to the database
|
||||||
|
- Add Item wizard: guided workflow for adding new BOM entries
|
||||||
|
- PN conflict resolution: handle duplicate part numbers gracefully
|
||||||
|
- Auto project tagging: items in a working BOM are tagged with the project
|
||||||
|
|
||||||
|
Toolbar commands appear when a Calc spreadsheet is active.
|
||||||
|
Settings and API token are stored in ~/.config/silo/calc-settings.json.
|
||||||
3
pkg/calc/pythonpath/silo_calc/__init__.py
Normal file
3
pkg/calc/pythonpath/silo_calc/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Silo LibreOffice Calc extension -- spreadsheet sync for project data."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
217
pkg/calc/pythonpath/silo_calc/ai_client.py
Normal file
217
pkg/calc/pythonpath/silo_calc/ai_client.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
"""OpenRouter AI client for the Silo Calc extension.
|
||||||
|
|
||||||
|
Provides AI-powered text generation via the OpenRouter API
|
||||||
|
(https://openrouter.ai/api/v1/chat/completions). Uses stdlib urllib
|
||||||
|
only -- no external dependencies.
|
||||||
|
|
||||||
|
The core ``chat_completion()`` function is generic and reusable for
|
||||||
|
future features (price analysis, sourcing assistance). Domain helpers
|
||||||
|
like ``generate_description()`` build on top of it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from . import settings as _settings
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||||
|
|
||||||
|
DEFAULT_MODEL = "openai/gpt-4.1-nano"
|
||||||
|
|
||||||
|
DEFAULT_INSTRUCTIONS = (
|
||||||
|
"You are a parts librarian for an engineering company. "
|
||||||
|
"Given a seller's product description, produce a concise, standardized "
|
||||||
|
"part description suitable for a Bill of Materials. Rules:\n"
|
||||||
|
"- Maximum 60 characters\n"
|
||||||
|
"- Use title case\n"
|
||||||
|
"- Start with the component type (e.g., Bolt, Resistor, Bearing)\n"
|
||||||
|
"- Include key specifications (size, rating, material) in order of importance\n"
|
||||||
|
"- Omit brand names, marketing language, and redundant words\n"
|
||||||
|
"- Use standard engineering abbreviations (SS, Al, M3, 1/4-20)\n"
|
||||||
|
"- Output ONLY the description, no quotes or explanation"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SSL helper (same pattern as client.py)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_ssl_context() -> ssl.SSLContext:
|
||||||
|
"""Build an SSL context for OpenRouter API calls."""
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
for ca_path in (
|
||||||
|
"/etc/ssl/certs/ca-certificates.crt",
|
||||||
|
"/etc/pki/tls/certs/ca-bundle.crt",
|
||||||
|
):
|
||||||
|
if os.path.isfile(ca_path):
|
||||||
|
try:
|
||||||
|
ctx.load_verify_locations(ca_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Settings resolution helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_api_key() -> str:
|
||||||
|
"""Resolve the OpenRouter API key from settings or environment."""
|
||||||
|
cfg = _settings.load()
|
||||||
|
key = cfg.get("openrouter_api_key", "")
|
||||||
|
if not key:
|
||||||
|
key = os.environ.get("OPENROUTER_API_KEY", "")
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def _get_model() -> str:
|
||||||
|
"""Resolve the model slug from settings or default."""
|
||||||
|
cfg = _settings.load()
|
||||||
|
return cfg.get("openrouter_model", "") or DEFAULT_MODEL
|
||||||
|
|
||||||
|
|
||||||
|
def _get_instructions() -> str:
|
||||||
|
"""Resolve the system instructions from settings or default."""
|
||||||
|
cfg = _settings.load()
|
||||||
|
return cfg.get("openrouter_instructions", "") or DEFAULT_INSTRUCTIONS
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Core API function
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def chat_completion(
|
||||||
|
messages: List[Dict[str, str]],
|
||||||
|
model: Optional[str] = None,
|
||||||
|
temperature: float = 0.3,
|
||||||
|
max_tokens: int = 200,
|
||||||
|
) -> str:
|
||||||
|
"""Send a chat completion request to OpenRouter.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
messages : list of {"role": str, "content": str}
|
||||||
|
model : model slug (default: from settings or DEFAULT_MODEL)
|
||||||
|
temperature : sampling temperature
|
||||||
|
max_tokens : maximum response tokens
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
str : the assistant's response text
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
RuntimeError : on missing API key, HTTP errors, network errors,
|
||||||
|
or unexpected response format.
|
||||||
|
"""
|
||||||
|
api_key = _get_api_key()
|
||||||
|
if not api_key:
|
||||||
|
raise RuntimeError(
|
||||||
|
"OpenRouter API key not configured. "
|
||||||
|
"Set it in Settings or the OPENROUTER_API_KEY environment variable."
|
||||||
|
)
|
||||||
|
|
||||||
|
model = model or _get_model()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": temperature,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"HTTP-Referer": "https://github.com/kindredsystems/silo",
|
||||||
|
"X-Title": "Silo Calc Extension",
|
||||||
|
}
|
||||||
|
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
OPENROUTER_API_URL, data=body, headers=headers, method="POST"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
req, context=_get_ssl_context(), timeout=30
|
||||||
|
) as resp:
|
||||||
|
result = json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
error_body = e.read().decode("utf-8", errors="replace")
|
||||||
|
if e.code == 401:
|
||||||
|
raise RuntimeError("OpenRouter API key is invalid or expired.")
|
||||||
|
if e.code == 402:
|
||||||
|
raise RuntimeError("OpenRouter account has insufficient credits.")
|
||||||
|
if e.code == 429:
|
||||||
|
raise RuntimeError("OpenRouter rate limit exceeded. Try again shortly.")
|
||||||
|
raise RuntimeError(f"OpenRouter API error {e.code}: {error_body}")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise RuntimeError(f"Network error contacting OpenRouter: {e.reason}")
|
||||||
|
|
||||||
|
choices = result.get("choices", [])
|
||||||
|
if not choices:
|
||||||
|
raise RuntimeError("OpenRouter returned an empty response.")
|
||||||
|
|
||||||
|
return choices[0].get("message", {}).get("content", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Domain helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def generate_description(
|
||||||
|
seller_description: str,
|
||||||
|
category: str = "",
|
||||||
|
existing_description: str = "",
|
||||||
|
part_number: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Generate a standardized part description from a seller description.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
seller_description : the raw seller/vendor description text
|
||||||
|
category : category code (e.g. "F01") for context
|
||||||
|
existing_description : current description in col E, if any
|
||||||
|
part_number : the part number, for context
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
str : the AI-generated standardized description
|
||||||
|
"""
|
||||||
|
system_prompt = _get_instructions()
|
||||||
|
|
||||||
|
user_parts = []
|
||||||
|
if category:
|
||||||
|
user_parts.append(f"Category: {category}")
|
||||||
|
if part_number:
|
||||||
|
user_parts.append(f"Part Number: {part_number}")
|
||||||
|
if existing_description:
|
||||||
|
user_parts.append(f"Current Description: {existing_description}")
|
||||||
|
user_parts.append(f"Seller Description: {seller_description}")
|
||||||
|
|
||||||
|
user_prompt = "\n".join(user_parts)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_prompt},
|
||||||
|
]
|
||||||
|
|
||||||
|
return chat_completion(messages)
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured() -> bool:
|
||||||
|
"""Return True if the OpenRouter API key is available."""
|
||||||
|
return bool(_get_api_key())
|
||||||
447
pkg/calc/pythonpath/silo_calc/client.py
Normal file
447
pkg/calc/pythonpath/silo_calc/client.py
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
"""Silo API client for LibreOffice Calc extension.
|
||||||
|
|
||||||
|
Adapted from pkg/freecad/silo_commands.py SiloClient. Uses urllib (no
|
||||||
|
external dependencies) and the same auth flow: session login to obtain a
|
||||||
|
persistent API token stored in a local JSON settings file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import http.cookiejar
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from . import settings as _settings
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SSL helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_ssl_context() -> ssl.SSLContext:
|
||||||
|
"""Build an SSL context honouring the user's verify/cert preferences."""
|
||||||
|
cfg = _settings.load()
|
||||||
|
if not cfg.get("ssl_verify", True):
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
custom_cert = cfg.get("ssl_cert_path", "")
|
||||||
|
if custom_cert and os.path.isfile(custom_cert):
|
||||||
|
try:
|
||||||
|
ctx.load_verify_locations(custom_cert)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Load system CA bundles (bundled Python may not find them automatically)
|
||||||
|
for ca_path in (
|
||||||
|
"/etc/ssl/certs/ca-certificates.crt",
|
||||||
|
"/etc/pki/tls/certs/ca-bundle.crt",
|
||||||
|
):
|
||||||
|
if os.path.isfile(ca_path):
|
||||||
|
try:
|
||||||
|
ctx.load_verify_locations(ca_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SiloClient
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SiloClient:
|
||||||
|
"""HTTP client for the Silo REST API."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str = None):
|
||||||
|
self._explicit_url = base_url
|
||||||
|
|
||||||
|
# -- URL helpers --------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_url(self) -> str:
|
||||||
|
if self._explicit_url:
|
||||||
|
return self._explicit_url.rstrip("/")
|
||||||
|
cfg = _settings.load()
|
||||||
|
url = cfg.get("api_url", "").rstrip("/")
|
||||||
|
if not url:
|
||||||
|
url = os.environ.get("SILO_API_URL", "http://localhost:8080/api")
|
||||||
|
# Auto-append /api for bare origins
|
||||||
|
parsed = urllib.parse.urlparse(url)
|
||||||
|
if not parsed.path or parsed.path == "/":
|
||||||
|
url = url + "/api"
|
||||||
|
return url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _origin(self) -> str:
|
||||||
|
"""Server origin (without /api) for auth endpoints."""
|
||||||
|
base = self.base_url
|
||||||
|
return base.rsplit("/api", 1)[0] if base.endswith("/api") else base
|
||||||
|
|
||||||
|
# -- Auth headers -------------------------------------------------------
|
||||||
|
|
||||||
|
def _auth_headers(self) -> Dict[str, str]:
|
||||||
|
token = _settings.load().get("api_token", "") or os.environ.get(
|
||||||
|
"SILO_API_TOKEN", ""
|
||||||
|
)
|
||||||
|
if token:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# -- Core HTTP ----------------------------------------------------------
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
data: Optional[Dict] = None,
|
||||||
|
raw: bool = False,
|
||||||
|
) -> Any:
|
||||||
|
"""Make an authenticated JSON request. Returns parsed JSON.
|
||||||
|
|
||||||
|
If *raw* is True the response bytes are returned instead.
|
||||||
|
"""
|
||||||
|
url = f"{self.base_url}{path}"
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
headers.update(self._auth_headers())
|
||||||
|
body = json.dumps(data).encode() if data else None
|
||||||
|
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, context=_get_ssl_context()) as resp:
|
||||||
|
payload = resp.read()
|
||||||
|
if raw:
|
||||||
|
return payload
|
||||||
|
return json.loads(payload.decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 401:
|
||||||
|
_settings.clear_auth()
|
||||||
|
error_body = e.read().decode()
|
||||||
|
raise RuntimeError(f"API error {e.code}: {error_body}")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise RuntimeError(f"Connection error: {e.reason}")
|
||||||
|
|
||||||
|
def _download(self, path: str) -> bytes:
|
||||||
|
"""Download raw bytes from an API path."""
|
||||||
|
url = f"{self.base_url}{path}"
|
||||||
|
req = urllib.request.Request(url, headers=self._auth_headers(), method="GET")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, context=_get_ssl_context()) as resp:
|
||||||
|
return resp.read()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise RuntimeError(f"Download error {e.code}: {e.read().decode()}")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise RuntimeError(f"Connection error: {e.reason}")
|
||||||
|
|
||||||
|
def _upload_ods(
|
||||||
|
self, path: str, ods_bytes: bytes, filename: str = "upload.ods"
|
||||||
|
) -> Any:
|
||||||
|
"""POST an ODS file as multipart/form-data."""
|
||||||
|
boundary = "----SiloCalcUpload" + str(abs(hash(filename)))[-8:]
|
||||||
|
parts = []
|
||||||
|
parts.append(
|
||||||
|
f"--{boundary}\r\n"
|
||||||
|
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
|
||||||
|
f"Content-Type: application/vnd.oasis.opendocument.spreadsheet\r\n\r\n"
|
||||||
|
)
|
||||||
|
parts.append(ods_bytes)
|
||||||
|
parts.append(f"\r\n--{boundary}--\r\n")
|
||||||
|
|
||||||
|
body = b""
|
||||||
|
for p in parts:
|
||||||
|
body += p.encode("utf-8") if isinstance(p, str) else p
|
||||||
|
|
||||||
|
url = f"{self.base_url}{path}"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||||
|
"Content-Length": str(len(body)),
|
||||||
|
}
|
||||||
|
headers.update(self._auth_headers())
|
||||||
|
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, context=_get_ssl_context()) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise RuntimeError(f"Upload error {e.code}: {e.read().decode()}")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise RuntimeError(f"Connection error: {e.reason}")
|
||||||
|
|
||||||
|
# -- Authentication -----------------------------------------------------
|
||||||
|
|
||||||
|
def login(self, username: str, password: str) -> Dict[str, Any]:
|
||||||
|
"""Session login + create persistent API token (same flow as FreeCAD)."""
|
||||||
|
ctx = _get_ssl_context()
|
||||||
|
cookie_jar = http.cookiejar.CookieJar()
|
||||||
|
opener = urllib.request.build_opener(
|
||||||
|
urllib.request.HTTPCookieProcessor(cookie_jar),
|
||||||
|
urllib.request.HTTPSHandler(context=ctx),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 1: POST credentials to /login
|
||||||
|
login_url = f"{self._origin}/login"
|
||||||
|
form_data = urllib.parse.urlencode(
|
||||||
|
{"username": username, "password": password}
|
||||||
|
).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
login_url,
|
||||||
|
data=form_data,
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
opener.open(req)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code not in (302, 303):
|
||||||
|
raise RuntimeError(f"Login failed (HTTP {e.code})")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise RuntimeError(f"Connection error: {e.reason}")
|
||||||
|
|
||||||
|
# Step 2: Verify via /api/auth/me
|
||||||
|
me_req = urllib.request.Request(f"{self._origin}/api/auth/me", method="GET")
|
||||||
|
try:
|
||||||
|
with opener.open(me_req) as resp:
|
||||||
|
user_info = json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 401:
|
||||||
|
raise RuntimeError("Login failed: invalid username or password")
|
||||||
|
raise RuntimeError(f"Login verification failed (HTTP {e.code})")
|
||||||
|
|
||||||
|
# Step 3: Create API token
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
token_body = json.dumps(
|
||||||
|
{"name": f"LibreOffice Calc ({hostname})", "expires_in_days": 90}
|
||||||
|
).encode()
|
||||||
|
token_req = urllib.request.Request(
|
||||||
|
f"{self._origin}/api/auth/tokens",
|
||||||
|
data=token_body,
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with opener.open(token_req) as resp:
|
||||||
|
token_result = json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise RuntimeError(f"Failed to create API token (HTTP {e.code})")
|
||||||
|
|
||||||
|
raw_token = token_result.get("token", "")
|
||||||
|
if not raw_token:
|
||||||
|
raise RuntimeError("Server did not return an API token")
|
||||||
|
|
||||||
|
_settings.save_auth(
|
||||||
|
username=user_info.get("username", username),
|
||||||
|
role=user_info.get("role", ""),
|
||||||
|
source=user_info.get("auth_source", ""),
|
||||||
|
token=raw_token,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"username": user_info.get("username", username),
|
||||||
|
"role": user_info.get("role", ""),
|
||||||
|
"auth_source": user_info.get("auth_source", ""),
|
||||||
|
"token_name": token_result.get("name", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
def logout(self):
|
||||||
|
_settings.clear_auth()
|
||||||
|
|
||||||
|
def is_authenticated(self) -> bool:
|
||||||
|
cfg = _settings.load()
|
||||||
|
return bool(cfg.get("api_token") or os.environ.get("SILO_API_TOKEN"))
|
||||||
|
|
||||||
|
def get_current_user(self) -> Optional[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
return self._request("GET", "/auth/me")
|
||||||
|
except RuntimeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def check_connection(self) -> Tuple[bool, str]:
|
||||||
|
url = f"{self._origin}/health"
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
req, context=_get_ssl_context(), timeout=5
|
||||||
|
) as resp:
|
||||||
|
return True, f"OK ({resp.status})"
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return True, f"Server error ({e.code})"
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
return False, str(e.reason)
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
# -- Items --------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_item(self, part_number: str) -> Dict[str, Any]:
|
||||||
|
return self._request(
|
||||||
|
"GET", f"/items/{urllib.parse.quote(part_number, safe='')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_items(self, search: str = "", project: str = "", limit: int = 100) -> list:
|
||||||
|
params = [f"limit={limit}"]
|
||||||
|
if search:
|
||||||
|
params.append(f"search={urllib.parse.quote(search)}")
|
||||||
|
if project:
|
||||||
|
params.append(f"project={urllib.parse.quote(project)}")
|
||||||
|
return self._request("GET", "/items?" + "&".join(params))
|
||||||
|
|
||||||
|
def create_item(
|
||||||
|
self,
|
||||||
|
schema: str,
|
||||||
|
category: str,
|
||||||
|
description: str = "",
|
||||||
|
projects: Optional[List[str]] = None,
|
||||||
|
sourcing_type: str = "",
|
||||||
|
sourcing_link: str = "",
|
||||||
|
standard_cost: Optional[float] = None,
|
||||||
|
long_description: str = "",
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
data: Dict[str, Any] = {
|
||||||
|
"schema": schema,
|
||||||
|
"category": category,
|
||||||
|
"description": description,
|
||||||
|
}
|
||||||
|
if projects:
|
||||||
|
data["projects"] = projects
|
||||||
|
if sourcing_type:
|
||||||
|
data["sourcing_type"] = sourcing_type
|
||||||
|
if sourcing_link:
|
||||||
|
data["sourcing_link"] = sourcing_link
|
||||||
|
if standard_cost is not None:
|
||||||
|
data["standard_cost"] = standard_cost
|
||||||
|
if long_description:
|
||||||
|
data["long_description"] = long_description
|
||||||
|
return self._request("POST", "/items", data)
|
||||||
|
|
||||||
|
def update_item(self, part_number: str, **fields) -> Dict[str, Any]:
|
||||||
|
return self._request(
|
||||||
|
"PUT", f"/items/{urllib.parse.quote(part_number, safe='')}", fields
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Projects -----------------------------------------------------------
|
||||||
|
|
||||||
|
def get_projects(self) -> list:
|
||||||
|
return self._request("GET", "/projects")
|
||||||
|
|
||||||
|
def get_project_items(self, code: str) -> list:
|
||||||
|
return self._request(
|
||||||
|
"GET", f"/projects/{urllib.parse.quote(code, safe='')}/items"
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_item_projects(
|
||||||
|
self, part_number: str, project_codes: List[str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
return self._request(
|
||||||
|
"POST",
|
||||||
|
f"/items/{urllib.parse.quote(part_number, safe='')}/projects",
|
||||||
|
{"projects": project_codes},
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_item_projects(self, part_number: str) -> list:
|
||||||
|
return self._request(
|
||||||
|
"GET", f"/items/{urllib.parse.quote(part_number, safe='')}/projects"
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Schemas ------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_schema(self, name: str = "kindred-rd") -> Dict[str, Any]:
|
||||||
|
return self._request("GET", f"/schemas/{urllib.parse.quote(name, safe='')}")
|
||||||
|
|
||||||
|
def get_property_schema(self, name: str = "kindred-rd") -> Dict[str, Any]:
|
||||||
|
return self._request(
|
||||||
|
"GET", f"/schemas/{urllib.parse.quote(name, safe='')}/properties"
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- BOM ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_bom(self, part_number: str) -> list:
|
||||||
|
return self._request(
|
||||||
|
"GET", f"/items/{urllib.parse.quote(part_number, safe='')}/bom"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_bom_expanded(self, part_number: str, depth: int = 10) -> list:
|
||||||
|
return self._request(
|
||||||
|
"GET",
|
||||||
|
f"/items/{urllib.parse.quote(part_number, safe='')}/bom/expanded?depth={depth}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_bom_entry(
|
||||||
|
self,
|
||||||
|
parent_pn: str,
|
||||||
|
child_pn: str,
|
||||||
|
quantity: Optional[float] = None,
|
||||||
|
rel_type: str = "component",
|
||||||
|
metadata: Optional[Dict] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
data: Dict[str, Any] = {
|
||||||
|
"child_part_number": child_pn,
|
||||||
|
"rel_type": rel_type,
|
||||||
|
}
|
||||||
|
if quantity is not None:
|
||||||
|
data["quantity"] = quantity
|
||||||
|
if metadata:
|
||||||
|
data["metadata"] = metadata
|
||||||
|
return self._request(
|
||||||
|
"POST", f"/items/{urllib.parse.quote(parent_pn, safe='')}/bom", data
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_bom_entry(
|
||||||
|
self,
|
||||||
|
parent_pn: str,
|
||||||
|
child_pn: str,
|
||||||
|
quantity: Optional[float] = None,
|
||||||
|
metadata: Optional[Dict] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
data: Dict[str, Any] = {}
|
||||||
|
if quantity is not None:
|
||||||
|
data["quantity"] = quantity
|
||||||
|
if metadata:
|
||||||
|
data["metadata"] = metadata
|
||||||
|
return self._request(
|
||||||
|
"PUT",
|
||||||
|
f"/items/{urllib.parse.quote(parent_pn, safe='')}/bom/{urllib.parse.quote(child_pn, safe='')}",
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Revisions ----------------------------------------------------------
|
||||||
|
|
||||||
|
def get_revisions(self, part_number: str) -> list:
|
||||||
|
return self._request(
|
||||||
|
"GET", f"/items/{urllib.parse.quote(part_number, safe='')}/revisions"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_revision(self, part_number: str, revision: int) -> Dict[str, Any]:
|
||||||
|
return self._request(
|
||||||
|
"GET",
|
||||||
|
f"/items/{urllib.parse.quote(part_number, safe='')}/revisions/{revision}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- ODS endpoints ------------------------------------------------------
|
||||||
|
|
||||||
|
def download_bom_ods(self, part_number: str) -> bytes:
|
||||||
|
return self._download(
|
||||||
|
f"/items/{urllib.parse.quote(part_number, safe='')}/bom/export.ods"
|
||||||
|
)
|
||||||
|
|
||||||
|
def download_project_sheet(self, project_code: str) -> bytes:
|
||||||
|
return self._download(
|
||||||
|
f"/projects/{urllib.parse.quote(project_code, safe='')}/sheet.ods"
|
||||||
|
)
|
||||||
|
|
||||||
|
def upload_sheet_diff(
|
||||||
|
self, ods_bytes: bytes, filename: str = "sheet.ods"
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
return self._upload_ods("/sheets/diff", ods_bytes, filename)
|
||||||
|
|
||||||
|
# -- Part number generation ---------------------------------------------
|
||||||
|
|
||||||
|
def generate_part_number(self, schema: str, category: str) -> Dict[str, Any]:
|
||||||
|
return self._request(
|
||||||
|
"POST",
|
||||||
|
"/generate-part-number",
|
||||||
|
{"schema": schema, "category": category},
|
||||||
|
)
|
||||||
395
pkg/calc/pythonpath/silo_calc/completion_wizard.py
Normal file
395
pkg/calc/pythonpath/silo_calc/completion_wizard.py
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
"""Completion Wizard for adding new items to a BOM sheet.
|
||||||
|
|
||||||
|
Three-step guided workflow:
|
||||||
|
1. Category selection (from schema)
|
||||||
|
2. Required fields (Description, optional PN)
|
||||||
|
3. Common fields (Source, Unit Cost, QTY, Sourcing Link, category-specific properties)
|
||||||
|
|
||||||
|
If a manually entered PN already exists, the PN Conflict Resolution dialog
|
||||||
|
is shown.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from . import ai_client as _ai
|
||||||
|
from . import dialogs, sync_engine
|
||||||
|
from . import settings as _settings
|
||||||
|
from . import sheet_format as sf
|
||||||
|
from .client import SiloClient
|
||||||
|
|
||||||
|
# UNO imports
|
||||||
|
try:
|
||||||
|
import uno
|
||||||
|
|
||||||
|
_HAS_UNO = True
|
||||||
|
|
||||||
|
_HAS_UNO = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_UNO = False
|
||||||
|
|
||||||
|
# Category prefix descriptions for grouping in the picker
|
||||||
|
_PREFIX_GROUPS = {
|
||||||
|
"F": "Fasteners",
|
||||||
|
"C": "Fittings",
|
||||||
|
"R": "Motion",
|
||||||
|
"S": "Structural",
|
||||||
|
"E": "Electrical",
|
||||||
|
"M": "Mechanical",
|
||||||
|
"T": "Tooling",
|
||||||
|
"A": "Assemblies",
|
||||||
|
"P": "Purchased",
|
||||||
|
"X": "Custom Fabricated",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default sourcing type by category prefix
|
||||||
|
_DEFAULT_SOURCING = {
|
||||||
|
"A": "M", # assemblies are manufactured
|
||||||
|
"X": "M", # custom fab is manufactured
|
||||||
|
"T": "M", # tooling is manufactured
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_categories(
|
||||||
|
client: SiloClient, schema: str = "kindred-rd"
|
||||||
|
) -> List[Tuple[str, str]]:
|
||||||
|
"""Fetch category codes and descriptions from the schema.
|
||||||
|
|
||||||
|
Returns list of (code, description) tuples sorted by code.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
schema_data = client.get_schema(schema)
|
||||||
|
segments = schema_data.get("segments", [])
|
||||||
|
cat_segment = None
|
||||||
|
for seg in segments:
|
||||||
|
if seg.get("name") == "category":
|
||||||
|
cat_segment = seg
|
||||||
|
break
|
||||||
|
if cat_segment and cat_segment.get("values"):
|
||||||
|
return sorted(cat_segment["values"].items())
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _get_category_properties(
|
||||||
|
client: SiloClient, category: str, schema: str = "kindred-rd"
|
||||||
|
) -> List[str]:
|
||||||
|
"""Fetch property field names relevant to a category.
|
||||||
|
|
||||||
|
Returns the list of property keys that apply to the category's prefix group.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
prop_schema = client.get_property_schema(schema)
|
||||||
|
# prop_schema has global defaults and category-specific overrides
|
||||||
|
defaults = prop_schema.get("defaults", {})
|
||||||
|
category_props = prop_schema.get("categories", {}).get(category[:1], {})
|
||||||
|
# Merge: category-specific fields + global defaults
|
||||||
|
all_keys = set(defaults.keys())
|
||||||
|
all_keys.update(category_props.keys())
|
||||||
|
return sorted(all_keys)
|
||||||
|
except RuntimeError:
|
||||||
|
return list(sf.PROPERTY_KEY_MAP.values())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Wizard dialog (UNO)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def run_completion_wizard(
|
||||||
|
client: SiloClient,
|
||||||
|
doc,
|
||||||
|
sheet,
|
||||||
|
insert_row: int,
|
||||||
|
project_code: str = "",
|
||||||
|
schema: str = "kindred-rd",
|
||||||
|
) -> bool:
|
||||||
|
"""Run the item completion wizard. Returns True if a row was inserted.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
client : SiloClient
|
||||||
|
doc : XSpreadsheetDocument
|
||||||
|
sheet : XSpreadsheet
|
||||||
|
insert_row : int (0-based row index to insert at)
|
||||||
|
project_code : str (for auto-tagging)
|
||||||
|
schema : str
|
||||||
|
"""
|
||||||
|
if not _HAS_UNO:
|
||||||
|
return False
|
||||||
|
|
||||||
|
ctx = uno.getComponentContext()
|
||||||
|
smgr = ctx.ServiceManager
|
||||||
|
|
||||||
|
# -- Step 1: Category selection -----------------------------------------
|
||||||
|
categories = _get_categories(client, schema)
|
||||||
|
if not categories:
|
||||||
|
dialogs._msgbox(
|
||||||
|
None,
|
||||||
|
"Add Item",
|
||||||
|
"Could not fetch categories from server.",
|
||||||
|
box_type="errorbox",
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Build display list grouped by prefix
|
||||||
|
cat_display = []
|
||||||
|
for code, desc in categories:
|
||||||
|
prefix = code[0] if code else "?"
|
||||||
|
group = _PREFIX_GROUPS.get(prefix, "Other")
|
||||||
|
cat_display.append(f"{code} - {desc} [{group}]")
|
||||||
|
|
||||||
|
# Use a simple input box with the category list as hint
|
||||||
|
# (A proper ListBox dialog would be more polished but this is functional)
|
||||||
|
cat_hint = ", ".join(c[0] for c in categories[:20])
|
||||||
|
if len(categories) > 20:
|
||||||
|
cat_hint += f"... ({len(categories)} total)"
|
||||||
|
|
||||||
|
category_input = dialogs._input_box(
|
||||||
|
"Add Item - Step 1/3",
|
||||||
|
f"Category code ({cat_hint}):",
|
||||||
|
)
|
||||||
|
if not category_input:
|
||||||
|
return False
|
||||||
|
category = category_input.strip().upper()
|
||||||
|
|
||||||
|
# Validate category
|
||||||
|
valid_codes = {c[0] for c in categories}
|
||||||
|
if category not in valid_codes:
|
||||||
|
dialogs._msgbox(
|
||||||
|
None,
|
||||||
|
"Add Item",
|
||||||
|
f"Unknown category: {category}",
|
||||||
|
box_type="errorbox",
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -- Step 2: Required fields --------------------------------------------
|
||||||
|
description = dialogs._input_box(
|
||||||
|
"Add Item - Step 2/3",
|
||||||
|
"Description (required, leave blank to use AI):",
|
||||||
|
)
|
||||||
|
|
||||||
|
# If blank and AI is configured, offer AI generation from seller description
|
||||||
|
if (not description or not description.strip()) and _ai.is_configured():
|
||||||
|
seller_desc = dialogs._input_box(
|
||||||
|
"Add Item - AI Description",
|
||||||
|
"Paste the seller description for AI generation:",
|
||||||
|
)
|
||||||
|
if seller_desc and seller_desc.strip():
|
||||||
|
try:
|
||||||
|
ai_desc = _ai.generate_description(
|
||||||
|
seller_description=seller_desc.strip(),
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
accepted = dialogs.show_ai_description_dialog(
|
||||||
|
seller_desc.strip(), ai_desc
|
||||||
|
)
|
||||||
|
if accepted:
|
||||||
|
description = accepted
|
||||||
|
except RuntimeError as e:
|
||||||
|
dialogs._msgbox(
|
||||||
|
None,
|
||||||
|
"AI Description Failed",
|
||||||
|
str(e),
|
||||||
|
box_type="errorbox",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not description or not description.strip():
|
||||||
|
dialogs._msgbox(
|
||||||
|
None, "Add Item", "Description is required.", box_type="errorbox"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
manual_pn = dialogs._input_box(
|
||||||
|
"Add Item - Step 2/3",
|
||||||
|
"Part number (leave blank for auto-generation):",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for PN conflict if user entered one
|
||||||
|
use_existing_item = None
|
||||||
|
if manual_pn and manual_pn.strip():
|
||||||
|
manual_pn = manual_pn.strip()
|
||||||
|
try:
|
||||||
|
existing = client.get_item(manual_pn)
|
||||||
|
# PN exists -- show conflict dialog
|
||||||
|
result = dialogs.show_pn_conflict_dialog(manual_pn, existing)
|
||||||
|
if result == dialogs.PN_USE_EXISTING:
|
||||||
|
use_existing_item = existing
|
||||||
|
elif result == dialogs.PN_CREATE_NEW:
|
||||||
|
manual_pn = "" # will auto-generate
|
||||||
|
else:
|
||||||
|
return False # cancelled
|
||||||
|
except RuntimeError:
|
||||||
|
pass # PN doesn't exist, which is fine
|
||||||
|
|
||||||
|
# -- Step 3: Common fields ----------------------------------------------
|
||||||
|
prefix = category[0] if category else ""
|
||||||
|
default_source = _DEFAULT_SOURCING.get(prefix, "P")
|
||||||
|
|
||||||
|
source = dialogs._input_box(
|
||||||
|
"Add Item - Step 3/3",
|
||||||
|
f"Sourcing type (M=manufactured, P=purchased) [default: {default_source}]:",
|
||||||
|
default=default_source,
|
||||||
|
)
|
||||||
|
if source is None:
|
||||||
|
return False
|
||||||
|
source = source.strip().upper() or default_source
|
||||||
|
|
||||||
|
unit_cost_str = dialogs._input_box(
|
||||||
|
"Add Item - Step 3/3",
|
||||||
|
"Unit cost (e.g. 10.50):",
|
||||||
|
default="0",
|
||||||
|
)
|
||||||
|
unit_cost = 0.0
|
||||||
|
if unit_cost_str:
|
||||||
|
try:
|
||||||
|
unit_cost = float(unit_cost_str.strip().replace("$", "").replace(",", ""))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
qty_str = dialogs._input_box(
|
||||||
|
"Add Item - Step 3/3",
|
||||||
|
"Quantity [default: 1]:",
|
||||||
|
default="1",
|
||||||
|
)
|
||||||
|
qty = 1.0
|
||||||
|
if qty_str:
|
||||||
|
try:
|
||||||
|
qty = float(qty_str.strip())
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
sourcing_link = (
|
||||||
|
dialogs._input_box(
|
||||||
|
"Add Item - Step 3/3",
|
||||||
|
"Sourcing link (URL, optional):",
|
||||||
|
)
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Create item or use existing ----------------------------------------
|
||||||
|
created_item = None
|
||||||
|
if use_existing_item:
|
||||||
|
# Use the existing item's data
|
||||||
|
created_item = use_existing_item
|
||||||
|
final_pn = use_existing_item.get("part_number", manual_pn)
|
||||||
|
elif manual_pn:
|
||||||
|
# Create with the user's manual PN
|
||||||
|
try:
|
||||||
|
created_item = client.create_item(
|
||||||
|
schema=schema,
|
||||||
|
category=category,
|
||||||
|
description=description.strip(),
|
||||||
|
projects=[project_code] if project_code else None,
|
||||||
|
sourcing_type=source,
|
||||||
|
sourcing_link=sourcing_link.strip(),
|
||||||
|
standard_cost=unit_cost if unit_cost else None,
|
||||||
|
)
|
||||||
|
final_pn = created_item.get("part_number", manual_pn)
|
||||||
|
except RuntimeError as e:
|
||||||
|
dialogs._msgbox(None, "Add Item Failed", str(e), box_type="errorbox")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# Auto-generate PN
|
||||||
|
try:
|
||||||
|
created_item = client.create_item(
|
||||||
|
schema=schema,
|
||||||
|
category=category,
|
||||||
|
description=description.strip(),
|
||||||
|
projects=[project_code] if project_code else None,
|
||||||
|
sourcing_type=source,
|
||||||
|
sourcing_link=sourcing_link.strip(),
|
||||||
|
standard_cost=unit_cost if unit_cost else None,
|
||||||
|
)
|
||||||
|
final_pn = created_item.get("part_number", "")
|
||||||
|
except RuntimeError as e:
|
||||||
|
dialogs._msgbox(None, "Add Item Failed", str(e), box_type="errorbox")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not final_pn:
|
||||||
|
dialogs._msgbox(
|
||||||
|
None, "Add Item", "No part number returned.", box_type="errorbox"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Auto-tag with project if needed
|
||||||
|
if project_code and created_item and not use_existing_item:
|
||||||
|
try:
|
||||||
|
client.add_item_projects(final_pn, [project_code])
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -- Insert row into sheet ----------------------------------------------
|
||||||
|
_insert_bom_row(
|
||||||
|
sheet,
|
||||||
|
insert_row,
|
||||||
|
pn=final_pn,
|
||||||
|
description=created_item.get("description", description.strip())
|
||||||
|
if created_item
|
||||||
|
else description.strip(),
|
||||||
|
unit_cost=unit_cost,
|
||||||
|
qty=qty,
|
||||||
|
sourcing_link=sourcing_link.strip(),
|
||||||
|
schema=schema,
|
||||||
|
status=sync_engine.STATUS_NEW,
|
||||||
|
parent_pn="",
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_bom_row(
|
||||||
|
sheet,
|
||||||
|
row: int,
|
||||||
|
pn: str,
|
||||||
|
description: str,
|
||||||
|
source: str,
|
||||||
|
unit_cost: float,
|
||||||
|
qty: float,
|
||||||
|
sourcing_link: str,
|
||||||
|
schema: str,
|
||||||
|
status: str,
|
||||||
|
parent_pn: str,
|
||||||
|
):
|
||||||
|
"""Write a single BOM row at the given position with sync tracking."""
|
||||||
|
from . import pull as _pull # avoid circular import at module level
|
||||||
|
|
||||||
|
_pull._set_cell_string(sheet, sf.COL_ITEM, row, "")
|
||||||
|
_pull._set_cell_string(sheet, sf.COL_LEVEL, row, "")
|
||||||
|
_pull._set_cell_string(sheet, sf.COL_SOURCE, row, source)
|
||||||
|
_pull._set_cell_string(sheet, sf.COL_PN, row, pn)
|
||||||
|
_pull._set_cell_string(sheet, sf.COL_DESCRIPTION, row, description)
|
||||||
|
_pull._set_cell_string(sheet, sf.COL_SELLER_DESC, row, "")
|
||||||
|
|
||||||
|
if unit_cost:
|
||||||
|
_pull._set_cell_float(sheet, sf.COL_UNIT_COST, row, unit_cost)
|
||||||
|
_pull._set_cell_float(sheet, sf.COL_QTY, row, qty)
|
||||||
|
|
||||||
|
# Ext Cost formula
|
||||||
|
ext_formula = f"={sf.col_letter(sf.COL_UNIT_COST)}{row + 1}*{sf.col_letter(sf.COL_QTY)}{row + 1}"
|
||||||
|
_pull._set_cell_formula(sheet, sf.COL_EXT_COST, row, ext_formula)
|
||||||
|
|
||||||
|
_pull._set_cell_string(sheet, sf.COL_SOURCING_LINK, row, sourcing_link)
|
||||||
|
_pull._set_cell_string(sheet, sf.COL_SCHEMA, row, schema)
|
||||||
|
|
||||||
|
# Build row cells for hash computation
|
||||||
|
row_cells = [""] * sf.BOM_TOTAL_COLS
|
||||||
|
row_cells[sf.COL_SOURCE] = source
|
||||||
|
row_cells[sf.COL_PN] = pn
|
||||||
|
row_cells[sf.COL_DESCRIPTION] = description
|
||||||
|
row_cells[sf.COL_UNIT_COST] = str(unit_cost) if unit_cost else ""
|
||||||
|
row_cells[sf.COL_QTY] = str(qty)
|
||||||
|
row_cells[sf.COL_SOURCING_LINK] = sourcing_link
|
||||||
|
row_cells[sf.COL_SCHEMA] = schema
|
||||||
|
|
||||||
|
sync_engine.update_row_sync_state(row_cells, status, parent_pn=parent_pn)
|
||||||
|
_pull._set_cell_string(sheet, sf.COL_ROW_HASH, row, row_cells[sf.COL_ROW_HASH])
|
||||||
|
_pull._set_cell_string(sheet, sf.COL_ROW_STATUS, row, row_cells[sf.COL_ROW_STATUS])
|
||||||
|
_pull._set_cell_string(sheet, sf.COL_UPDATED_AT, row, row_cells[sf.COL_UPDATED_AT])
|
||||||
|
_pull._set_cell_string(sheet, sf.COL_PARENT_PN, row, row_cells[sf.COL_PARENT_PN])
|
||||||
|
|
||||||
|
# Colour the row
|
||||||
|
color = _pull._STATUS_COLORS.get(status)
|
||||||
|
if color:
|
||||||
|
_pull._set_row_bg(sheet, row, sf.BOM_TOTAL_COLS, color)
|
||||||
667
pkg/calc/pythonpath/silo_calc/dialogs.py
Normal file
667
pkg/calc/pythonpath/silo_calc/dialogs.py
Normal file
@@ -0,0 +1,667 @@
|
|||||||
|
"""UNO dialogs for the Silo Calc extension.
|
||||||
|
|
||||||
|
Provides login, settings, push summary, and PN conflict resolution dialogs.
|
||||||
|
All dialogs use the UNO dialog toolkit (``com.sun.star.awt``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
# UNO imports are only available inside LibreOffice
|
||||||
|
try:
|
||||||
|
import uno
|
||||||
|
|
||||||
|
_HAS_UNO = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_UNO = False
|
||||||
|
|
||||||
|
from . import settings as _settings
|
||||||
|
from .client import SiloClient
|
||||||
|
|
||||||
|
|
||||||
|
def _get_desktop():
|
||||||
|
"""Return the XSCRIPTCONTEXT desktop, or resolve via component context."""
|
||||||
|
ctx = uno.getComponentContext()
|
||||||
|
smgr = ctx.ServiceManager
|
||||||
|
return smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def _msgbox(parent, title: str, message: str, box_type="infobox"):
|
||||||
|
"""Show a simple message box."""
|
||||||
|
if not _HAS_UNO:
|
||||||
|
return
|
||||||
|
ctx = uno.getComponentContext()
|
||||||
|
smgr = ctx.ServiceManager
|
||||||
|
toolkit = smgr.createInstanceWithContext("com.sun.star.awt.Toolkit", ctx)
|
||||||
|
if parent is None:
|
||||||
|
parent = _get_desktop().getCurrentFrame().getContainerWindow()
|
||||||
|
mbt = uno.Enum(
|
||||||
|
"com.sun.star.awt.MessageBoxType",
|
||||||
|
"INFOBOX" if box_type == "infobox" else "ERRORBOX",
|
||||||
|
)
|
||||||
|
msg_box = toolkit.createMessageBox(parent, mbt, 1, title, message)
|
||||||
|
msg_box.execute()
|
||||||
|
|
||||||
|
|
||||||
|
def _input_box(
|
||||||
|
title: str, label: str, default: str = "", password: bool = False
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Show a simple single-field input dialog. Returns None on cancel."""
|
||||||
|
if not _HAS_UNO:
|
||||||
|
return None
|
||||||
|
ctx = uno.getComponentContext()
|
||||||
|
smgr = ctx.ServiceManager
|
||||||
|
dlg_provider = smgr.createInstanceWithContext(
|
||||||
|
"com.sun.star.awt.DialogProvider", ctx
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build dialog model programmatically
|
||||||
|
dlg_model = smgr.createInstanceWithContext(
|
||||||
|
"com.sun.star.awt.UnoControlDialogModel", ctx
|
||||||
|
)
|
||||||
|
dlg_model.Width = 220
|
||||||
|
dlg_model.Height = 80
|
||||||
|
dlg_model.Title = title
|
||||||
|
|
||||||
|
# Label
|
||||||
|
lbl = dlg_model.createInstance("com.sun.star.awt.UnoControlFixedTextModel")
|
||||||
|
lbl.Name = "lbl"
|
||||||
|
lbl.PositionX = 10
|
||||||
|
lbl.PositionY = 10
|
||||||
|
lbl.Width = 200
|
||||||
|
lbl.Height = 12
|
||||||
|
lbl.Label = label
|
||||||
|
dlg_model.insertByName("lbl", lbl)
|
||||||
|
|
||||||
|
# Text field
|
||||||
|
tf = dlg_model.createInstance("com.sun.star.awt.UnoControlEditModel")
|
||||||
|
tf.Name = "tf"
|
||||||
|
tf.PositionX = 10
|
||||||
|
tf.PositionY = 24
|
||||||
|
tf.Width = 200
|
||||||
|
tf.Height = 14
|
||||||
|
tf.Text = default
|
||||||
|
if password:
|
||||||
|
tf.EchoChar = ord("*")
|
||||||
|
dlg_model.insertByName("tf", tf)
|
||||||
|
|
||||||
|
# OK button
|
||||||
|
btn_ok = dlg_model.createInstance("com.sun.star.awt.UnoControlButtonModel")
|
||||||
|
btn_ok.Name = "btn_ok"
|
||||||
|
btn_ok.PositionX = 110
|
||||||
|
btn_ok.PositionY = 50
|
||||||
|
btn_ok.Width = 45
|
||||||
|
btn_ok.Height = 16
|
||||||
|
btn_ok.Label = "OK"
|
||||||
|
btn_ok.PushButtonType = 1 # OK
|
||||||
|
dlg_model.insertByName("btn_ok", btn_ok)
|
||||||
|
|
||||||
|
# Cancel button
|
||||||
|
btn_cancel = dlg_model.createInstance("com.sun.star.awt.UnoControlButtonModel")
|
||||||
|
btn_cancel.Name = "btn_cancel"
|
||||||
|
btn_cancel.PositionX = 160
|
||||||
|
btn_cancel.PositionY = 50
|
||||||
|
btn_cancel.Width = 45
|
||||||
|
btn_cancel.Height = 16
|
||||||
|
btn_cancel.Label = "Cancel"
|
||||||
|
btn_cancel.PushButtonType = 2 # CANCEL
|
||||||
|
dlg_model.insertByName("btn_cancel", btn_cancel)
|
||||||
|
|
||||||
|
# Create dialog control
|
||||||
|
dlg = smgr.createInstanceWithContext("com.sun.star.awt.UnoControlDialog", ctx)
|
||||||
|
dlg.setModel(dlg_model)
|
||||||
|
dlg.setVisible(False)
|
||||||
|
toolkit = smgr.createInstanceWithContext("com.sun.star.awt.Toolkit", ctx)
|
||||||
|
dlg.createPeer(toolkit, None)
|
||||||
|
|
||||||
|
result = dlg.execute()
|
||||||
|
if result == 1: # OK
|
||||||
|
text = dlg.getControl("tf").getText()
|
||||||
|
dlg.dispose()
|
||||||
|
return text
|
||||||
|
dlg.dispose()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Login dialog
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def show_login_dialog(parent=None) -> bool:
|
||||||
|
"""Two-step login: username then password. Returns True on success."""
|
||||||
|
username = _input_box("Silo Login", "Username:")
|
||||||
|
if not username:
|
||||||
|
return False
|
||||||
|
password = _input_box("Silo Login", f"Password for {username}:", password=True)
|
||||||
|
if not password:
|
||||||
|
return False
|
||||||
|
|
||||||
|
client = SiloClient()
|
||||||
|
try:
|
||||||
|
result = client.login(username, password)
|
||||||
|
_msgbox(
|
||||||
|
parent,
|
||||||
|
"Silo Login",
|
||||||
|
f"Logged in as {result['username']} ({result.get('role', 'viewer')})",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except RuntimeError as e:
|
||||||
|
_msgbox(parent, "Silo Login Failed", str(e), box_type="errorbox")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Settings dialog
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def show_settings_dialog(parent=None) -> bool:
|
||||||
|
"""Show the settings dialog. Returns True if saved."""
|
||||||
|
if not _HAS_UNO:
|
||||||
|
return False
|
||||||
|
|
||||||
|
ctx = uno.getComponentContext()
|
||||||
|
smgr = ctx.ServiceManager
|
||||||
|
cfg = _settings.load()
|
||||||
|
|
||||||
|
dlg_model = smgr.createInstanceWithContext(
|
||||||
|
"com.sun.star.awt.UnoControlDialogModel", ctx
|
||||||
|
)
|
||||||
|
dlg_model.Width = 300
|
||||||
|
dlg_model.Height = 200
|
||||||
|
dlg_model.Title = "Silo Settings"
|
||||||
|
|
||||||
|
fields = [
|
||||||
|
("API URL", "api_url", cfg.get("api_url", "")),
|
||||||
|
("API Token", "api_token", cfg.get("api_token", "")),
|
||||||
|
("SSL Cert Path", "ssl_cert_path", cfg.get("ssl_cert_path", "")),
|
||||||
|
("Projects Dir", "projects_dir", cfg.get("projects_dir", "")),
|
||||||
|
("Default Schema", "default_schema", cfg.get("default_schema", "kindred-rd")),
|
||||||
|
]
|
||||||
|
|
||||||
|
y = 10
|
||||||
|
for label_text, name, default in fields:
|
||||||
|
lbl = dlg_model.createInstance("com.sun.star.awt.UnoControlFixedTextModel")
|
||||||
|
lbl.Name = f"lbl_{name}"
|
||||||
|
lbl.PositionX = 10
|
||||||
|
lbl.PositionY = y
|
||||||
|
lbl.Width = 80
|
||||||
|
lbl.Height = 12
|
||||||
|
lbl.Label = label_text
|
||||||
|
dlg_model.insertByName(f"lbl_{name}", lbl)
|
||||||
|
|
||||||
|
tf = dlg_model.createInstance("com.sun.star.awt.UnoControlEditModel")
|
||||||
|
tf.Name = f"tf_{name}"
|
||||||
|
tf.PositionX = 95
|
||||||
|
tf.PositionY = y
|
||||||
|
tf.Width = 195
|
||||||
|
tf.Height = 14
|
||||||
|
tf.Text = default
|
||||||
|
dlg_model.insertByName(f"tf_{name}", tf)
|
||||||
|
y += 22
|
||||||
|
|
||||||
|
# SSL verify checkbox
|
||||||
|
cb = dlg_model.createInstance("com.sun.star.awt.UnoControlCheckBoxModel")
|
||||||
|
cb.Name = "cb_ssl_verify"
|
||||||
|
cb.PositionX = 95
|
||||||
|
cb.PositionY = y
|
||||||
|
cb.Width = 120
|
||||||
|
cb.Height = 14
|
||||||
|
cb.Label = "Verify SSL"
|
||||||
|
cb.State = 1 if cfg.get("ssl_verify", True) else 0
|
||||||
|
dlg_model.insertByName("cb_ssl_verify", cb)
|
||||||
|
y += 22
|
||||||
|
|
||||||
|
# --- OpenRouter AI section ---
|
||||||
|
lbl_ai = dlg_model.createInstance("com.sun.star.awt.UnoControlFixedTextModel")
|
||||||
|
lbl_ai.Name = "lbl_ai_section"
|
||||||
|
lbl_ai.PositionX = 10
|
||||||
|
lbl_ai.PositionY = y
|
||||||
|
lbl_ai.Width = 280
|
||||||
|
lbl_ai.Height = 12
|
||||||
|
lbl_ai.Label = "--- OpenRouter AI ---"
|
||||||
|
dlg_model.insertByName("lbl_ai_section", lbl_ai)
|
||||||
|
y += 16
|
||||||
|
|
||||||
|
# API Key (masked)
|
||||||
|
lbl_key = dlg_model.createInstance("com.sun.star.awt.UnoControlFixedTextModel")
|
||||||
|
lbl_key.Name = "lbl_openrouter_api_key"
|
||||||
|
lbl_key.PositionX = 10
|
||||||
|
lbl_key.PositionY = y
|
||||||
|
lbl_key.Width = 80
|
||||||
|
lbl_key.Height = 12
|
||||||
|
lbl_key.Label = "API Key"
|
||||||
|
dlg_model.insertByName("lbl_openrouter_api_key", lbl_key)
|
||||||
|
|
||||||
|
tf_key = dlg_model.createInstance("com.sun.star.awt.UnoControlEditModel")
|
||||||
|
tf_key.Name = "tf_openrouter_api_key"
|
||||||
|
tf_key.PositionX = 95
|
||||||
|
tf_key.PositionY = y
|
||||||
|
tf_key.Width = 195
|
||||||
|
tf_key.Height = 14
|
||||||
|
tf_key.Text = cfg.get("openrouter_api_key", "")
|
||||||
|
tf_key.EchoChar = ord("*")
|
||||||
|
dlg_model.insertByName("tf_openrouter_api_key", tf_key)
|
||||||
|
y += 22
|
||||||
|
|
||||||
|
# AI Model
|
||||||
|
lbl_model = dlg_model.createInstance("com.sun.star.awt.UnoControlFixedTextModel")
|
||||||
|
lbl_model.Name = "lbl_openrouter_model"
|
||||||
|
lbl_model.PositionX = 10
|
||||||
|
lbl_model.PositionY = y
|
||||||
|
lbl_model.Width = 80
|
||||||
|
lbl_model.Height = 12
|
||||||
|
lbl_model.Label = "AI Model"
|
||||||
|
dlg_model.insertByName("lbl_openrouter_model", lbl_model)
|
||||||
|
|
||||||
|
tf_model = dlg_model.createInstance("com.sun.star.awt.UnoControlEditModel")
|
||||||
|
tf_model.Name = "tf_openrouter_model"
|
||||||
|
tf_model.PositionX = 95
|
||||||
|
tf_model.PositionY = y
|
||||||
|
tf_model.Width = 195
|
||||||
|
tf_model.Height = 14
|
||||||
|
tf_model.Text = cfg.get("openrouter_model", "")
|
||||||
|
tf_model.HelpText = "openai/gpt-4.1-nano"
|
||||||
|
dlg_model.insertByName("tf_openrouter_model", tf_model)
|
||||||
|
y += 22
|
||||||
|
|
||||||
|
# AI Instructions (multi-line)
|
||||||
|
lbl_instr = dlg_model.createInstance("com.sun.star.awt.UnoControlFixedTextModel")
|
||||||
|
lbl_instr.Name = "lbl_openrouter_instructions"
|
||||||
|
lbl_instr.PositionX = 10
|
||||||
|
lbl_instr.PositionY = y
|
||||||
|
lbl_instr.Width = 80
|
||||||
|
lbl_instr.Height = 12
|
||||||
|
lbl_instr.Label = "AI Instructions"
|
||||||
|
dlg_model.insertByName("lbl_openrouter_instructions", lbl_instr)
|
||||||
|
|
||||||
|
tf_instr = dlg_model.createInstance("com.sun.star.awt.UnoControlEditModel")
|
||||||
|
tf_instr.Name = "tf_openrouter_instructions"
|
||||||
|
tf_instr.PositionX = 95
|
||||||
|
tf_instr.PositionY = y
|
||||||
|
tf_instr.Width = 195
|
||||||
|
tf_instr.Height = 56
|
||||||
|
tf_instr.Text = cfg.get("openrouter_instructions", "")
|
||||||
|
tf_instr.MultiLine = True
|
||||||
|
tf_instr.VScroll = True
|
||||||
|
tf_instr.HelpText = "Custom system prompt (leave blank for default)"
|
||||||
|
dlg_model.insertByName("tf_openrouter_instructions", tf_instr)
|
||||||
|
y += 62
|
||||||
|
|
||||||
|
# Test connection button
|
||||||
|
btn_test = dlg_model.createInstance("com.sun.star.awt.UnoControlButtonModel")
|
||||||
|
btn_test.Name = "btn_test"
|
||||||
|
btn_test.PositionX = 10
|
||||||
|
btn_test.PositionY = y
|
||||||
|
btn_test.Width = 80
|
||||||
|
btn_test.Height = 16
|
||||||
|
btn_test.Label = "Test Connection"
|
||||||
|
dlg_model.insertByName("btn_test", btn_test)
|
||||||
|
|
||||||
|
# Status label
|
||||||
|
lbl_status = dlg_model.createInstance("com.sun.star.awt.UnoControlFixedTextModel")
|
||||||
|
lbl_status.Name = "lbl_status"
|
||||||
|
lbl_status.PositionX = 95
|
||||||
|
lbl_status.PositionY = y + 2
|
||||||
|
lbl_status.Width = 195
|
||||||
|
lbl_status.Height = 12
|
||||||
|
lbl_status.Label = ""
|
||||||
|
dlg_model.insertByName("lbl_status", lbl_status)
|
||||||
|
y += 22
|
||||||
|
|
||||||
|
# OK / Cancel
|
||||||
|
btn_ok = dlg_model.createInstance("com.sun.star.awt.UnoControlButtonModel")
|
||||||
|
btn_ok.Name = "btn_ok"
|
||||||
|
btn_ok.PositionX = 190
|
||||||
|
btn_ok.PositionY = y
|
||||||
|
btn_ok.Width = 45
|
||||||
|
btn_ok.Height = 16
|
||||||
|
btn_ok.Label = "Save"
|
||||||
|
btn_ok.PushButtonType = 1
|
||||||
|
dlg_model.insertByName("btn_ok", btn_ok)
|
||||||
|
|
||||||
|
btn_cancel = dlg_model.createInstance("com.sun.star.awt.UnoControlButtonModel")
|
||||||
|
btn_cancel.Name = "btn_cancel"
|
||||||
|
btn_cancel.PositionX = 240
|
||||||
|
btn_cancel.PositionY = y
|
||||||
|
btn_cancel.Width = 45
|
||||||
|
btn_cancel.Height = 16
|
||||||
|
btn_cancel.Label = "Cancel"
|
||||||
|
btn_cancel.PushButtonType = 2
|
||||||
|
dlg_model.insertByName("btn_cancel", btn_cancel)
|
||||||
|
|
||||||
|
dlg_model.Height = y + 26
|
||||||
|
|
||||||
|
dlg = smgr.createInstanceWithContext("com.sun.star.awt.UnoControlDialog", ctx)
|
||||||
|
dlg.setModel(dlg_model)
|
||||||
|
dlg.setVisible(False)
|
||||||
|
toolkit = smgr.createInstanceWithContext("com.sun.star.awt.Toolkit", ctx)
|
||||||
|
dlg.createPeer(toolkit, None)
|
||||||
|
|
||||||
|
result = dlg.execute()
|
||||||
|
if result == 1:
|
||||||
|
for _, name, _ in fields:
|
||||||
|
cfg[name] = dlg.getControl(f"tf_{name}").getText()
|
||||||
|
cfg["ssl_verify"] = bool(dlg.getControl("cb_ssl_verify").getModel().State)
|
||||||
|
cfg["openrouter_api_key"] = dlg.getControl("tf_openrouter_api_key").getText()
|
||||||
|
cfg["openrouter_model"] = dlg.getControl("tf_openrouter_model").getText()
|
||||||
|
cfg["openrouter_instructions"] = dlg.getControl(
|
||||||
|
"tf_openrouter_instructions"
|
||||||
|
).getText()
|
||||||
|
_settings.save(cfg)
|
||||||
|
dlg.dispose()
|
||||||
|
return True
|
||||||
|
|
||||||
|
dlg.dispose()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Push summary dialog
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def show_push_summary(
|
||||||
|
new_count: int,
|
||||||
|
modified_count: int,
|
||||||
|
conflict_count: int,
|
||||||
|
unchanged_count: int,
|
||||||
|
parent=None,
|
||||||
|
) -> bool:
|
||||||
|
"""Show push summary and return True if user confirms."""
|
||||||
|
lines = [
|
||||||
|
f"New items: {new_count}",
|
||||||
|
f"Modified items: {modified_count}",
|
||||||
|
f"Conflicts: {conflict_count}",
|
||||||
|
f"Unchanged: {unchanged_count}",
|
||||||
|
]
|
||||||
|
if conflict_count:
|
||||||
|
lines.append("\nConflicts must be resolved before pushing.")
|
||||||
|
|
||||||
|
msg = "\n".join(lines)
|
||||||
|
if conflict_count:
|
||||||
|
_msgbox(parent, "Silo Push -- Conflicts Found", msg, box_type="errorbox")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if new_count == 0 and modified_count == 0:
|
||||||
|
_msgbox(parent, "Silo Push", "Nothing to push -- all rows are up to date.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Confirmation -- for now use a simple info box (OK = proceed)
|
||||||
|
_msgbox(parent, "Silo Push", f"Ready to push:\n\n{msg}\n\nProceed?")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PN Conflict Resolution dialog
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Return values
|
||||||
|
PN_USE_EXISTING = "use_existing"
|
||||||
|
PN_CREATE_NEW = "create_new"
|
||||||
|
PN_CANCEL = "cancel"
|
||||||
|
|
||||||
|
|
||||||
|
def show_pn_conflict_dialog(
|
||||||
|
part_number: str,
|
||||||
|
existing_item: Dict[str, Any],
|
||||||
|
parent=None,
|
||||||
|
) -> str:
|
||||||
|
"""Show PN conflict dialog when a manually entered PN already exists.
|
||||||
|
|
||||||
|
Returns one of: PN_USE_EXISTING, PN_CREATE_NEW, PN_CANCEL.
|
||||||
|
"""
|
||||||
|
if not _HAS_UNO:
|
||||||
|
return PN_CANCEL
|
||||||
|
|
||||||
|
ctx = uno.getComponentContext()
|
||||||
|
smgr = ctx.ServiceManager
|
||||||
|
|
||||||
|
dlg_model = smgr.createInstanceWithContext(
|
||||||
|
"com.sun.star.awt.UnoControlDialogModel", ctx
|
||||||
|
)
|
||||||
|
dlg_model.Width = 320
|
||||||
|
dlg_model.Height = 220
|
||||||
|
dlg_model.Title = f"Part Number Conflict: {part_number}"
|
||||||
|
|
||||||
|
y = 10
|
||||||
|
info_lines = [
|
||||||
|
"This part number already exists in Silo:",
|
||||||
|
"",
|
||||||
|
f" Description: {existing_item.get('description', '')}",
|
||||||
|
f" Type: {existing_item.get('item_type', '')}",
|
||||||
|
f" Category: {existing_item.get('part_number', '')[:3]}",
|
||||||
|
f" Sourcing: {existing_item.get('sourcing_type', '')}",
|
||||||
|
f" Cost: ${existing_item.get('standard_cost', 0):.2f}",
|
||||||
|
]
|
||||||
|
|
||||||
|
for line in info_lines:
|
||||||
|
lbl = dlg_model.createInstance("com.sun.star.awt.UnoControlFixedTextModel")
|
||||||
|
lbl.Name = f"info_{y}"
|
||||||
|
lbl.PositionX = 10
|
||||||
|
lbl.PositionY = y
|
||||||
|
lbl.Width = 300
|
||||||
|
lbl.Height = 12
|
||||||
|
lbl.Label = line
|
||||||
|
dlg_model.insertByName(f"info_{y}", lbl)
|
||||||
|
y += 13
|
||||||
|
|
||||||
|
y += 5
|
||||||
|
|
||||||
|
# Radio buttons
|
||||||
|
rb_use = dlg_model.createInstance("com.sun.star.awt.UnoControlRadioButtonModel")
|
||||||
|
rb_use.Name = "rb_use"
|
||||||
|
rb_use.PositionX = 20
|
||||||
|
rb_use.PositionY = y
|
||||||
|
rb_use.Width = 280
|
||||||
|
rb_use.Height = 14
|
||||||
|
rb_use.Label = "Use existing item (add to BOM)"
|
||||||
|
rb_use.State = 1 # selected by default
|
||||||
|
dlg_model.insertByName("rb_use", rb_use)
|
||||||
|
y += 18
|
||||||
|
|
||||||
|
rb_new = dlg_model.createInstance("com.sun.star.awt.UnoControlRadioButtonModel")
|
||||||
|
rb_new.Name = "rb_new"
|
||||||
|
rb_new.PositionX = 20
|
||||||
|
rb_new.PositionY = y
|
||||||
|
rb_new.Width = 280
|
||||||
|
rb_new.Height = 14
|
||||||
|
rb_new.Label = "Create new item (auto-generate PN)"
|
||||||
|
rb_new.State = 0
|
||||||
|
dlg_model.insertByName("rb_new", rb_new)
|
||||||
|
y += 18
|
||||||
|
|
||||||
|
rb_cancel = dlg_model.createInstance("com.sun.star.awt.UnoControlRadioButtonModel")
|
||||||
|
rb_cancel.Name = "rb_cancel"
|
||||||
|
rb_cancel.PositionX = 20
|
||||||
|
rb_cancel.PositionY = y
|
||||||
|
rb_cancel.Width = 280
|
||||||
|
rb_cancel.Height = 14
|
||||||
|
rb_cancel.Label = "Cancel"
|
||||||
|
rb_cancel.State = 0
|
||||||
|
dlg_model.insertByName("rb_cancel", rb_cancel)
|
||||||
|
y += 25
|
||||||
|
|
||||||
|
# OK button
|
||||||
|
btn_ok = dlg_model.createInstance("com.sun.star.awt.UnoControlButtonModel")
|
||||||
|
btn_ok.Name = "btn_ok"
|
||||||
|
btn_ok.PositionX = 210
|
||||||
|
btn_ok.PositionY = y
|
||||||
|
btn_ok.Width = 45
|
||||||
|
btn_ok.Height = 16
|
||||||
|
btn_ok.Label = "OK"
|
||||||
|
btn_ok.PushButtonType = 1
|
||||||
|
dlg_model.insertByName("btn_ok", btn_ok)
|
||||||
|
|
||||||
|
btn_cancel_btn = dlg_model.createInstance("com.sun.star.awt.UnoControlButtonModel")
|
||||||
|
btn_cancel_btn.Name = "btn_cancel_btn"
|
||||||
|
btn_cancel_btn.PositionX = 260
|
||||||
|
btn_cancel_btn.PositionY = y
|
||||||
|
btn_cancel_btn.Width = 45
|
||||||
|
btn_cancel_btn.Height = 16
|
||||||
|
btn_cancel_btn.Label = "Cancel"
|
||||||
|
btn_cancel_btn.PushButtonType = 2
|
||||||
|
dlg_model.insertByName("btn_cancel_btn", btn_cancel_btn)
|
||||||
|
|
||||||
|
dlg_model.Height = y + 26
|
||||||
|
|
||||||
|
dlg = smgr.createInstanceWithContext("com.sun.star.awt.UnoControlDialog", ctx)
|
||||||
|
dlg.setModel(dlg_model)
|
||||||
|
dlg.setVisible(False)
|
||||||
|
toolkit = smgr.createInstanceWithContext("com.sun.star.awt.Toolkit", ctx)
|
||||||
|
dlg.createPeer(toolkit, None)
|
||||||
|
|
||||||
|
result = dlg.execute()
|
||||||
|
if result != 1:
|
||||||
|
dlg.dispose()
|
||||||
|
return PN_CANCEL
|
||||||
|
|
||||||
|
if dlg.getControl("rb_use").getModel().State:
|
||||||
|
dlg.dispose()
|
||||||
|
return PN_USE_EXISTING
|
||||||
|
if dlg.getControl("rb_new").getModel().State:
|
||||||
|
dlg.dispose()
|
||||||
|
return PN_CREATE_NEW
|
||||||
|
|
||||||
|
dlg.dispose()
|
||||||
|
return PN_CANCEL
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AI Description review dialog
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def show_ai_description_dialog(
|
||||||
|
seller_description: str, ai_description: str, parent=None
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Show AI-generated description for review/editing.
|
||||||
|
|
||||||
|
Side-by-side layout: seller description (read-only) on the left,
|
||||||
|
AI-generated description (editable) on the right.
|
||||||
|
|
||||||
|
Returns the accepted/edited description text, or None on cancel.
|
||||||
|
"""
|
||||||
|
if not _HAS_UNO:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ctx = uno.getComponentContext()
|
||||||
|
smgr = ctx.ServiceManager
|
||||||
|
|
||||||
|
dlg_model = smgr.createInstanceWithContext(
|
||||||
|
"com.sun.star.awt.UnoControlDialogModel", ctx
|
||||||
|
)
|
||||||
|
dlg_model.Width = 400
|
||||||
|
dlg_model.Height = 210
|
||||||
|
dlg_model.Title = "AI Description Review"
|
||||||
|
|
||||||
|
# Left: Seller Description (read-only)
|
||||||
|
lbl_seller = dlg_model.createInstance("com.sun.star.awt.UnoControlFixedTextModel")
|
||||||
|
lbl_seller.Name = "lbl_seller"
|
||||||
|
lbl_seller.PositionX = 10
|
||||||
|
lbl_seller.PositionY = 8
|
||||||
|
lbl_seller.Width = 185
|
||||||
|
lbl_seller.Height = 12
|
||||||
|
lbl_seller.Label = "Seller Description"
|
||||||
|
dlg_model.insertByName("lbl_seller", lbl_seller)
|
||||||
|
|
||||||
|
tf_seller = dlg_model.createInstance("com.sun.star.awt.UnoControlEditModel")
|
||||||
|
tf_seller.Name = "tf_seller"
|
||||||
|
tf_seller.PositionX = 10
|
||||||
|
tf_seller.PositionY = 22
|
||||||
|
tf_seller.Width = 185
|
||||||
|
tf_seller.Height = 140
|
||||||
|
tf_seller.Text = seller_description
|
||||||
|
tf_seller.MultiLine = True
|
||||||
|
tf_seller.VScroll = True
|
||||||
|
tf_seller.ReadOnly = True
|
||||||
|
dlg_model.insertByName("tf_seller", tf_seller)
|
||||||
|
|
||||||
|
# Right: Generated Description (editable)
|
||||||
|
lbl_gen = dlg_model.createInstance("com.sun.star.awt.UnoControlFixedTextModel")
|
||||||
|
lbl_gen.Name = "lbl_gen"
|
||||||
|
lbl_gen.PositionX = 205
|
||||||
|
lbl_gen.PositionY = 8
|
||||||
|
lbl_gen.Width = 185
|
||||||
|
lbl_gen.Height = 12
|
||||||
|
lbl_gen.Label = "Generated Description (editable)"
|
||||||
|
dlg_model.insertByName("lbl_gen", lbl_gen)
|
||||||
|
|
||||||
|
tf_gen = dlg_model.createInstance("com.sun.star.awt.UnoControlEditModel")
|
||||||
|
tf_gen.Name = "tf_gen"
|
||||||
|
tf_gen.PositionX = 205
|
||||||
|
tf_gen.PositionY = 22
|
||||||
|
tf_gen.Width = 185
|
||||||
|
tf_gen.Height = 140
|
||||||
|
tf_gen.Text = ai_description
|
||||||
|
tf_gen.MultiLine = True
|
||||||
|
tf_gen.VScroll = True
|
||||||
|
dlg_model.insertByName("tf_gen", tf_gen)
|
||||||
|
|
||||||
|
# Accept button
|
||||||
|
btn_ok = dlg_model.createInstance("com.sun.star.awt.UnoControlButtonModel")
|
||||||
|
btn_ok.Name = "btn_ok"
|
||||||
|
btn_ok.PositionX = 290
|
||||||
|
btn_ok.PositionY = 175
|
||||||
|
btn_ok.Width = 50
|
||||||
|
btn_ok.Height = 18
|
||||||
|
btn_ok.Label = "Accept"
|
||||||
|
btn_ok.PushButtonType = 1 # OK
|
||||||
|
dlg_model.insertByName("btn_ok", btn_ok)
|
||||||
|
|
||||||
|
# Cancel button
|
||||||
|
btn_cancel = dlg_model.createInstance("com.sun.star.awt.UnoControlButtonModel")
|
||||||
|
btn_cancel.Name = "btn_cancel"
|
||||||
|
btn_cancel.PositionX = 345
|
||||||
|
btn_cancel.PositionY = 175
|
||||||
|
btn_cancel.Width = 45
|
||||||
|
btn_cancel.Height = 18
|
||||||
|
btn_cancel.Label = "Cancel"
|
||||||
|
btn_cancel.PushButtonType = 2 # CANCEL
|
||||||
|
dlg_model.insertByName("btn_cancel", btn_cancel)
|
||||||
|
|
||||||
|
dlg = smgr.createInstanceWithContext("com.sun.star.awt.UnoControlDialog", ctx)
|
||||||
|
dlg.setModel(dlg_model)
|
||||||
|
dlg.setVisible(False)
|
||||||
|
toolkit = smgr.createInstanceWithContext("com.sun.star.awt.Toolkit", ctx)
|
||||||
|
dlg.createPeer(toolkit, None)
|
||||||
|
|
||||||
|
result = dlg.execute()
|
||||||
|
if result == 1: # OK / Accept
|
||||||
|
text = dlg.getControl("tf_gen").getText()
|
||||||
|
dlg.dispose()
|
||||||
|
return text
|
||||||
|
|
||||||
|
dlg.dispose()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Assembly / Project picker dialogs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def show_assembly_picker(client: SiloClient, parent=None) -> Optional[str]:
|
||||||
|
"""Show a dialog to pick an assembly by PN. Returns the PN or None."""
|
||||||
|
pn = _input_box("Pull BOM", "Assembly part number (e.g. A01-0003):")
|
||||||
|
return pn if pn and pn.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def show_project_picker(client: SiloClient, parent=None) -> Optional[str]:
|
||||||
|
"""Show a dialog to pick a project code. Returns the code or None."""
|
||||||
|
try:
|
||||||
|
projects = client.get_projects()
|
||||||
|
except RuntimeError:
|
||||||
|
projects = []
|
||||||
|
|
||||||
|
if not projects:
|
||||||
|
code = _input_box("Pull Project", "Project code:")
|
||||||
|
return code if code and code.strip() else None
|
||||||
|
|
||||||
|
# Build a choice list
|
||||||
|
choices = [f"{p.get('code', '')} - {p.get('name', '')}" for p in projects]
|
||||||
|
# For simplicity, use an input box with hint. A proper list picker
|
||||||
|
# would use a ListBox control, but this is functional for now.
|
||||||
|
hint = "Available: " + ", ".join(p.get("code", "") for p in projects)
|
||||||
|
code = _input_box("Pull Project", f"Project code ({hint}):")
|
||||||
|
return code if code and code.strip() else None
|
||||||
76
pkg/calc/pythonpath/silo_calc/project_files.py
Normal file
76
pkg/calc/pythonpath/silo_calc/project_files.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Local project file management for ODS workbooks.
|
||||||
|
|
||||||
|
Mirrors the FreeCAD file path pattern from ``pkg/freecad/silo_commands.py``.
|
||||||
|
Project ODS files live at::
|
||||||
|
|
||||||
|
~/projects/sheets/{PROJECT_CODE}/{PROJECT_CODE}.ods
|
||||||
|
|
||||||
|
The ``SILO_PROJECTS_DIR`` env var (shared with the FreeCAD workbench)
|
||||||
|
controls the base directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from . import settings as _settings
|
||||||
|
|
||||||
|
|
||||||
|
def get_sheets_dir() -> Path:
|
||||||
|
"""Return the base directory for ODS project sheets."""
|
||||||
|
return _settings.get_projects_dir() / "sheets"
|
||||||
|
|
||||||
|
|
||||||
|
def get_project_sheet_path(project_code: str) -> Path:
|
||||||
|
"""Canonical path for a project workbook.
|
||||||
|
|
||||||
|
Example: ``~/projects/sheets/3DX10/3DX10.ods``
|
||||||
|
"""
|
||||||
|
return get_sheets_dir() / project_code / f"{project_code}.ods"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_project_dir(project_code: str) -> Path:
|
||||||
|
"""Create the project sheet directory if needed and return its path."""
|
||||||
|
d = get_sheets_dir() / project_code
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def project_sheet_exists(project_code: str) -> bool:
|
||||||
|
"""Check whether a project workbook already exists locally."""
|
||||||
|
return get_project_sheet_path(project_code).is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def save_project_sheet(project_code: str, ods_bytes: bytes) -> Path:
|
||||||
|
"""Write ODS bytes to the canonical project path.
|
||||||
|
|
||||||
|
Returns the Path written to.
|
||||||
|
"""
|
||||||
|
ensure_project_dir(project_code)
|
||||||
|
path = get_project_sheet_path(project_code)
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(ods_bytes)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def read_project_sheet(project_code: str) -> Optional[bytes]:
|
||||||
|
"""Read ODS bytes from the canonical project path, or None."""
|
||||||
|
path = get_project_sheet_path(project_code)
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def list_project_sheets() -> list:
|
||||||
|
"""Return a list of (project_code, path) tuples for all local sheets."""
|
||||||
|
sheets_dir = get_sheets_dir()
|
||||||
|
results = []
|
||||||
|
if not sheets_dir.is_dir():
|
||||||
|
return results
|
||||||
|
for entry in sorted(sheets_dir.iterdir()):
|
||||||
|
if entry.is_dir():
|
||||||
|
ods = entry / f"{entry.name}.ods"
|
||||||
|
if ods.is_file():
|
||||||
|
results.append((entry.name, ods))
|
||||||
|
return results
|
||||||
542
pkg/calc/pythonpath/silo_calc/pull.py
Normal file
542
pkg/calc/pythonpath/silo_calc/pull.py
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
"""Pull commands -- populate LibreOffice Calc sheets from Silo API data.
|
||||||
|
|
||||||
|
This module handles the UNO cell-level work for SiloPullBOM and
|
||||||
|
SiloPullProject. It fetches data via the SiloClient, then writes
|
||||||
|
cells with proper formatting, formulas, hidden columns, and row
|
||||||
|
hash tracking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from . import sheet_format as sf
|
||||||
|
from . import sync_engine
|
||||||
|
from .client import SiloClient
|
||||||
|
|
||||||
|
# UNO imports -- only available inside LibreOffice
|
||||||
|
try:
|
||||||
|
import uno
|
||||||
|
from com.sun.star.beans import PropertyValue
|
||||||
|
from com.sun.star.table import CellHoriJustify
|
||||||
|
|
||||||
|
_HAS_UNO = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_UNO = False
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Colour helpers (UNO uses 0xRRGGBB integers)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _rgb_int(r: int, g: int, b: int) -> int:
|
||||||
|
return (r << 16) | (g << 8) | b
|
||||||
|
|
||||||
|
|
||||||
|
_HEADER_BG = _rgb_int(68, 114, 196) # steel blue
|
||||||
|
_HEADER_FG = _rgb_int(255, 255, 255) # white text
|
||||||
|
|
||||||
|
_STATUS_COLORS = {k: _rgb_int(*v) for k, v in sf.STATUS_COLORS.items()}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cell writing helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _set_cell_string(sheet, col: int, row: int, value: str):
|
||||||
|
cell = sheet.getCellByPosition(col, row)
|
||||||
|
cell.setString(str(value) if value else "")
|
||||||
|
|
||||||
|
|
||||||
|
def _set_cell_float(sheet, col: int, row: int, value, fmt: str = ""):
|
||||||
|
cell = sheet.getCellByPosition(col, row)
|
||||||
|
try:
|
||||||
|
cell.setValue(float(value))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
cell.setString(str(value) if value else "")
|
||||||
|
|
||||||
|
|
||||||
|
def _set_cell_formula(sheet, col: int, row: int, formula: str):
|
||||||
|
cell = sheet.getCellByPosition(col, row)
|
||||||
|
cell.setFormula(formula)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_row_bg(sheet, row: int, col_count: int, color: int):
|
||||||
|
"""Set background colour on an entire row."""
|
||||||
|
rng = sheet.getCellRangeByPosition(0, row, col_count - 1, row)
|
||||||
|
rng.CellBackColor = color
|
||||||
|
|
||||||
|
|
||||||
|
def _format_header_row(sheet, col_count: int):
|
||||||
|
"""Bold white text on blue background for row 0."""
|
||||||
|
rng = sheet.getCellRangeByPosition(0, 0, col_count - 1, 0)
|
||||||
|
rng.CellBackColor = _HEADER_BG
|
||||||
|
rng.CharColor = _HEADER_FG
|
||||||
|
rng.CharWeight = 150 # com.sun.star.awt.FontWeight.BOLD
|
||||||
|
|
||||||
|
|
||||||
|
def _freeze_row(doc, row: int = 1):
|
||||||
|
"""Freeze panes at the given row (default: freeze header)."""
|
||||||
|
ctrl = doc.getCurrentController()
|
||||||
|
ctrl.freezeAtPosition(0, row)
|
||||||
|
|
||||||
|
|
||||||
|
def _hide_columns(sheet, start_col: int, end_col: int):
|
||||||
|
"""Hide a range of columns (inclusive)."""
|
||||||
|
cols = sheet.getColumns()
|
||||||
|
for i in range(start_col, end_col):
|
||||||
|
col = cols.getByIndex(i)
|
||||||
|
col.IsVisible = False
|
||||||
|
|
||||||
|
|
||||||
|
def _set_column_width(sheet, col: int, width_mm100: int):
|
||||||
|
"""Set column width in 1/100 mm."""
|
||||||
|
cols = sheet.getColumns()
|
||||||
|
c = cols.getByIndex(col)
|
||||||
|
c.Width = width_mm100
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# BOM data helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_meta(entry: Dict, key: str, default: str = "") -> str:
|
||||||
|
"""Extract a value from a BOM entry's metadata dict."""
|
||||||
|
meta = entry.get("metadata") or {}
|
||||||
|
val = meta.get(key, default)
|
||||||
|
return str(val) if val else default
|
||||||
|
|
||||||
|
|
||||||
|
def _get_meta_float(entry: Dict, key: str) -> Optional[float]:
|
||||||
|
meta = entry.get("metadata") or {}
|
||||||
|
val = meta.get(key)
|
||||||
|
if val is not None:
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_property(rev: Optional[Dict], key: str) -> str:
|
||||||
|
"""Extract a property from a revision's properties dict."""
|
||||||
|
if not rev:
|
||||||
|
return ""
|
||||||
|
props = rev.get("properties") or {}
|
||||||
|
val = props.get(key, "")
|
||||||
|
return str(val) if val else ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SiloPullBOM
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def pull_bom(
|
||||||
|
client: SiloClient,
|
||||||
|
doc,
|
||||||
|
sheet,
|
||||||
|
assembly_pn: str,
|
||||||
|
project_code: str = "",
|
||||||
|
schema: str = "kindred-rd",
|
||||||
|
):
|
||||||
|
"""Fetch an expanded BOM and populate *sheet* with formatted data.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
client : SiloClient
|
||||||
|
doc : XSpreadsheetDocument
|
||||||
|
sheet : XSpreadsheet (the target sheet to populate)
|
||||||
|
assembly_pn : str (top-level assembly part number)
|
||||||
|
project_code : str (project code for auto-tagging, optional)
|
||||||
|
schema : str
|
||||||
|
"""
|
||||||
|
if not _HAS_UNO:
|
||||||
|
raise RuntimeError("UNO API not available -- must run inside LibreOffice")
|
||||||
|
|
||||||
|
# Fetch expanded BOM
|
||||||
|
bom_entries = client.get_bom_expanded(assembly_pn, depth=10)
|
||||||
|
if not bom_entries:
|
||||||
|
raise RuntimeError(f"No BOM entries found for {assembly_pn}")
|
||||||
|
|
||||||
|
# Fetch the top-level item for the assembly name
|
||||||
|
try:
|
||||||
|
top_item = client.get_item(assembly_pn)
|
||||||
|
except RuntimeError:
|
||||||
|
top_item = {}
|
||||||
|
|
||||||
|
# Build a cache of items and their latest revisions for property lookup
|
||||||
|
item_cache: Dict[str, Dict] = {}
|
||||||
|
rev_cache: Dict[str, Dict] = {}
|
||||||
|
|
||||||
|
def _ensure_cached(pn: str):
|
||||||
|
if pn in item_cache:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
item_cache[pn] = client.get_item(pn)
|
||||||
|
except RuntimeError:
|
||||||
|
item_cache[pn] = {}
|
||||||
|
try:
|
||||||
|
revisions = client.get_revisions(pn)
|
||||||
|
if revisions:
|
||||||
|
rev_cache[pn] = revisions[0] # newest first
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Pre-cache all items in the BOM
|
||||||
|
all_pns = set()
|
||||||
|
for e in bom_entries:
|
||||||
|
all_pns.add(e.get("child_part_number", ""))
|
||||||
|
all_pns.add(e.get("parent_part_number", ""))
|
||||||
|
all_pns.discard("")
|
||||||
|
for pn in all_pns:
|
||||||
|
_ensure_cached(pn)
|
||||||
|
|
||||||
|
# -- Write header row ---------------------------------------------------
|
||||||
|
for col_idx, header in enumerate(sf.BOM_ALL_HEADERS):
|
||||||
|
_set_cell_string(sheet, col_idx, 0, header)
|
||||||
|
_format_header_row(sheet, sf.BOM_TOTAL_COLS)
|
||||||
|
|
||||||
|
# -- Group entries by parent for section headers ------------------------
|
||||||
|
# BOM entries come back in tree order (parent then children).
|
||||||
|
# We insert section header rows for each depth-1 sub-assembly.
|
||||||
|
|
||||||
|
row = 1 # current write row (0 is header)
|
||||||
|
prev_parent = None
|
||||||
|
|
||||||
|
for entry in bom_entries:
|
||||||
|
depth = entry.get("depth", 0)
|
||||||
|
child_pn = entry.get("child_part_number", "")
|
||||||
|
parent_pn = entry.get("parent_part_number", "")
|
||||||
|
child_item = item_cache.get(child_pn, {})
|
||||||
|
child_rev = rev_cache.get(child_pn)
|
||||||
|
|
||||||
|
# Section header: when the parent changes for depth >= 1 entries
|
||||||
|
if depth == 1 and parent_pn != prev_parent and parent_pn:
|
||||||
|
if row > 1:
|
||||||
|
# Blank separator row
|
||||||
|
row += 1
|
||||||
|
# Sub-assembly label row
|
||||||
|
parent_item = item_cache.get(parent_pn, {})
|
||||||
|
label = parent_item.get("description", parent_pn)
|
||||||
|
_set_cell_string(sheet, sf.COL_ITEM, row, label)
|
||||||
|
_set_cell_float(sheet, sf.COL_LEVEL, row, 0)
|
||||||
|
_set_cell_string(sheet, sf.COL_SOURCE, row, "M")
|
||||||
|
_set_cell_string(sheet, sf.COL_PN, row, parent_pn)
|
||||||
|
|
||||||
|
# Compute sub-assembly cost from children if available
|
||||||
|
parent_cost = _compute_subassembly_cost(bom_entries, parent_pn, item_cache)
|
||||||
|
if parent_cost is not None:
|
||||||
|
_set_cell_float(sheet, sf.COL_UNIT_COST, row, parent_cost)
|
||||||
|
_set_cell_float(sheet, sf.COL_QTY, row, 1)
|
||||||
|
# Ext Cost formula
|
||||||
|
ext_formula = f"={sf.col_letter(sf.COL_UNIT_COST)}{row + 1}*{sf.col_letter(sf.COL_QTY)}{row + 1}"
|
||||||
|
_set_cell_formula(sheet, sf.COL_EXT_COST, row, ext_formula)
|
||||||
|
_set_cell_string(sheet, sf.COL_SCHEMA, row, schema)
|
||||||
|
|
||||||
|
# Sync tracking for parent row
|
||||||
|
parent_cells = [""] * sf.BOM_TOTAL_COLS
|
||||||
|
parent_cells[sf.COL_ITEM] = label
|
||||||
|
parent_cells[sf.COL_LEVEL] = "0"
|
||||||
|
parent_cells[sf.COL_SOURCE] = "M"
|
||||||
|
parent_cells[sf.COL_PN] = parent_pn
|
||||||
|
parent_cells[sf.COL_SCHEMA] = schema
|
||||||
|
sync_engine.update_row_sync_state(
|
||||||
|
parent_cells,
|
||||||
|
sync_engine.STATUS_SYNCED,
|
||||||
|
updated_at=parent_item.get("updated_at", ""),
|
||||||
|
parent_pn="",
|
||||||
|
)
|
||||||
|
_set_cell_string(sheet, sf.COL_ROW_HASH, row, parent_cells[sf.COL_ROW_HASH])
|
||||||
|
_set_cell_string(
|
||||||
|
sheet, sf.COL_ROW_STATUS, row, parent_cells[sf.COL_ROW_STATUS]
|
||||||
|
)
|
||||||
|
_set_cell_string(
|
||||||
|
sheet, sf.COL_UPDATED_AT, row, parent_cells[sf.COL_UPDATED_AT]
|
||||||
|
)
|
||||||
|
|
||||||
|
_set_row_bg(sheet, row, sf.BOM_TOTAL_COLS, _STATUS_COLORS["synced"])
|
||||||
|
prev_parent = parent_pn
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
# -- Write child row -----------------------------------------------
|
||||||
|
quantity = entry.get("quantity")
|
||||||
|
unit_cost = _get_meta_float(entry, "unit_cost")
|
||||||
|
if unit_cost is None:
|
||||||
|
unit_cost = child_item.get("standard_cost")
|
||||||
|
|
||||||
|
# Item column: blank for children (name is in the section header)
|
||||||
|
_set_cell_string(sheet, sf.COL_ITEM, row, "")
|
||||||
|
_set_cell_float(sheet, sf.COL_LEVEL, row, depth)
|
||||||
|
_set_cell_string(sheet, sf.COL_SOURCE, row, child_item.get("sourcing_type", ""))
|
||||||
|
_set_cell_string(sheet, sf.COL_PN, row, child_pn)
|
||||||
|
_set_cell_string(
|
||||||
|
sheet, sf.COL_DESCRIPTION, row, child_item.get("description", "")
|
||||||
|
)
|
||||||
|
_set_cell_string(
|
||||||
|
sheet, sf.COL_SELLER_DESC, row, _get_meta(entry, "seller_description")
|
||||||
|
)
|
||||||
|
|
||||||
|
if unit_cost is not None:
|
||||||
|
_set_cell_float(sheet, sf.COL_UNIT_COST, row, unit_cost)
|
||||||
|
if quantity is not None:
|
||||||
|
_set_cell_float(sheet, sf.COL_QTY, row, quantity)
|
||||||
|
|
||||||
|
# Ext Cost formula
|
||||||
|
ext_formula = f"={sf.col_letter(sf.COL_UNIT_COST)}{row + 1}*{sf.col_letter(sf.COL_QTY)}{row + 1}"
|
||||||
|
_set_cell_formula(sheet, sf.COL_EXT_COST, row, ext_formula)
|
||||||
|
|
||||||
|
_set_cell_string(
|
||||||
|
sheet, sf.COL_SOURCING_LINK, row, child_item.get("sourcing_link", "")
|
||||||
|
)
|
||||||
|
_set_cell_string(sheet, sf.COL_SCHEMA, row, schema)
|
||||||
|
|
||||||
|
# -- Property columns -----------------------------------------------
|
||||||
|
prop_values = _build_property_cells(child_item, child_rev, entry)
|
||||||
|
for i, val in enumerate(prop_values):
|
||||||
|
if val:
|
||||||
|
_set_cell_string(sheet, sf.COL_PROP_START + i, row, val)
|
||||||
|
|
||||||
|
# -- Sync tracking ---------------------------------------------------
|
||||||
|
row_cells = [""] * sf.BOM_TOTAL_COLS
|
||||||
|
row_cells[sf.COL_LEVEL] = str(depth)
|
||||||
|
row_cells[sf.COL_SOURCE] = child_item.get("sourcing_type", "")
|
||||||
|
row_cells[sf.COL_PN] = child_pn
|
||||||
|
row_cells[sf.COL_DESCRIPTION] = child_item.get("description", "")
|
||||||
|
row_cells[sf.COL_SELLER_DESC] = _get_meta(entry, "seller_description")
|
||||||
|
row_cells[sf.COL_UNIT_COST] = str(unit_cost) if unit_cost else ""
|
||||||
|
row_cells[sf.COL_QTY] = str(quantity) if quantity else ""
|
||||||
|
row_cells[sf.COL_SOURCING_LINK] = child_item.get("sourcing_link", "")
|
||||||
|
row_cells[sf.COL_SCHEMA] = schema
|
||||||
|
for i, val in enumerate(prop_values):
|
||||||
|
row_cells[sf.COL_PROP_START + i] = val
|
||||||
|
|
||||||
|
sync_engine.update_row_sync_state(
|
||||||
|
row_cells,
|
||||||
|
sync_engine.STATUS_SYNCED,
|
||||||
|
updated_at=child_item.get("updated_at", ""),
|
||||||
|
parent_pn=parent_pn,
|
||||||
|
)
|
||||||
|
_set_cell_string(sheet, sf.COL_ROW_HASH, row, row_cells[sf.COL_ROW_HASH])
|
||||||
|
_set_cell_string(sheet, sf.COL_ROW_STATUS, row, row_cells[sf.COL_ROW_STATUS])
|
||||||
|
_set_cell_string(sheet, sf.COL_UPDATED_AT, row, row_cells[sf.COL_UPDATED_AT])
|
||||||
|
_set_cell_string(sheet, sf.COL_PARENT_PN, row, row_cells[sf.COL_PARENT_PN])
|
||||||
|
|
||||||
|
_set_row_bg(sheet, row, sf.BOM_TOTAL_COLS, _STATUS_COLORS["synced"])
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
# -- Formatting ---------------------------------------------------------
|
||||||
|
_freeze_row(doc, 1)
|
||||||
|
_hide_columns(sheet, sf.COL_PROP_START, sf.COL_PROP_END) # property cols
|
||||||
|
_hide_columns(sheet, sf.COL_SYNC_START, sf.BOM_TOTAL_COLS) # sync cols
|
||||||
|
|
||||||
|
# Set reasonable column widths for visible columns (in 1/100 mm)
|
||||||
|
_WIDTHS = {
|
||||||
|
sf.COL_ITEM: 4500,
|
||||||
|
sf.COL_LEVEL: 1200,
|
||||||
|
sf.COL_SOURCE: 1500,
|
||||||
|
sf.COL_PN: 2500,
|
||||||
|
sf.COL_DESCRIPTION: 5000,
|
||||||
|
sf.COL_SELLER_DESC: 6000,
|
||||||
|
sf.COL_UNIT_COST: 2200,
|
||||||
|
sf.COL_QTY: 1200,
|
||||||
|
sf.COL_EXT_COST: 2200,
|
||||||
|
sf.COL_SOURCING_LINK: 5000,
|
||||||
|
sf.COL_SCHEMA: 1500,
|
||||||
|
}
|
||||||
|
for col, width in _WIDTHS.items():
|
||||||
|
_set_column_width(sheet, col, width)
|
||||||
|
|
||||||
|
# Auto-tag all items with the project (if a project code is set)
|
||||||
|
if project_code:
|
||||||
|
_auto_tag_project(client, all_pns, project_code)
|
||||||
|
|
||||||
|
return row - 1 # number of data rows written
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_subassembly_cost(
|
||||||
|
bom_entries: List[Dict],
|
||||||
|
parent_pn: str,
|
||||||
|
item_cache: Dict[str, Dict],
|
||||||
|
) -> Optional[float]:
|
||||||
|
"""Sum unit_cost * quantity for direct children of parent_pn."""
|
||||||
|
total = 0.0
|
||||||
|
found = False
|
||||||
|
for e in bom_entries:
|
||||||
|
if e.get("parent_part_number") == parent_pn and e.get("depth", 0) > 0:
|
||||||
|
q = e.get("quantity") or 0
|
||||||
|
uc = _get_meta_float(e, "unit_cost")
|
||||||
|
if uc is None:
|
||||||
|
child = item_cache.get(e.get("child_part_number", ""), {})
|
||||||
|
uc = child.get("standard_cost")
|
||||||
|
if uc is not None:
|
||||||
|
total += float(uc) * float(q)
|
||||||
|
found = True
|
||||||
|
return total if found else None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_property_cells(
|
||||||
|
item: Dict, rev: Optional[Dict], bom_entry: Dict
|
||||||
|
) -> List[str]:
|
||||||
|
"""Build the property column values in order matching BOM_PROPERTY_HEADERS.
|
||||||
|
|
||||||
|
Sources (priority): revision properties > BOM metadata > item fields.
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
for header in sf.BOM_PROPERTY_HEADERS:
|
||||||
|
db_key = sf.PROPERTY_KEY_MAP.get(header, "")
|
||||||
|
val = ""
|
||||||
|
# Check revision properties first
|
||||||
|
if db_key:
|
||||||
|
val = _get_property(rev, db_key)
|
||||||
|
# Fallback to BOM entry metadata
|
||||||
|
if not val and db_key:
|
||||||
|
val = _get_meta(bom_entry, db_key)
|
||||||
|
# Special case: Long Description from item field
|
||||||
|
if header == "Long Description" and not val:
|
||||||
|
val = item.get("long_description", "")
|
||||||
|
# Special case: Notes from item metadata or revision
|
||||||
|
if header == "Notes" and not val:
|
||||||
|
val = _get_meta(bom_entry, "notes")
|
||||||
|
result.append(str(val) if val else "")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_tag_project(
|
||||||
|
client: SiloClient,
|
||||||
|
part_numbers: set,
|
||||||
|
project_code: str,
|
||||||
|
):
|
||||||
|
"""Tag all part numbers with the given project code (skip failures)."""
|
||||||
|
for pn in part_numbers:
|
||||||
|
if not pn:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
existing = client.get_item_projects(pn)
|
||||||
|
existing_codes = (
|
||||||
|
{p.get("code", "") for p in existing}
|
||||||
|
if isinstance(existing, list)
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
if project_code not in existing_codes:
|
||||||
|
client.add_item_projects(pn, [project_code])
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Best-effort tagging
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SiloPullProject
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def pull_project(
|
||||||
|
client: SiloClient,
|
||||||
|
doc,
|
||||||
|
project_code: str,
|
||||||
|
schema: str = "kindred-rd",
|
||||||
|
):
|
||||||
|
"""Fetch project items and populate an Items sheet.
|
||||||
|
|
||||||
|
Also attempts to find an assembly and populate a BOM sheet.
|
||||||
|
"""
|
||||||
|
if not _HAS_UNO:
|
||||||
|
raise RuntimeError("UNO API not available")
|
||||||
|
|
||||||
|
items = client.get_project_items(project_code)
|
||||||
|
if not items:
|
||||||
|
raise RuntimeError(f"No items found for project {project_code}")
|
||||||
|
|
||||||
|
sheets = doc.getSheets()
|
||||||
|
|
||||||
|
# -- Items sheet --------------------------------------------------------
|
||||||
|
if sheets.hasByName("Items"):
|
||||||
|
items_sheet = sheets.getByName("Items")
|
||||||
|
else:
|
||||||
|
sheets.insertNewByName("Items", sheets.getCount())
|
||||||
|
items_sheet = sheets.getByName("Items")
|
||||||
|
|
||||||
|
# Header
|
||||||
|
for col_idx, header in enumerate(sf.ITEMS_HEADERS):
|
||||||
|
_set_cell_string(items_sheet, col_idx, 0, header)
|
||||||
|
header_range = items_sheet.getCellRangeByPosition(
|
||||||
|
0, 0, len(sf.ITEMS_HEADERS) - 1, 0
|
||||||
|
)
|
||||||
|
header_range.CellBackColor = _HEADER_BG
|
||||||
|
header_range.CharColor = _HEADER_FG
|
||||||
|
header_range.CharWeight = 150
|
||||||
|
|
||||||
|
for row_idx, item in enumerate(items, start=1):
|
||||||
|
_set_cell_string(items_sheet, 0, row_idx, item.get("part_number", ""))
|
||||||
|
_set_cell_string(items_sheet, 1, row_idx, item.get("description", ""))
|
||||||
|
_set_cell_string(items_sheet, 2, row_idx, item.get("item_type", ""))
|
||||||
|
_set_cell_string(items_sheet, 3, row_idx, item.get("sourcing_type", ""))
|
||||||
|
_set_cell_string(items_sheet, 4, row_idx, schema)
|
||||||
|
cost = item.get("standard_cost")
|
||||||
|
if cost is not None:
|
||||||
|
_set_cell_float(items_sheet, 5, row_idx, cost)
|
||||||
|
_set_cell_string(items_sheet, 6, row_idx, item.get("sourcing_link", ""))
|
||||||
|
_set_cell_string(items_sheet, 7, row_idx, item.get("long_description", ""))
|
||||||
|
|
||||||
|
# Properties from latest revision (if available)
|
||||||
|
rev = None
|
||||||
|
try:
|
||||||
|
revisions = client.get_revisions(item.get("part_number", ""))
|
||||||
|
if revisions:
|
||||||
|
rev = revisions[0]
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
prop_cols = [
|
||||||
|
"manufacturer",
|
||||||
|
"manufacturer_pn",
|
||||||
|
"supplier",
|
||||||
|
"supplier_pn",
|
||||||
|
"lead_time_days",
|
||||||
|
"minimum_order_qty",
|
||||||
|
"lifecycle_status",
|
||||||
|
"rohs_compliant",
|
||||||
|
"country_of_origin",
|
||||||
|
"material",
|
||||||
|
"finish",
|
||||||
|
"notes",
|
||||||
|
]
|
||||||
|
for pi, prop_key in enumerate(prop_cols):
|
||||||
|
val = _get_property(rev, prop_key)
|
||||||
|
if val:
|
||||||
|
_set_cell_string(items_sheet, 8 + pi, row_idx, val)
|
||||||
|
|
||||||
|
_set_cell_string(
|
||||||
|
items_sheet,
|
||||||
|
20,
|
||||||
|
row_idx,
|
||||||
|
item.get("created_at", "")[:10] if item.get("created_at") else "",
|
||||||
|
)
|
||||||
|
_set_cell_string(
|
||||||
|
items_sheet,
|
||||||
|
21,
|
||||||
|
row_idx,
|
||||||
|
item.get("updated_at", "")[:10] if item.get("updated_at") else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Freeze header
|
||||||
|
_freeze_row(doc, 1)
|
||||||
|
|
||||||
|
# -- BOM sheet (if we can find an assembly) -----------------------------
|
||||||
|
assemblies = [i for i in items if i.get("item_type") == "assembly"]
|
||||||
|
if assemblies:
|
||||||
|
top_assembly = assemblies[0]
|
||||||
|
top_pn = top_assembly.get("part_number", "")
|
||||||
|
|
||||||
|
if sheets.hasByName("BOM"):
|
||||||
|
bom_sheet = sheets.getByName("BOM")
|
||||||
|
else:
|
||||||
|
sheets.insertNewByName("BOM", 0)
|
||||||
|
bom_sheet = sheets.getByName("BOM")
|
||||||
|
|
||||||
|
try:
|
||||||
|
pull_bom(
|
||||||
|
client, doc, bom_sheet, top_pn, project_code=project_code, schema=schema
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
pass # BOM sheet stays empty if fetch fails
|
||||||
|
|
||||||
|
return len(items)
|
||||||
431
pkg/calc/pythonpath/silo_calc/push.py
Normal file
431
pkg/calc/pythonpath/silo_calc/push.py
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
"""Push command -- sync local BOM edits back to the Silo database.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Row classification (new / modified / synced / conflict)
|
||||||
|
- Creating new items via the API
|
||||||
|
- Updating existing items and BOM entry metadata
|
||||||
|
- Auto-tagging new items with the project code
|
||||||
|
- Conflict detection against server timestamps
|
||||||
|
- Updating row sync state after successful push
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from . import sheet_format as sf
|
||||||
|
from . import sync_engine
|
||||||
|
from .client import SiloClient
|
||||||
|
|
||||||
|
# UNO imports
|
||||||
|
try:
|
||||||
|
import uno
|
||||||
|
|
||||||
|
_HAS_UNO = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_UNO = False
|
||||||
|
|
||||||
|
|
||||||
|
def _read_sheet_rows(sheet) -> List[List[str]]:
|
||||||
|
"""Read all rows from a sheet as lists of strings."""
|
||||||
|
cursor = sheet.createCursor()
|
||||||
|
cursor.gotoStartOfUsedArea(False)
|
||||||
|
cursor.gotoEndOfUsedArea(True)
|
||||||
|
addr = cursor.getRangeAddress()
|
||||||
|
end_row = addr.EndRow
|
||||||
|
end_col = max(addr.EndColumn, sf.BOM_TOTAL_COLS - 1)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for r in range(end_row + 1):
|
||||||
|
row_cells = []
|
||||||
|
for c in range(end_col + 1):
|
||||||
|
cell = sheet.getCellByPosition(c, r)
|
||||||
|
# Get display string for all cell types
|
||||||
|
val = cell.getString()
|
||||||
|
row_cells.append(val)
|
||||||
|
# Pad to full width
|
||||||
|
while len(row_cells) < sf.BOM_TOTAL_COLS:
|
||||||
|
row_cells.append("")
|
||||||
|
rows.append(row_cells)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_project_code(doc) -> str:
|
||||||
|
"""Try to detect the project code from the file path."""
|
||||||
|
try:
|
||||||
|
file_url = doc.getURL()
|
||||||
|
if file_url:
|
||||||
|
file_path = uno.fileUrlToSystemPath(file_url)
|
||||||
|
parts = file_path.replace("\\", "/").split("/")
|
||||||
|
if "sheets" in parts:
|
||||||
|
idx = parts.index("sheets")
|
||||||
|
if idx + 1 < len(parts):
|
||||||
|
return parts[idx + 1]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_server_timestamps(
|
||||||
|
client: SiloClient, part_numbers: List[str]
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""Fetch updated_at timestamps for a list of part numbers."""
|
||||||
|
timestamps = {}
|
||||||
|
for pn in part_numbers:
|
||||||
|
if not pn:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
item = client.get_item(pn)
|
||||||
|
timestamps[pn] = item.get("updated_at", "")
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
return timestamps
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Push execution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def push_sheet(
|
||||||
|
client: SiloClient,
|
||||||
|
doc,
|
||||||
|
sheet,
|
||||||
|
schema: str = "kindred-rd",
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Execute a push for the active BOM sheet.
|
||||||
|
|
||||||
|
Returns a summary dict with counts and any errors.
|
||||||
|
"""
|
||||||
|
if not _HAS_UNO:
|
||||||
|
raise RuntimeError("UNO API not available")
|
||||||
|
|
||||||
|
rows = _read_sheet_rows(sheet)
|
||||||
|
if not rows:
|
||||||
|
return {"created": 0, "updated": 0, "errors": [], "skipped": 0}
|
||||||
|
|
||||||
|
project_code = _detect_project_code(doc)
|
||||||
|
|
||||||
|
# Classify all rows
|
||||||
|
classified = sync_engine.classify_rows(rows)
|
||||||
|
|
||||||
|
# Collect part numbers for server timestamp check
|
||||||
|
modified_pns = [
|
||||||
|
cells[sf.COL_PN].strip()
|
||||||
|
for _, status, cells in classified
|
||||||
|
if status == sync_engine.STATUS_MODIFIED and cells[sf.COL_PN].strip()
|
||||||
|
]
|
||||||
|
server_ts = _fetch_server_timestamps(client, modified_pns)
|
||||||
|
|
||||||
|
# Build diff
|
||||||
|
diff = sync_engine.build_push_diff(classified, server_timestamps=server_ts)
|
||||||
|
|
||||||
|
results = {
|
||||||
|
"created": 0,
|
||||||
|
"updated": 0,
|
||||||
|
"errors": [],
|
||||||
|
"skipped": diff["unchanged"],
|
||||||
|
"conflicts": len(diff["conflicts"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- Handle new rows: create items in the database ----------------------
|
||||||
|
for row_info in diff["new"]:
|
||||||
|
row_idx = row_info["row_index"]
|
||||||
|
cells = rows[row_idx]
|
||||||
|
pn = cells[sf.COL_PN].strip()
|
||||||
|
desc = cells[sf.COL_DESCRIPTION].strip()
|
||||||
|
source = cells[sf.COL_SOURCE].strip()
|
||||||
|
sourcing_link = cells[sf.COL_SOURCING_LINK].strip()
|
||||||
|
unit_cost_str = cells[sf.COL_UNIT_COST].strip()
|
||||||
|
qty_str = cells[sf.COL_QTY].strip()
|
||||||
|
parent_pn = (
|
||||||
|
cells[sf.COL_PARENT_PN].strip() if len(cells) > sf.COL_PARENT_PN else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
unit_cost = None
|
||||||
|
if unit_cost_str:
|
||||||
|
try:
|
||||||
|
unit_cost = float(unit_cost_str.replace("$", "").replace(",", ""))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
qty = 1.0
|
||||||
|
if qty_str:
|
||||||
|
try:
|
||||||
|
qty = float(qty_str)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not desc:
|
||||||
|
results["errors"].append(
|
||||||
|
f"Row {row_idx + 1}: description is required for new items"
|
||||||
|
)
|
||||||
|
_set_row_status(sheet, row_idx, sync_engine.STATUS_ERROR)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
if pn:
|
||||||
|
# Check if item already exists
|
||||||
|
try:
|
||||||
|
existing = client.get_item(pn)
|
||||||
|
# Item exists -- just update BOM relationship if parent is known
|
||||||
|
if parent_pn:
|
||||||
|
_update_bom_relationship(
|
||||||
|
client, parent_pn, pn, qty, unit_cost, cells
|
||||||
|
)
|
||||||
|
results["updated"] += 1
|
||||||
|
_update_row_after_push(sheet, rows, row_idx, existing)
|
||||||
|
continue
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Item doesn't exist, create it
|
||||||
|
|
||||||
|
# Detect category from PN prefix (e.g., F01-0001 -> F01)
|
||||||
|
category = pn[:3] if pn and len(pn) >= 3 else ""
|
||||||
|
|
||||||
|
# Create the item
|
||||||
|
create_data = {
|
||||||
|
"schema": schema,
|
||||||
|
"category": category,
|
||||||
|
"description": desc,
|
||||||
|
}
|
||||||
|
if source:
|
||||||
|
create_data["sourcing_type"] = source
|
||||||
|
if sourcing_link:
|
||||||
|
create_data["sourcing_link"] = sourcing_link
|
||||||
|
if unit_cost is not None:
|
||||||
|
create_data["standard_cost"] = unit_cost
|
||||||
|
if project_code:
|
||||||
|
create_data["projects"] = [project_code]
|
||||||
|
|
||||||
|
created = client.create_item(**create_data)
|
||||||
|
created_pn = created.get("part_number", pn)
|
||||||
|
|
||||||
|
# Update the PN cell if it was auto-generated
|
||||||
|
if not pn and created_pn:
|
||||||
|
from . import pull as _pull
|
||||||
|
|
||||||
|
_pull._set_cell_string(sheet, sf.COL_PN, row_idx, created_pn)
|
||||||
|
cells[sf.COL_PN] = created_pn
|
||||||
|
|
||||||
|
# Add to parent's BOM if parent is known
|
||||||
|
if parent_pn:
|
||||||
|
_update_bom_relationship(
|
||||||
|
client, parent_pn, created_pn, qty, unit_cost, cells
|
||||||
|
)
|
||||||
|
|
||||||
|
# Auto-tag with project
|
||||||
|
if project_code:
|
||||||
|
try:
|
||||||
|
client.add_item_projects(created_pn, [project_code])
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Set property columns via revision update (if any properties set)
|
||||||
|
_push_properties(client, created_pn, cells)
|
||||||
|
|
||||||
|
results["created"] += 1
|
||||||
|
_update_row_after_push(sheet, rows, row_idx, created)
|
||||||
|
|
||||||
|
except RuntimeError as e:
|
||||||
|
results["errors"].append(f"Row {row_idx + 1} ({pn}): {e}")
|
||||||
|
_set_row_status(sheet, row_idx, sync_engine.STATUS_ERROR)
|
||||||
|
|
||||||
|
# -- Handle modified rows: update items ---------------------------------
|
||||||
|
for row_info in diff["modified"]:
|
||||||
|
row_idx = row_info["row_index"]
|
||||||
|
cells = rows[row_idx]
|
||||||
|
pn = cells[sf.COL_PN].strip()
|
||||||
|
parent_pn = (
|
||||||
|
cells[sf.COL_PARENT_PN].strip() if len(cells) > sf.COL_PARENT_PN else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
if not pn:
|
||||||
|
results["errors"].append(
|
||||||
|
f"Row {row_idx + 1}: no part number for modified row"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update item fields
|
||||||
|
update_fields = {}
|
||||||
|
desc = cells[sf.COL_DESCRIPTION].strip()
|
||||||
|
if desc:
|
||||||
|
update_fields["description"] = desc
|
||||||
|
source = cells[sf.COL_SOURCE].strip()
|
||||||
|
if source:
|
||||||
|
update_fields["sourcing_type"] = source
|
||||||
|
sourcing_link = cells[sf.COL_SOURCING_LINK].strip()
|
||||||
|
update_fields["sourcing_link"] = sourcing_link
|
||||||
|
|
||||||
|
unit_cost_str = cells[sf.COL_UNIT_COST].strip()
|
||||||
|
unit_cost = None
|
||||||
|
if unit_cost_str:
|
||||||
|
try:
|
||||||
|
unit_cost = float(unit_cost_str.replace("$", "").replace(",", ""))
|
||||||
|
update_fields["standard_cost"] = unit_cost
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if update_fields:
|
||||||
|
updated = client.update_item(pn, **update_fields)
|
||||||
|
else:
|
||||||
|
updated = client.get_item(pn)
|
||||||
|
|
||||||
|
# Update BOM relationship
|
||||||
|
qty_str = cells[sf.COL_QTY].strip()
|
||||||
|
qty = 1.0
|
||||||
|
if qty_str:
|
||||||
|
try:
|
||||||
|
qty = float(qty_str)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if parent_pn:
|
||||||
|
_update_bom_relationship(client, parent_pn, pn, qty, unit_cost, cells)
|
||||||
|
|
||||||
|
# Update properties
|
||||||
|
_push_properties(client, pn, cells)
|
||||||
|
|
||||||
|
# Auto-tag with project
|
||||||
|
if project_code:
|
||||||
|
try:
|
||||||
|
existing_projects = client.get_item_projects(pn)
|
||||||
|
existing_codes = (
|
||||||
|
{p.get("code", "") for p in existing_projects}
|
||||||
|
if isinstance(existing_projects, list)
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
if project_code not in existing_codes:
|
||||||
|
client.add_item_projects(pn, [project_code])
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
results["updated"] += 1
|
||||||
|
_update_row_after_push(sheet, rows, row_idx, updated)
|
||||||
|
|
||||||
|
except RuntimeError as e:
|
||||||
|
results["errors"].append(f"Row {row_idx + 1} ({pn}): {e}")
|
||||||
|
_set_row_status(sheet, row_idx, sync_engine.STATUS_ERROR)
|
||||||
|
|
||||||
|
# -- Mark conflicts -----------------------------------------------------
|
||||||
|
for row_info in diff["conflicts"]:
|
||||||
|
row_idx = row_info["row_index"]
|
||||||
|
_set_row_status(sheet, row_idx, sync_engine.STATUS_CONFLICT)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _update_bom_relationship(
|
||||||
|
client: SiloClient,
|
||||||
|
parent_pn: str,
|
||||||
|
child_pn: str,
|
||||||
|
qty: float,
|
||||||
|
unit_cost: Optional[float],
|
||||||
|
cells: List[str],
|
||||||
|
):
|
||||||
|
"""Create or update a BOM relationship between parent and child."""
|
||||||
|
metadata = {}
|
||||||
|
seller_desc = (
|
||||||
|
cells[sf.COL_SELLER_DESC].strip() if len(cells) > sf.COL_SELLER_DESC else ""
|
||||||
|
)
|
||||||
|
if seller_desc:
|
||||||
|
metadata["seller_description"] = seller_desc
|
||||||
|
if unit_cost is not None:
|
||||||
|
metadata["unit_cost"] = unit_cost
|
||||||
|
sourcing_link = (
|
||||||
|
cells[sf.COL_SOURCING_LINK].strip() if len(cells) > sf.COL_SOURCING_LINK else ""
|
||||||
|
)
|
||||||
|
if sourcing_link:
|
||||||
|
metadata["sourcing_link"] = sourcing_link
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try update first (entry may already exist)
|
||||||
|
client.update_bom_entry(
|
||||||
|
parent_pn,
|
||||||
|
child_pn,
|
||||||
|
quantity=qty,
|
||||||
|
metadata=metadata if metadata else None,
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
# If update fails, try creating
|
||||||
|
try:
|
||||||
|
client.add_bom_entry(
|
||||||
|
parent_pn,
|
||||||
|
child_pn,
|
||||||
|
quantity=qty,
|
||||||
|
metadata=metadata if metadata else None,
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Best effort
|
||||||
|
|
||||||
|
|
||||||
|
def _push_properties(client: SiloClient, pn: str, cells: List[str]):
|
||||||
|
"""Push property column values to the item's latest revision.
|
||||||
|
|
||||||
|
Currently this is best-effort -- the API may not support bulk property
|
||||||
|
updates in a single call. Properties are stored in revision.properties
|
||||||
|
JSONB on the server side.
|
||||||
|
"""
|
||||||
|
# Collect property values from the row
|
||||||
|
properties = {}
|
||||||
|
for i, header in enumerate(sf.BOM_PROPERTY_HEADERS):
|
||||||
|
col_idx = sf.COL_PROP_START + i
|
||||||
|
if col_idx < len(cells):
|
||||||
|
val = cells[col_idx].strip()
|
||||||
|
if val:
|
||||||
|
db_key = sf.PROPERTY_KEY_MAP.get(header, "")
|
||||||
|
if db_key:
|
||||||
|
properties[db_key] = val
|
||||||
|
|
||||||
|
if not properties:
|
||||||
|
return
|
||||||
|
|
||||||
|
# The Silo API stores properties on revisions. For now, we'll update
|
||||||
|
# the item's long_description if it's set, and rely on the revision
|
||||||
|
# properties being set during create or via revision update.
|
||||||
|
long_desc = properties.pop("long_description", None)
|
||||||
|
if long_desc:
|
||||||
|
try:
|
||||||
|
client.update_item(pn, long_description=long_desc)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _update_row_after_push(
|
||||||
|
sheet, rows: List[List[str]], row_idx: int, item: Dict[str, Any]
|
||||||
|
):
|
||||||
|
"""Update sync tracking columns after a successful push."""
|
||||||
|
from . import pull as _pull
|
||||||
|
|
||||||
|
cells = rows[row_idx]
|
||||||
|
|
||||||
|
# Update the PN if the server returned one (auto-generated)
|
||||||
|
server_pn = item.get("part_number", "")
|
||||||
|
if server_pn and not cells[sf.COL_PN].strip():
|
||||||
|
cells[sf.COL_PN] = server_pn
|
||||||
|
_pull._set_cell_string(sheet, sf.COL_PN, row_idx, server_pn)
|
||||||
|
|
||||||
|
# Recompute hash and set synced status
|
||||||
|
sync_engine.update_row_sync_state(
|
||||||
|
cells,
|
||||||
|
sync_engine.STATUS_SYNCED,
|
||||||
|
updated_at=item.get("updated_at", ""),
|
||||||
|
)
|
||||||
|
_pull._set_cell_string(sheet, sf.COL_ROW_HASH, row_idx, cells[sf.COL_ROW_HASH])
|
||||||
|
_pull._set_cell_string(sheet, sf.COL_ROW_STATUS, row_idx, cells[sf.COL_ROW_STATUS])
|
||||||
|
_pull._set_cell_string(sheet, sf.COL_UPDATED_AT, row_idx, cells[sf.COL_UPDATED_AT])
|
||||||
|
|
||||||
|
_pull._set_row_bg(sheet, row_idx, sf.BOM_TOTAL_COLS, _pull._STATUS_COLORS["synced"])
|
||||||
|
|
||||||
|
|
||||||
|
def _set_row_status(sheet, row_idx: int, status: str):
|
||||||
|
"""Set just the status cell and row colour."""
|
||||||
|
from . import pull as _pull
|
||||||
|
|
||||||
|
_pull._set_cell_string(sheet, sf.COL_ROW_STATUS, row_idx, status)
|
||||||
|
color = _pull._STATUS_COLORS.get(status)
|
||||||
|
if color:
|
||||||
|
_pull._set_row_bg(sheet, row_idx, sf.BOM_TOTAL_COLS, color)
|
||||||
94
pkg/calc/pythonpath/silo_calc/settings.py
Normal file
94
pkg/calc/pythonpath/silo_calc/settings.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""Persistent settings for the Silo Calc extension.
|
||||||
|
|
||||||
|
Settings are stored in ``~/.config/silo/calc-settings.json``.
|
||||||
|
The file is a flat JSON dict with known keys.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
_SETTINGS_DIR = (
|
||||||
|
Path(os.environ.get("XDG_CONFIG_HOME", "~/.config")).expanduser() / "silo"
|
||||||
|
)
|
||||||
|
_SETTINGS_FILE = _SETTINGS_DIR / "calc-settings.json"
|
||||||
|
|
||||||
|
# Default values for every known key.
|
||||||
|
_DEFAULTS: Dict[str, Any] = {
|
||||||
|
"api_url": "",
|
||||||
|
"api_token": "",
|
||||||
|
"ssl_verify": True,
|
||||||
|
"ssl_cert_path": "",
|
||||||
|
"auth_username": "",
|
||||||
|
"auth_role": "",
|
||||||
|
"auth_source": "",
|
||||||
|
"projects_dir": "", # fallback: SILO_PROJECTS_DIR env or ~/projects
|
||||||
|
"default_schema": "kindred-rd",
|
||||||
|
"openrouter_api_key": "", # fallback: OPENROUTER_API_KEY env var
|
||||||
|
"openrouter_model": "", # fallback: ai_client.DEFAULT_MODEL
|
||||||
|
"openrouter_instructions": "", # fallback: ai_client.DEFAULT_INSTRUCTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load() -> Dict[str, Any]:
|
||||||
|
"""Load settings, returning defaults for any missing keys."""
|
||||||
|
cfg = dict(_DEFAULTS)
|
||||||
|
if _SETTINGS_FILE.is_file():
|
||||||
|
try:
|
||||||
|
with open(_SETTINGS_FILE, "r") as f:
|
||||||
|
stored = json.load(f)
|
||||||
|
cfg.update(stored)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def save(cfg: Dict[str, Any]) -> None:
|
||||||
|
"""Persist the full settings dict to disk."""
|
||||||
|
_SETTINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(_SETTINGS_FILE, "w") as f:
|
||||||
|
json.dump(cfg, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def get(key: str, default: Any = None) -> Any:
|
||||||
|
"""Convenience: load a single key."""
|
||||||
|
cfg = load()
|
||||||
|
return cfg.get(key, default)
|
||||||
|
|
||||||
|
|
||||||
|
def put(key: str, value: Any) -> None:
|
||||||
|
"""Convenience: update a single key and persist."""
|
||||||
|
cfg = load()
|
||||||
|
cfg[key] = value
|
||||||
|
save(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def save_auth(username: str, role: str = "", source: str = "", token: str = "") -> None:
|
||||||
|
"""Store authentication info."""
|
||||||
|
cfg = load()
|
||||||
|
cfg["auth_username"] = username
|
||||||
|
cfg["auth_role"] = role
|
||||||
|
cfg["auth_source"] = source
|
||||||
|
if token:
|
||||||
|
cfg["api_token"] = token
|
||||||
|
save(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_auth() -> None:
|
||||||
|
"""Remove stored auth credentials."""
|
||||||
|
cfg = load()
|
||||||
|
cfg["api_token"] = ""
|
||||||
|
cfg["auth_username"] = ""
|
||||||
|
cfg["auth_role"] = ""
|
||||||
|
cfg["auth_source"] = ""
|
||||||
|
save(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def get_projects_dir() -> Path:
|
||||||
|
"""Return the resolved projects base directory."""
|
||||||
|
cfg = load()
|
||||||
|
d = cfg.get("projects_dir", "")
|
||||||
|
if not d:
|
||||||
|
d = os.environ.get("SILO_PROJECTS_DIR", "~/projects")
|
||||||
|
return Path(d).expanduser()
|
||||||
178
pkg/calc/pythonpath/silo_calc/sheet_format.py
Normal file
178
pkg/calc/pythonpath/silo_calc/sheet_format.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""BOM and Items sheet column layouts, constants, and detection helpers.
|
||||||
|
|
||||||
|
This module defines the column structure that matches the engineer's working
|
||||||
|
BOM format. Hidden property columns and sync-tracking columns are appended
|
||||||
|
to the right.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Column indices -- BOM sheet
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Visible core columns (always shown)
|
||||||
|
BOM_VISIBLE_HEADERS: List[str] = [
|
||||||
|
"Item", # A - assembly label / section header
|
||||||
|
"Level", # B - depth in expanded BOM
|
||||||
|
"Source", # C - sourcing_type (M/P)
|
||||||
|
"PN", # D - part_number
|
||||||
|
"Description", # E - item description
|
||||||
|
"Seller Description", # F - metadata.seller_description
|
||||||
|
"Unit Cost", # G - standard_cost / metadata.unit_cost
|
||||||
|
"QTY", # H - quantity on relationship
|
||||||
|
"Ext Cost", # I - formula =G*H
|
||||||
|
"Sourcing Link", # J - sourcing_link
|
||||||
|
"Schema", # K - schema name
|
||||||
|
]
|
||||||
|
|
||||||
|
# Hidden property columns (collapsed group, available when needed)
|
||||||
|
BOM_PROPERTY_HEADERS: List[str] = [
|
||||||
|
"Manufacturer", # L
|
||||||
|
"Manufacturer PN", # M
|
||||||
|
"Supplier", # N
|
||||||
|
"Supplier PN", # O
|
||||||
|
"Lead Time (days)", # P
|
||||||
|
"Min Order Qty", # Q
|
||||||
|
"Lifecycle Status", # R
|
||||||
|
"RoHS Compliant", # S
|
||||||
|
"Country of Origin", # T
|
||||||
|
"Material", # U
|
||||||
|
"Finish", # V
|
||||||
|
"Notes", # W
|
||||||
|
"Long Description", # X
|
||||||
|
]
|
||||||
|
|
||||||
|
# Hidden sync columns (never shown to user)
|
||||||
|
BOM_SYNC_HEADERS: List[str] = [
|
||||||
|
"_silo_row_hash", # Y - SHA256 of row data at pull time
|
||||||
|
"_silo_row_status", # Z - synced/modified/new/error
|
||||||
|
"_silo_updated_at", # AA - server timestamp
|
||||||
|
"_silo_parent_pn", # AB - parent assembly PN for this BOM entry
|
||||||
|
]
|
||||||
|
|
||||||
|
# All headers in order
|
||||||
|
BOM_ALL_HEADERS: List[str] = (
|
||||||
|
BOM_VISIBLE_HEADERS + BOM_PROPERTY_HEADERS + BOM_SYNC_HEADERS
|
||||||
|
)
|
||||||
|
|
||||||
|
# Index constants for quick access
|
||||||
|
COL_ITEM = 0
|
||||||
|
COL_LEVEL = 1
|
||||||
|
COL_SOURCE = 2
|
||||||
|
COL_PN = 3
|
||||||
|
COL_DESCRIPTION = 4
|
||||||
|
COL_SELLER_DESC = 5
|
||||||
|
COL_UNIT_COST = 6
|
||||||
|
COL_QTY = 7
|
||||||
|
COL_EXT_COST = 8
|
||||||
|
COL_SOURCING_LINK = 9
|
||||||
|
COL_SCHEMA = 10
|
||||||
|
|
||||||
|
# Property column range
|
||||||
|
COL_PROP_START = len(BOM_VISIBLE_HEADERS) # 11
|
||||||
|
COL_PROP_END = COL_PROP_START + len(BOM_PROPERTY_HEADERS) # 24
|
||||||
|
|
||||||
|
# Sync column range
|
||||||
|
COL_SYNC_START = COL_PROP_END # 24
|
||||||
|
COL_ROW_HASH = COL_SYNC_START # 24
|
||||||
|
COL_ROW_STATUS = COL_SYNC_START + 1 # 25
|
||||||
|
COL_UPDATED_AT = COL_SYNC_START + 2 # 26
|
||||||
|
COL_PARENT_PN = COL_SYNC_START + 3 # 27
|
||||||
|
|
||||||
|
# Total column count
|
||||||
|
BOM_TOTAL_COLS = len(BOM_ALL_HEADERS)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Items sheet columns (flat list of all items for a project)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ITEMS_HEADERS: List[str] = [
|
||||||
|
"PN",
|
||||||
|
"Description",
|
||||||
|
"Type",
|
||||||
|
"Source",
|
||||||
|
"Schema",
|
||||||
|
"Standard Cost",
|
||||||
|
"Sourcing Link",
|
||||||
|
"Long Description",
|
||||||
|
"Manufacturer",
|
||||||
|
"Manufacturer PN",
|
||||||
|
"Supplier",
|
||||||
|
"Supplier PN",
|
||||||
|
"Lead Time (days)",
|
||||||
|
"Min Order Qty",
|
||||||
|
"Lifecycle Status",
|
||||||
|
"RoHS Compliant",
|
||||||
|
"Country of Origin",
|
||||||
|
"Material",
|
||||||
|
"Finish",
|
||||||
|
"Notes",
|
||||||
|
"Created",
|
||||||
|
"Updated",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Property key mapping (header name -> DB field path)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PROPERTY_KEY_MAP: Dict[str, str] = {
|
||||||
|
"Manufacturer": "manufacturer",
|
||||||
|
"Manufacturer PN": "manufacturer_pn",
|
||||||
|
"Supplier": "supplier",
|
||||||
|
"Supplier PN": "supplier_pn",
|
||||||
|
"Lead Time (days)": "lead_time_days",
|
||||||
|
"Min Order Qty": "minimum_order_qty",
|
||||||
|
"Lifecycle Status": "lifecycle_status",
|
||||||
|
"RoHS Compliant": "rohs_compliant",
|
||||||
|
"Country of Origin": "country_of_origin",
|
||||||
|
"Material": "material",
|
||||||
|
"Finish": "finish",
|
||||||
|
"Notes": "notes",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reverse map
|
||||||
|
DB_FIELD_TO_HEADER: Dict[str, str] = {v: k for k, v in PROPERTY_KEY_MAP.items()}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Row status colours (RGB tuples, 0-255)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
STATUS_COLORS: Dict[str, Tuple[int, int, int]] = {
|
||||||
|
"synced": (198, 239, 206), # light green #C6EFCE
|
||||||
|
"modified": (255, 235, 156), # light yellow #FFEB9C
|
||||||
|
"new": (189, 215, 238), # light blue #BDD7EE
|
||||||
|
"error": (255, 199, 206), # light red #FFC7CE
|
||||||
|
"conflict": (244, 176, 132), # orange #F4B084
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sheet type detection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def detect_sheet_type(headers: List[str]) -> Optional[str]:
|
||||||
|
"""Detect sheet type from the first row of headers.
|
||||||
|
|
||||||
|
Returns ``"bom"``, ``"items"``, or ``None`` if unrecognised.
|
||||||
|
"""
|
||||||
|
if not headers:
|
||||||
|
return None
|
||||||
|
# Normalise for comparison
|
||||||
|
norm = [h.strip().lower() for h in headers]
|
||||||
|
if "item" in norm and "level" in norm and "qty" in norm:
|
||||||
|
return "bom"
|
||||||
|
if "pn" in norm and "type" in norm:
|
||||||
|
return "items"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def col_letter(index: int) -> str:
|
||||||
|
"""Convert 0-based column index to spreadsheet letter (A, B, ..., AA, AB)."""
|
||||||
|
result = ""
|
||||||
|
while True:
|
||||||
|
result = chr(65 + index % 26) + result
|
||||||
|
index = index // 26 - 1
|
||||||
|
if index < 0:
|
||||||
|
break
|
||||||
|
return result
|
||||||
160
pkg/calc/pythonpath/silo_calc/sync_engine.py
Normal file
160
pkg/calc/pythonpath/silo_calc/sync_engine.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"""Row hashing, diff classification, and sync state tracking.
|
||||||
|
|
||||||
|
Used by push/pull commands to detect which rows have been modified locally
|
||||||
|
since the last pull, and to detect conflicts with server-side changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from . import sheet_format as sf
|
||||||
|
|
||||||
|
# Row statuses
|
||||||
|
STATUS_SYNCED = "synced"
|
||||||
|
STATUS_MODIFIED = "modified"
|
||||||
|
STATUS_NEW = "new"
|
||||||
|
STATUS_ERROR = "error"
|
||||||
|
STATUS_CONFLICT = "conflict"
|
||||||
|
|
||||||
|
|
||||||
|
def compute_row_hash(cells: List[str]) -> str:
|
||||||
|
"""SHA-256 hash of the visible + property columns of a row.
|
||||||
|
|
||||||
|
Only the data columns are hashed (not sync tracking columns).
|
||||||
|
Blank/empty cells are normalised to the empty string.
|
||||||
|
"""
|
||||||
|
# Use columns 0..COL_PROP_END-1 (visible + properties, not sync cols)
|
||||||
|
data_cells = cells[: sf.COL_PROP_END]
|
||||||
|
# Normalise
|
||||||
|
normalised = [str(c).strip() if c else "" for c in data_cells]
|
||||||
|
raw = "\t".join(normalised).encode("utf-8")
|
||||||
|
return hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def classify_row(cells: List[str]) -> str:
|
||||||
|
"""Return the sync status of a single row.
|
||||||
|
|
||||||
|
Reads the stored hash and current cell values to determine whether
|
||||||
|
the row is synced, modified, new, or in an error state.
|
||||||
|
"""
|
||||||
|
# Ensure we have enough columns
|
||||||
|
while len(cells) < sf.BOM_TOTAL_COLS:
|
||||||
|
cells.append("")
|
||||||
|
|
||||||
|
stored_hash = cells[sf.COL_ROW_HASH].strip() if cells[sf.COL_ROW_HASH] else ""
|
||||||
|
stored_status = cells[sf.COL_ROW_STATUS].strip() if cells[sf.COL_ROW_STATUS] else ""
|
||||||
|
|
||||||
|
# No hash -> new row (never pulled from server)
|
||||||
|
if not stored_hash:
|
||||||
|
# Check if there's any data in the row
|
||||||
|
has_data = any(
|
||||||
|
cells[i].strip()
|
||||||
|
for i in range(sf.COL_PROP_END)
|
||||||
|
if i < len(cells) and cells[i]
|
||||||
|
)
|
||||||
|
return STATUS_NEW if has_data else ""
|
||||||
|
|
||||||
|
# Compute current hash and compare
|
||||||
|
current_hash = compute_row_hash(cells)
|
||||||
|
if current_hash == stored_hash:
|
||||||
|
return STATUS_SYNCED
|
||||||
|
return STATUS_MODIFIED
|
||||||
|
|
||||||
|
|
||||||
|
def classify_rows(all_rows: List[List[str]]) -> List[Tuple[int, str, List[str]]]:
|
||||||
|
"""Classify every row in a sheet.
|
||||||
|
|
||||||
|
Returns list of ``(row_index, status, cells)`` for rows that have data.
|
||||||
|
Blank separator rows and the header row (index 0) are skipped.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
for i, cells in enumerate(all_rows):
|
||||||
|
if i == 0:
|
||||||
|
continue # header row
|
||||||
|
status = classify_row(list(cells))
|
||||||
|
if status:
|
||||||
|
results.append((i, status, list(cells)))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def build_push_diff(
|
||||||
|
classified: List[Tuple[int, str, List[str]]],
|
||||||
|
server_timestamps: Optional[Dict[str, str]] = None,
|
||||||
|
) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
|
"""Build a push diff from classified rows.
|
||||||
|
|
||||||
|
*server_timestamps* maps part numbers to their server ``updated_at``
|
||||||
|
values, used for conflict detection.
|
||||||
|
|
||||||
|
Returns a dict with keys ``new``, ``modified``, ``conflicts``, and
|
||||||
|
the count of ``unchanged`` rows.
|
||||||
|
"""
|
||||||
|
server_ts = server_timestamps or {}
|
||||||
|
new_rows = []
|
||||||
|
modified_rows = []
|
||||||
|
conflicts = []
|
||||||
|
unchanged = 0
|
||||||
|
|
||||||
|
for row_idx, status, cells in classified:
|
||||||
|
if status == STATUS_SYNCED:
|
||||||
|
unchanged += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
pn = cells[sf.COL_PN].strip() if len(cells) > sf.COL_PN else ""
|
||||||
|
stored_ts = (
|
||||||
|
cells[sf.COL_UPDATED_AT].strip()
|
||||||
|
if len(cells) > sf.COL_UPDATED_AT and cells[sf.COL_UPDATED_AT]
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
row_info = {
|
||||||
|
"row_index": row_idx,
|
||||||
|
"part_number": pn,
|
||||||
|
"description": cells[sf.COL_DESCRIPTION].strip()
|
||||||
|
if len(cells) > sf.COL_DESCRIPTION
|
||||||
|
else "",
|
||||||
|
"cells": cells[: sf.COL_PROP_END],
|
||||||
|
}
|
||||||
|
|
||||||
|
if status == STATUS_NEW:
|
||||||
|
new_rows.append(row_info)
|
||||||
|
elif status == STATUS_MODIFIED:
|
||||||
|
# Check for conflict: server changed since we pulled
|
||||||
|
server_updated = server_ts.get(pn, "")
|
||||||
|
if stored_ts and server_updated and server_updated != stored_ts:
|
||||||
|
row_info["local_ts"] = stored_ts
|
||||||
|
row_info["server_ts"] = server_updated
|
||||||
|
conflicts.append(row_info)
|
||||||
|
else:
|
||||||
|
modified_rows.append(row_info)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"new": new_rows,
|
||||||
|
"modified": modified_rows,
|
||||||
|
"conflicts": conflicts,
|
||||||
|
"unchanged": unchanged,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def update_row_sync_state(
|
||||||
|
cells: List[str],
|
||||||
|
status: str,
|
||||||
|
updated_at: str = "",
|
||||||
|
parent_pn: str = "",
|
||||||
|
) -> List[str]:
|
||||||
|
"""Set the sync tracking columns on a row and return it.
|
||||||
|
|
||||||
|
Recomputes the row hash from current visible+property data.
|
||||||
|
"""
|
||||||
|
while len(cells) < sf.BOM_TOTAL_COLS:
|
||||||
|
cells.append("")
|
||||||
|
|
||||||
|
cells[sf.COL_ROW_HASH] = compute_row_hash(cells)
|
||||||
|
cells[sf.COL_ROW_STATUS] = status
|
||||||
|
if updated_at:
|
||||||
|
cells[sf.COL_UPDATED_AT] = updated_at
|
||||||
|
if parent_pn:
|
||||||
|
cells[sf.COL_PARENT_PN] = parent_pn
|
||||||
|
|
||||||
|
return cells
|
||||||
496
pkg/calc/silo_calc_component.py
Normal file
496
pkg/calc/silo_calc_component.py
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
"""UNO ProtocolHandler component for the Silo Calc extension.
|
||||||
|
|
||||||
|
This file is registered in META-INF/manifest.xml and acts as the entry
|
||||||
|
point for all toolbar / menu commands. Each custom protocol URL
|
||||||
|
dispatches to a handler function that orchestrates the corresponding
|
||||||
|
feature.
|
||||||
|
|
||||||
|
All silo_calc submodule imports are deferred to handler call time so
|
||||||
|
that the component registration always succeeds even if a submodule
|
||||||
|
has issues.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import uno
|
||||||
|
import unohelper
|
||||||
|
from com.sun.star.frame import XDispatch, XDispatchProvider
|
||||||
|
from com.sun.star.lang import XInitialization, XServiceInfo
|
||||||
|
|
||||||
|
# Ensure pythonpath/ is importable
|
||||||
|
_ext_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_pypath = os.path.join(_ext_dir, "pythonpath")
|
||||||
|
if _pypath not in sys.path:
|
||||||
|
sys.path.insert(0, _pypath)
|
||||||
|
|
||||||
|
# Service identifiers
|
||||||
|
_IMPL_NAME = "io.kindredsystems.silo.calc.Component"
|
||||||
|
_SERVICE_NAME = "com.sun.star.frame.ProtocolHandler"
|
||||||
|
_PROTOCOL = "io.kindredsystems.silo.calc:"
|
||||||
|
|
||||||
|
|
||||||
|
def _log(msg: str):
|
||||||
|
"""Print to the LibreOffice terminal / stderr."""
|
||||||
|
print(f"[Silo Calc] {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_desktop():
|
||||||
|
ctx = uno.getComponentContext()
|
||||||
|
smgr = ctx.ServiceManager
|
||||||
|
return smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_active_sheet():
|
||||||
|
"""Return (doc, sheet) for the current spreadsheet, or (None, None)."""
|
||||||
|
desktop = _get_desktop()
|
||||||
|
doc = desktop.getCurrentComponent()
|
||||||
|
if doc is None:
|
||||||
|
return None, None
|
||||||
|
if not doc.supportsService("com.sun.star.sheet.SpreadsheetDocument"):
|
||||||
|
return None, None
|
||||||
|
sheet = doc.getSheets().getByIndex(
|
||||||
|
doc.getCurrentController().getActiveSheet().getRangeAddress().Sheet
|
||||||
|
)
|
||||||
|
return doc, sheet
|
||||||
|
|
||||||
|
|
||||||
|
def _msgbox(title, message, box_type="infobox"):
|
||||||
|
"""Lightweight message box that doesn't depend on dialogs module."""
|
||||||
|
ctx = uno.getComponentContext()
|
||||||
|
smgr = ctx.ServiceManager
|
||||||
|
toolkit = smgr.createInstanceWithContext("com.sun.star.awt.Toolkit", ctx)
|
||||||
|
parent = _get_desktop().getCurrentFrame().getContainerWindow()
|
||||||
|
mbt = uno.Enum(
|
||||||
|
"com.sun.star.awt.MessageBoxType",
|
||||||
|
"INFOBOX" if box_type == "infobox" else "ERRORBOX",
|
||||||
|
)
|
||||||
|
box = toolkit.createMessageBox(parent, mbt, 1, title, message)
|
||||||
|
box.execute()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Command handlers -- imports are deferred to call time
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_login(frame):
|
||||||
|
from silo_calc import dialogs
|
||||||
|
|
||||||
|
dialogs.show_login_dialog()
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_settings(frame):
|
||||||
|
from silo_calc import dialogs
|
||||||
|
|
||||||
|
dialogs.show_settings_dialog()
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_pull_bom(frame):
|
||||||
|
"""Pull a BOM from the server and populate the active sheet."""
|
||||||
|
from silo_calc import dialogs, project_files
|
||||||
|
from silo_calc import pull as _pull
|
||||||
|
from silo_calc import settings as _settings
|
||||||
|
from silo_calc.client import SiloClient
|
||||||
|
|
||||||
|
client = SiloClient()
|
||||||
|
if not client.is_authenticated():
|
||||||
|
dialogs.show_login_dialog()
|
||||||
|
client = SiloClient() # reload after login
|
||||||
|
if not client.is_authenticated():
|
||||||
|
return
|
||||||
|
|
||||||
|
pn = dialogs.show_assembly_picker(client)
|
||||||
|
if not pn:
|
||||||
|
return
|
||||||
|
|
||||||
|
project_code = (
|
||||||
|
dialogs._input_box("Pull BOM", "Project code for auto-tagging (optional):")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
doc, sheet = _get_active_sheet()
|
||||||
|
if doc is None:
|
||||||
|
desktop = _get_desktop()
|
||||||
|
doc = desktop.loadComponentFromURL("private:factory/scalc", "_blank", 0, ())
|
||||||
|
sheet = doc.getSheets().getByIndex(0)
|
||||||
|
sheet.setName("BOM")
|
||||||
|
|
||||||
|
try:
|
||||||
|
count = _pull.pull_bom(
|
||||||
|
client,
|
||||||
|
doc,
|
||||||
|
sheet,
|
||||||
|
pn,
|
||||||
|
project_code=project_code.strip(),
|
||||||
|
schema=_settings.get("default_schema", "kindred-rd"),
|
||||||
|
)
|
||||||
|
_log(f"Pulled BOM for {pn}: {count} rows")
|
||||||
|
|
||||||
|
if project_code.strip():
|
||||||
|
path = project_files.get_project_sheet_path(project_code.strip())
|
||||||
|
project_files.ensure_project_dir(project_code.strip())
|
||||||
|
url = uno.systemPathToFileUrl(str(path))
|
||||||
|
doc.storeToURL(url, ())
|
||||||
|
_log(f"Saved to {path}")
|
||||||
|
|
||||||
|
_msgbox("Pull BOM", f"Pulled {count} rows for {pn}.")
|
||||||
|
except RuntimeError as e:
|
||||||
|
_msgbox("Pull BOM Failed", str(e), box_type="errorbox")
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_pull_project(frame):
|
||||||
|
"""Pull all project items as a multi-sheet workbook."""
|
||||||
|
from silo_calc import dialogs, project_files
|
||||||
|
from silo_calc import pull as _pull
|
||||||
|
from silo_calc import settings as _settings
|
||||||
|
from silo_calc.client import SiloClient
|
||||||
|
|
||||||
|
client = SiloClient()
|
||||||
|
if not client.is_authenticated():
|
||||||
|
dialogs.show_login_dialog()
|
||||||
|
client = SiloClient()
|
||||||
|
if not client.is_authenticated():
|
||||||
|
return
|
||||||
|
|
||||||
|
code = dialogs.show_project_picker(client)
|
||||||
|
if not code:
|
||||||
|
return
|
||||||
|
|
||||||
|
doc, _ = _get_active_sheet()
|
||||||
|
if doc is None:
|
||||||
|
desktop = _get_desktop()
|
||||||
|
doc = desktop.loadComponentFromURL("private:factory/scalc", "_blank", 0, ())
|
||||||
|
|
||||||
|
try:
|
||||||
|
count = _pull.pull_project(
|
||||||
|
client,
|
||||||
|
doc,
|
||||||
|
code.strip(),
|
||||||
|
schema=_settings.get("default_schema", "kindred-rd"),
|
||||||
|
)
|
||||||
|
_log(f"Pulled project {code}: {count} items")
|
||||||
|
|
||||||
|
path = project_files.get_project_sheet_path(code.strip())
|
||||||
|
project_files.ensure_project_dir(code.strip())
|
||||||
|
url = uno.systemPathToFileUrl(str(path))
|
||||||
|
doc.storeToURL(url, ())
|
||||||
|
_log(f"Saved to {path}")
|
||||||
|
|
||||||
|
_msgbox("Pull Project", f"Pulled {count} items for project {code}.")
|
||||||
|
except RuntimeError as e:
|
||||||
|
_msgbox("Pull Project Failed", str(e), box_type="errorbox")
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_push(frame):
|
||||||
|
"""Push local changes back to the server."""
|
||||||
|
from silo_calc import dialogs, sync_engine
|
||||||
|
from silo_calc import push as _push
|
||||||
|
from silo_calc import settings as _settings
|
||||||
|
from silo_calc import sheet_format as sf
|
||||||
|
from silo_calc.client import SiloClient
|
||||||
|
|
||||||
|
client = SiloClient()
|
||||||
|
if not client.is_authenticated():
|
||||||
|
dialogs.show_login_dialog()
|
||||||
|
client = SiloClient()
|
||||||
|
if not client.is_authenticated():
|
||||||
|
return
|
||||||
|
|
||||||
|
doc, sheet = _get_active_sheet()
|
||||||
|
if doc is None or sheet is None:
|
||||||
|
_msgbox("Push", "No active spreadsheet.", box_type="errorbox")
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = _push._read_sheet_rows(sheet)
|
||||||
|
classified = sync_engine.classify_rows(rows)
|
||||||
|
|
||||||
|
modified_pns = [
|
||||||
|
cells[sf.COL_PN].strip()
|
||||||
|
for _, status, cells in classified
|
||||||
|
if status == sync_engine.STATUS_MODIFIED and cells[sf.COL_PN].strip()
|
||||||
|
]
|
||||||
|
server_ts = _push._fetch_server_timestamps(client, modified_pns)
|
||||||
|
diff = sync_engine.build_push_diff(classified, server_timestamps=server_ts)
|
||||||
|
|
||||||
|
ok = dialogs.show_push_summary(
|
||||||
|
new_count=len(diff["new"]),
|
||||||
|
modified_count=len(diff["modified"]),
|
||||||
|
conflict_count=len(diff["conflicts"]),
|
||||||
|
unchanged_count=diff["unchanged"],
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = _push.push_sheet(
|
||||||
|
client,
|
||||||
|
doc,
|
||||||
|
sheet,
|
||||||
|
schema=_settings.get("default_schema", "kindred-rd"),
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
_msgbox("Push Failed", str(e), box_type="errorbox")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_url = doc.getURL()
|
||||||
|
if file_url:
|
||||||
|
doc.store()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
summary_lines = [
|
||||||
|
f"Created: {results['created']}",
|
||||||
|
f"Updated: {results['updated']}",
|
||||||
|
f"Conflicts: {results.get('conflicts', 0)}",
|
||||||
|
f"Skipped: {results['skipped']}",
|
||||||
|
]
|
||||||
|
if results["errors"]:
|
||||||
|
summary_lines.append(f"\nErrors ({len(results['errors'])}):")
|
||||||
|
for err in results["errors"][:10]:
|
||||||
|
summary_lines.append(f" - {err}")
|
||||||
|
if len(results["errors"]) > 10:
|
||||||
|
summary_lines.append(f" ... and {len(results['errors']) - 10} more")
|
||||||
|
|
||||||
|
_msgbox("Push Complete", "\n".join(summary_lines))
|
||||||
|
_log(f"Push complete: {results['created']} created, {results['updated']} updated")
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_add_item(frame):
|
||||||
|
"""Completion wizard for adding a new BOM row."""
|
||||||
|
from silo_calc import completion_wizard as _wizard
|
||||||
|
from silo_calc import settings as _settings
|
||||||
|
from silo_calc.client import SiloClient
|
||||||
|
|
||||||
|
client = SiloClient()
|
||||||
|
if not client.is_authenticated():
|
||||||
|
from silo_calc import dialogs
|
||||||
|
|
||||||
|
dialogs.show_login_dialog()
|
||||||
|
client = SiloClient()
|
||||||
|
if not client.is_authenticated():
|
||||||
|
return
|
||||||
|
|
||||||
|
doc, sheet = _get_active_sheet()
|
||||||
|
if doc is None or sheet is None:
|
||||||
|
_msgbox("Add Item", "No active spreadsheet.", box_type="errorbox")
|
||||||
|
return
|
||||||
|
|
||||||
|
project_code = ""
|
||||||
|
try:
|
||||||
|
file_url = doc.getURL()
|
||||||
|
if file_url:
|
||||||
|
file_path = uno.fileUrlToSystemPath(file_url)
|
||||||
|
parts = file_path.replace("\\", "/").split("/")
|
||||||
|
if "sheets" in parts:
|
||||||
|
idx = parts.index("sheets")
|
||||||
|
if idx + 1 < len(parts):
|
||||||
|
project_code = parts[idx + 1]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cursor = sheet.createCursor()
|
||||||
|
cursor.gotoStartOfUsedArea(False)
|
||||||
|
cursor.gotoEndOfUsedArea(True)
|
||||||
|
insert_row = cursor.getRangeAddress().EndRow + 1
|
||||||
|
|
||||||
|
ok = _wizard.run_completion_wizard(
|
||||||
|
client,
|
||||||
|
doc,
|
||||||
|
sheet,
|
||||||
|
insert_row,
|
||||||
|
project_code=project_code,
|
||||||
|
schema=_settings.get("default_schema", "kindred-rd"),
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
_log(f"Added new item at row {insert_row + 1}")
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_refresh(frame):
|
||||||
|
"""Re-pull the current sheet from server."""
|
||||||
|
_msgbox("Refresh", "Refresh -- coming soon.")
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_ai_description(frame):
|
||||||
|
"""Generate an AI description from the seller description in the current row."""
|
||||||
|
from silo_calc import ai_client as _ai
|
||||||
|
from silo_calc import dialogs
|
||||||
|
from silo_calc import pull as _pull
|
||||||
|
from silo_calc import sheet_format as sf
|
||||||
|
|
||||||
|
if not _ai.is_configured():
|
||||||
|
_msgbox(
|
||||||
|
"AI Describe",
|
||||||
|
"OpenRouter API key not configured.\n\n"
|
||||||
|
"Set it in Silo Settings or via the OPENROUTER_API_KEY environment variable.",
|
||||||
|
box_type="errorbox",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
doc, sheet = _get_active_sheet()
|
||||||
|
if doc is None or sheet is None:
|
||||||
|
_msgbox("AI Describe", "No active spreadsheet.", box_type="errorbox")
|
||||||
|
return
|
||||||
|
|
||||||
|
controller = doc.getCurrentController()
|
||||||
|
selection = controller.getSelection()
|
||||||
|
try:
|
||||||
|
cell_addr = selection.getCellAddress()
|
||||||
|
row = cell_addr.Row
|
||||||
|
except AttributeError:
|
||||||
|
try:
|
||||||
|
range_addr = selection.getRangeAddress()
|
||||||
|
row = range_addr.StartRow
|
||||||
|
except AttributeError:
|
||||||
|
_msgbox("AI Describe", "Select a cell in a BOM row.", box_type="errorbox")
|
||||||
|
return
|
||||||
|
|
||||||
|
if row == 0:
|
||||||
|
_msgbox(
|
||||||
|
"AI Describe", "Select a data row, not the header.", box_type="errorbox"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
seller_desc = sheet.getCellByPosition(sf.COL_SELLER_DESC, row).getString().strip()
|
||||||
|
if not seller_desc:
|
||||||
|
_msgbox(
|
||||||
|
"AI Describe",
|
||||||
|
f"No seller description in column F (row {row + 1}).",
|
||||||
|
box_type="errorbox",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
existing_desc = sheet.getCellByPosition(sf.COL_DESCRIPTION, row).getString().strip()
|
||||||
|
part_number = sheet.getCellByPosition(sf.COL_PN, row).getString().strip()
|
||||||
|
category = part_number[:3] if len(part_number) >= 3 else ""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
ai_desc = _ai.generate_description(
|
||||||
|
seller_description=seller_desc,
|
||||||
|
category=category,
|
||||||
|
existing_description=existing_desc,
|
||||||
|
part_number=part_number,
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
_msgbox("AI Describe Failed", str(e), box_type="errorbox")
|
||||||
|
return
|
||||||
|
|
||||||
|
accepted = dialogs.show_ai_description_dialog(seller_desc, ai_desc)
|
||||||
|
if accepted is not None:
|
||||||
|
_pull._set_cell_string(sheet, sf.COL_DESCRIPTION, row, accepted)
|
||||||
|
_log(f"AI description written to row {row + 1}: {accepted}")
|
||||||
|
return
|
||||||
|
|
||||||
|
retry = dialogs._input_box(
|
||||||
|
"AI Describe",
|
||||||
|
"Generate again? (yes/no):",
|
||||||
|
default="no",
|
||||||
|
)
|
||||||
|
if not retry or retry.strip().lower() not in ("yes", "y"):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# Command dispatch table
|
||||||
|
_COMMANDS = {
|
||||||
|
"SiloLogin": _cmd_login,
|
||||||
|
"SiloPullBOM": _cmd_pull_bom,
|
||||||
|
"SiloPullProject": _cmd_pull_project,
|
||||||
|
"SiloPush": _cmd_push,
|
||||||
|
"SiloAddItem": _cmd_add_item,
|
||||||
|
"SiloRefresh": _cmd_refresh,
|
||||||
|
"SiloSettings": _cmd_settings,
|
||||||
|
"SiloAIDescription": _cmd_ai_description,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# UNO Dispatch implementation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SiloDispatch(unohelper.Base, XDispatch):
|
||||||
|
"""Handles a single dispatched command."""
|
||||||
|
|
||||||
|
def __init__(self, command: str, frame):
|
||||||
|
self._command = command
|
||||||
|
self._frame = frame
|
||||||
|
self._listeners = []
|
||||||
|
|
||||||
|
def dispatch(self, url, args):
|
||||||
|
handler = _COMMANDS.get(self._command)
|
||||||
|
if handler:
|
||||||
|
try:
|
||||||
|
handler(self._frame)
|
||||||
|
except Exception:
|
||||||
|
_log(f"Error in {self._command}:\n{traceback.format_exc()}")
|
||||||
|
try:
|
||||||
|
_msgbox(
|
||||||
|
f"Silo Error: {self._command}",
|
||||||
|
traceback.format_exc(),
|
||||||
|
box_type="errorbox",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def addStatusListener(self, listener, url):
|
||||||
|
self._listeners.append(listener)
|
||||||
|
|
||||||
|
def removeStatusListener(self, listener, url):
|
||||||
|
if listener in self._listeners:
|
||||||
|
self._listeners.remove(listener)
|
||||||
|
|
||||||
|
|
||||||
|
class SiloDispatchProvider(
|
||||||
|
unohelper.Base, XDispatchProvider, XInitialization, XServiceInfo
|
||||||
|
):
|
||||||
|
"""ProtocolHandler component for Silo commands.
|
||||||
|
|
||||||
|
LibreOffice instantiates this via com.sun.star.frame.ProtocolHandler
|
||||||
|
and calls initialize() with the frame, then queryDispatch() for each
|
||||||
|
command URL matching our protocol.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ctx):
|
||||||
|
self._ctx = ctx
|
||||||
|
self._frame = None
|
||||||
|
|
||||||
|
# XInitialization -- called by framework with the Frame
|
||||||
|
def initialize(self, args):
|
||||||
|
if args:
|
||||||
|
self._frame = args[0]
|
||||||
|
|
||||||
|
# XDispatchProvider
|
||||||
|
def queryDispatch(self, url, target_frame_name, search_flags):
|
||||||
|
if url.Protocol == _PROTOCOL:
|
||||||
|
command = url.Path
|
||||||
|
if command in _COMMANDS:
|
||||||
|
return SiloDispatch(command, self._frame)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def queryDispatches(self, requests):
|
||||||
|
return [
|
||||||
|
self.queryDispatch(r.FeatureURL, r.FrameName, r.SearchFlags)
|
||||||
|
for r in requests
|
||||||
|
]
|
||||||
|
|
||||||
|
# XServiceInfo
|
||||||
|
def getImplementationName(self):
|
||||||
|
return _IMPL_NAME
|
||||||
|
|
||||||
|
def supportsService(self, name):
|
||||||
|
return name == _SERVICE_NAME
|
||||||
|
|
||||||
|
def getSupportedServiceNames(self):
|
||||||
|
return (_SERVICE_NAME,)
|
||||||
|
|
||||||
|
|
||||||
|
# UNO component registration
|
||||||
|
g_ImplementationHelper = unohelper.ImplementationHelper()
|
||||||
|
g_ImplementationHelper.addImplementation(
|
||||||
|
SiloDispatchProvider,
|
||||||
|
_IMPL_NAME,
|
||||||
|
(_SERVICE_NAME,),
|
||||||
|
)
|
||||||
0
pkg/calc/tests/__init__.py
Normal file
0
pkg/calc/tests/__init__.py
Normal file
345
pkg/calc/tests/test_basics.py
Normal file
345
pkg/calc/tests/test_basics.py
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
"""Basic tests for silo_calc modules (no UNO dependency)."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add pythonpath to sys.path so we can import without LibreOffice
|
||||||
|
_pkg_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
_pypath = os.path.join(_pkg_dir, "pythonpath")
|
||||||
|
if _pypath not in sys.path:
|
||||||
|
sys.path.insert(0, _pypath)
|
||||||
|
|
||||||
|
from silo_calc import project_files, sync_engine
|
||||||
|
from silo_calc import settings as _settings
|
||||||
|
from silo_calc import sheet_format as sf
|
||||||
|
|
||||||
|
|
||||||
|
class TestSheetFormat(unittest.TestCase):
|
||||||
|
def test_bom_header_counts(self):
|
||||||
|
self.assertEqual(len(sf.BOM_VISIBLE_HEADERS), 11)
|
||||||
|
self.assertEqual(len(sf.BOM_PROPERTY_HEADERS), 13)
|
||||||
|
self.assertEqual(len(sf.BOM_SYNC_HEADERS), 4)
|
||||||
|
self.assertEqual(sf.BOM_TOTAL_COLS, 28)
|
||||||
|
|
||||||
|
def test_column_indices(self):
|
||||||
|
self.assertEqual(sf.COL_ITEM, 0)
|
||||||
|
self.assertEqual(sf.COL_PN, 3)
|
||||||
|
self.assertEqual(sf.COL_UNIT_COST, 6)
|
||||||
|
self.assertEqual(sf.COL_QTY, 7)
|
||||||
|
self.assertEqual(sf.COL_EXT_COST, 8)
|
||||||
|
|
||||||
|
def test_detect_sheet_type_bom(self):
|
||||||
|
headers = [
|
||||||
|
"Item",
|
||||||
|
"Level",
|
||||||
|
"Source",
|
||||||
|
"PN",
|
||||||
|
"Description",
|
||||||
|
"Seller Description",
|
||||||
|
"Unit Cost",
|
||||||
|
"QTY",
|
||||||
|
"Ext Cost",
|
||||||
|
]
|
||||||
|
self.assertEqual(sf.detect_sheet_type(headers), "bom")
|
||||||
|
|
||||||
|
def test_detect_sheet_type_items(self):
|
||||||
|
headers = ["PN", "Description", "Type", "Source"]
|
||||||
|
self.assertEqual(sf.detect_sheet_type(headers), "items")
|
||||||
|
|
||||||
|
def test_detect_sheet_type_unknown(self):
|
||||||
|
self.assertIsNone(sf.detect_sheet_type([]))
|
||||||
|
self.assertIsNone(sf.detect_sheet_type(["Foo", "Bar"]))
|
||||||
|
|
||||||
|
def test_col_letter(self):
|
||||||
|
self.assertEqual(sf.col_letter(0), "A")
|
||||||
|
self.assertEqual(sf.col_letter(25), "Z")
|
||||||
|
self.assertEqual(sf.col_letter(26), "AA")
|
||||||
|
self.assertEqual(sf.col_letter(27), "AB")
|
||||||
|
|
||||||
|
def test_property_key_map_bidirectional(self):
|
||||||
|
for header, key in sf.PROPERTY_KEY_MAP.items():
|
||||||
|
self.assertEqual(sf.DB_FIELD_TO_HEADER[key], header)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncEngine(unittest.TestCase):
|
||||||
|
def _make_row(self, pn="F01-0001", desc="Test", cost="10.00", qty="2"):
|
||||||
|
"""Create a minimal BOM row with enough columns."""
|
||||||
|
cells = [""] * sf.BOM_TOTAL_COLS
|
||||||
|
cells[sf.COL_PN] = pn
|
||||||
|
cells[sf.COL_DESCRIPTION] = desc
|
||||||
|
cells[sf.COL_UNIT_COST] = cost
|
||||||
|
cells[sf.COL_QTY] = qty
|
||||||
|
return cells
|
||||||
|
|
||||||
|
def test_compute_row_hash_deterministic(self):
|
||||||
|
row = self._make_row()
|
||||||
|
h1 = sync_engine.compute_row_hash(row)
|
||||||
|
h2 = sync_engine.compute_row_hash(row)
|
||||||
|
self.assertEqual(h1, h2)
|
||||||
|
self.assertEqual(len(h1), 64) # SHA-256 hex
|
||||||
|
|
||||||
|
def test_compute_row_hash_changes(self):
|
||||||
|
row1 = self._make_row(cost="10.00")
|
||||||
|
row2 = self._make_row(cost="20.00")
|
||||||
|
self.assertNotEqual(
|
||||||
|
sync_engine.compute_row_hash(row1),
|
||||||
|
sync_engine.compute_row_hash(row2),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_classify_row_new(self):
|
||||||
|
row = self._make_row()
|
||||||
|
# No stored hash -> new
|
||||||
|
self.assertEqual(sync_engine.classify_row(row), sync_engine.STATUS_NEW)
|
||||||
|
|
||||||
|
def test_classify_row_synced(self):
|
||||||
|
row = self._make_row()
|
||||||
|
# Set stored hash to current hash
|
||||||
|
row[sf.COL_ROW_HASH] = sync_engine.compute_row_hash(row)
|
||||||
|
row[sf.COL_ROW_STATUS] = "synced"
|
||||||
|
self.assertEqual(sync_engine.classify_row(row), sync_engine.STATUS_SYNCED)
|
||||||
|
|
||||||
|
def test_classify_row_modified(self):
|
||||||
|
row = self._make_row()
|
||||||
|
row[sf.COL_ROW_HASH] = sync_engine.compute_row_hash(row)
|
||||||
|
# Now change a cell
|
||||||
|
row[sf.COL_UNIT_COST] = "99.99"
|
||||||
|
self.assertEqual(sync_engine.classify_row(row), sync_engine.STATUS_MODIFIED)
|
||||||
|
|
||||||
|
def test_classify_rows_skips_header(self):
|
||||||
|
header = list(sf.BOM_ALL_HEADERS)
|
||||||
|
row1 = self._make_row()
|
||||||
|
all_rows = [header, row1]
|
||||||
|
classified = sync_engine.classify_rows(all_rows)
|
||||||
|
# Header row (index 0) should be skipped
|
||||||
|
self.assertEqual(len(classified), 1)
|
||||||
|
self.assertEqual(classified[0][0], 1) # row index
|
||||||
|
|
||||||
|
def test_update_row_sync_state(self):
|
||||||
|
row = self._make_row()
|
||||||
|
updated = sync_engine.update_row_sync_state(
|
||||||
|
row, "synced", updated_at="2025-01-01T00:00:00Z", parent_pn="A01-0003"
|
||||||
|
)
|
||||||
|
self.assertEqual(updated[sf.COL_ROW_STATUS], "synced")
|
||||||
|
self.assertEqual(updated[sf.COL_UPDATED_AT], "2025-01-01T00:00:00Z")
|
||||||
|
self.assertEqual(updated[sf.COL_PARENT_PN], "A01-0003")
|
||||||
|
# Hash should be set
|
||||||
|
self.assertEqual(len(updated[sf.COL_ROW_HASH]), 64)
|
||||||
|
|
||||||
|
def test_build_push_diff(self):
|
||||||
|
row_new = self._make_row(pn="NEW-0001")
|
||||||
|
row_synced = self._make_row(pn="F01-0001")
|
||||||
|
row_synced[sf.COL_ROW_HASH] = sync_engine.compute_row_hash(row_synced)
|
||||||
|
|
||||||
|
row_modified = self._make_row(pn="F01-0002")
|
||||||
|
row_modified[sf.COL_ROW_HASH] = sync_engine.compute_row_hash(row_modified)
|
||||||
|
row_modified[sf.COL_UNIT_COST] = "999.99" # change after hash
|
||||||
|
|
||||||
|
classified = [
|
||||||
|
(1, sync_engine.STATUS_NEW, row_new),
|
||||||
|
(2, sync_engine.STATUS_SYNCED, row_synced),
|
||||||
|
(3, sync_engine.STATUS_MODIFIED, row_modified),
|
||||||
|
]
|
||||||
|
diff = sync_engine.build_push_diff(classified)
|
||||||
|
self.assertEqual(len(diff["new"]), 1)
|
||||||
|
self.assertEqual(len(diff["modified"]), 1)
|
||||||
|
self.assertEqual(diff["unchanged"], 1)
|
||||||
|
self.assertEqual(len(diff["conflicts"]), 0)
|
||||||
|
|
||||||
|
def test_conflict_detection(self):
|
||||||
|
row = self._make_row(pn="F01-0001")
|
||||||
|
row[sf.COL_ROW_HASH] = sync_engine.compute_row_hash(row)
|
||||||
|
row[sf.COL_UPDATED_AT] = "2025-01-01T00:00:00Z"
|
||||||
|
row[sf.COL_UNIT_COST] = "changed" # local modification
|
||||||
|
|
||||||
|
classified = [(1, sync_engine.STATUS_MODIFIED, row)]
|
||||||
|
diff = sync_engine.build_push_diff(
|
||||||
|
classified,
|
||||||
|
server_timestamps={
|
||||||
|
"F01-0001": "2025-06-01T00:00:00Z"
|
||||||
|
}, # server changed too
|
||||||
|
)
|
||||||
|
self.assertEqual(len(diff["conflicts"]), 1)
|
||||||
|
self.assertEqual(len(diff["modified"]), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettings(unittest.TestCase):
|
||||||
|
def test_load_defaults(self):
|
||||||
|
# Use a temp dir so we don't touch real settings
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
_settings._SETTINGS_FILE = Path(tmp) / "test-settings.json"
|
||||||
|
cfg = _settings.load()
|
||||||
|
self.assertEqual(cfg["ssl_verify"], True)
|
||||||
|
self.assertEqual(cfg["default_schema"], "kindred-rd")
|
||||||
|
|
||||||
|
def test_save_and_load(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
_settings._SETTINGS_DIR = Path(tmp)
|
||||||
|
_settings._SETTINGS_FILE = Path(tmp) / "test-settings.json"
|
||||||
|
_settings.put("api_url", "https://silo.test/api")
|
||||||
|
cfg = _settings.load()
|
||||||
|
self.assertEqual(cfg["api_url"], "https://silo.test/api")
|
||||||
|
|
||||||
|
def test_save_auth_and_clear(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
_settings._SETTINGS_DIR = Path(tmp)
|
||||||
|
_settings._SETTINGS_FILE = Path(tmp) / "test-settings.json"
|
||||||
|
_settings.save_auth("testuser", "editor", "local", "silo_abc123")
|
||||||
|
cfg = _settings.load()
|
||||||
|
self.assertEqual(cfg["auth_username"], "testuser")
|
||||||
|
self.assertEqual(cfg["api_token"], "silo_abc123")
|
||||||
|
|
||||||
|
_settings.clear_auth()
|
||||||
|
cfg = _settings.load()
|
||||||
|
self.assertEqual(cfg["api_token"], "")
|
||||||
|
self.assertEqual(cfg["auth_username"], "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestProjectFiles(unittest.TestCase):
|
||||||
|
def test_get_project_sheet_path(self):
|
||||||
|
path = project_files.get_project_sheet_path("3DX10")
|
||||||
|
self.assertTrue(str(path).endswith("sheets/3DX10/3DX10.ods"))
|
||||||
|
|
||||||
|
def test_save_and_read(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
_settings._SETTINGS_DIR = Path(tmp)
|
||||||
|
_settings._SETTINGS_FILE = Path(tmp) / "test-settings.json"
|
||||||
|
_settings.put("projects_dir", tmp)
|
||||||
|
|
||||||
|
test_data = b"PK\x03\x04fake-ods-content"
|
||||||
|
path = project_files.save_project_sheet("TEST", test_data)
|
||||||
|
self.assertTrue(path.is_file())
|
||||||
|
self.assertEqual(path.name, "TEST.ods")
|
||||||
|
|
||||||
|
read_back = project_files.read_project_sheet("TEST")
|
||||||
|
self.assertEqual(read_back, test_data)
|
||||||
|
|
||||||
|
def test_list_project_sheets(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
_settings._SETTINGS_DIR = Path(tmp)
|
||||||
|
_settings._SETTINGS_FILE = Path(tmp) / "test-settings.json"
|
||||||
|
_settings.put("projects_dir", tmp)
|
||||||
|
|
||||||
|
# Create two project dirs
|
||||||
|
for code in ("AAA", "BBB"):
|
||||||
|
d = Path(tmp) / "sheets" / code
|
||||||
|
d.mkdir(parents=True)
|
||||||
|
(d / f"{code}.ods").write_bytes(b"fake")
|
||||||
|
|
||||||
|
sheets = project_files.list_project_sheets()
|
||||||
|
codes = [s[0] for s in sheets]
|
||||||
|
self.assertIn("AAA", codes)
|
||||||
|
self.assertIn("BBB", codes)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAIClient(unittest.TestCase):
|
||||||
|
"""Test ai_client helpers that don't require network or UNO."""
|
||||||
|
|
||||||
|
def test_default_constants(self):
|
||||||
|
from silo_calc import ai_client
|
||||||
|
|
||||||
|
self.assertTrue(ai_client.OPENROUTER_API_URL.startswith("https://"))
|
||||||
|
self.assertTrue(len(ai_client.DEFAULT_MODEL) > 0)
|
||||||
|
self.assertTrue(len(ai_client.DEFAULT_INSTRUCTIONS) > 0)
|
||||||
|
|
||||||
|
def test_is_configured_false_by_default(self):
|
||||||
|
from silo_calc import ai_client
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
_settings._SETTINGS_DIR = Path(tmp)
|
||||||
|
_settings._SETTINGS_FILE = Path(tmp) / "test-settings.json"
|
||||||
|
old = os.environ.pop("OPENROUTER_API_KEY", None)
|
||||||
|
try:
|
||||||
|
self.assertFalse(ai_client.is_configured())
|
||||||
|
finally:
|
||||||
|
if old is not None:
|
||||||
|
os.environ["OPENROUTER_API_KEY"] = old
|
||||||
|
|
||||||
|
def test_is_configured_with_env_var(self):
|
||||||
|
from silo_calc import ai_client
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
_settings._SETTINGS_DIR = Path(tmp)
|
||||||
|
_settings._SETTINGS_FILE = Path(tmp) / "test-settings.json"
|
||||||
|
old = os.environ.get("OPENROUTER_API_KEY")
|
||||||
|
os.environ["OPENROUTER_API_KEY"] = "sk-test-key"
|
||||||
|
try:
|
||||||
|
self.assertTrue(ai_client.is_configured())
|
||||||
|
finally:
|
||||||
|
if old is not None:
|
||||||
|
os.environ["OPENROUTER_API_KEY"] = old
|
||||||
|
else:
|
||||||
|
os.environ.pop("OPENROUTER_API_KEY", None)
|
||||||
|
|
||||||
|
def test_is_configured_with_settings(self):
|
||||||
|
from silo_calc import ai_client
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
_settings._SETTINGS_DIR = Path(tmp)
|
||||||
|
_settings._SETTINGS_FILE = Path(tmp) / "test-settings.json"
|
||||||
|
_settings.put("openrouter_api_key", "sk-test-key")
|
||||||
|
old = os.environ.pop("OPENROUTER_API_KEY", None)
|
||||||
|
try:
|
||||||
|
self.assertTrue(ai_client.is_configured())
|
||||||
|
finally:
|
||||||
|
if old is not None:
|
||||||
|
os.environ["OPENROUTER_API_KEY"] = old
|
||||||
|
|
||||||
|
def test_chat_completion_missing_key_raises(self):
|
||||||
|
from silo_calc import ai_client
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
_settings._SETTINGS_DIR = Path(tmp)
|
||||||
|
_settings._SETTINGS_FILE = Path(tmp) / "test-settings.json"
|
||||||
|
old = os.environ.pop("OPENROUTER_API_KEY", None)
|
||||||
|
try:
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
ai_client.chat_completion([{"role": "user", "content": "test"}])
|
||||||
|
self.assertIn("not configured", str(ctx.exception))
|
||||||
|
finally:
|
||||||
|
if old is not None:
|
||||||
|
os.environ["OPENROUTER_API_KEY"] = old
|
||||||
|
|
||||||
|
def test_get_model_default(self):
|
||||||
|
from silo_calc import ai_client
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
_settings._SETTINGS_DIR = Path(tmp)
|
||||||
|
_settings._SETTINGS_FILE = Path(tmp) / "test-settings.json"
|
||||||
|
self.assertEqual(ai_client._get_model(), ai_client.DEFAULT_MODEL)
|
||||||
|
|
||||||
|
def test_get_model_from_settings(self):
|
||||||
|
from silo_calc import ai_client
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
_settings._SETTINGS_DIR = Path(tmp)
|
||||||
|
_settings._SETTINGS_FILE = Path(tmp) / "test-settings.json"
|
||||||
|
_settings.put("openrouter_model", "anthropic/claude-3-haiku")
|
||||||
|
self.assertEqual(ai_client._get_model(), "anthropic/claude-3-haiku")
|
||||||
|
|
||||||
|
def test_get_instructions_default(self):
|
||||||
|
from silo_calc import ai_client
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
_settings._SETTINGS_DIR = Path(tmp)
|
||||||
|
_settings._SETTINGS_FILE = Path(tmp) / "test-settings.json"
|
||||||
|
self.assertEqual(
|
||||||
|
ai_client._get_instructions(), ai_client.DEFAULT_INSTRUCTIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_instructions_from_settings(self):
|
||||||
|
from silo_calc import ai_client
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
_settings._SETTINGS_DIR = Path(tmp)
|
||||||
|
_settings._SETTINGS_FILE = Path(tmp) / "test-settings.json"
|
||||||
|
_settings.put("openrouter_instructions", "Custom instructions")
|
||||||
|
self.assertEqual(ai_client._get_instructions(), "Custom instructions")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user