Files
Zoe Forbes 13b56fd1b0 initial: LibreOffice Calc Silo extension (extracted from silo monorepo)
LibreOffice Calc extension for Silo PLM integration. Uses shared
silo-client package (submodule) for API communication.

Changes from monorepo version:
- SiloClient class removed from client.py, replaced with CalcSiloSettings
  adapter + factory function wrapping silo_client.SiloClient
- silo_calc_component.py adds silo-client to sys.path
- Makefile build-oxt copies silo_client into .oxt for self-contained packaging
- All other modules unchanged
2026-02-06 11:24:13 -06:00

668 lines
21 KiB
Python

"""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