Compare commits

...

17 Commits

Author SHA1 Message Date
forbes-0023
dfa1da97dd chore: configure silo-client submodule to track main branch
Enables 'git submodule update --remote' to auto-advance to latest main.
2026-02-19 14:55:11 -06:00
43e905c00a Merge pull request 'feat(silo): BOM auto-extraction from Assembly links (#276)' (#49) from feat/bom-sync-and-manifest into main
Reviewed-on: #49
2026-02-19 20:27:14 +00:00
forbes-0023
f67d9a0422 feat(silo): BOM auto-extraction from Assembly links (#276)
Phase 1 implementation:
- New bom_sync.py: extract cross-document App::Link components from
  Assembly, resolve SiloItemId UUIDs to part numbers, diff against
  server BOM, apply adds/qty updates via individual CRUD calls.
- Hook _push_bom_after_upload into Silo_Save and Silo_Commit
  (same non-blocking pattern as DAG sync).
- Hook _update_manifest_revision to write revision_hash into .kc
  manifest after successful upload (#277).
- Add bom_merged SSE signal + dispatch + Activity pane handler.
- Add merge_bom_json to SiloClient (forward-looking for Phase 2).

Merge rules: auto-add, auto-update qty, NEVER auto-delete removed
entries (warn only).

Refs: #276, #277
2026-02-19 12:37:14 -06:00
af98994a53 Merge pull request 'chore: update silo-client pointer to main merge commit' (#48) from fix/silo-client-pointer into main
Reviewed-on: #48
2026-02-19 01:58:00 +00:00
Zoe Forbes
d266bfb653 chore: update silo-client pointer to main merge commit
Points to silo-client main (285bd1f) which includes the merged
kc-metadata-api methods from PR #19.
2026-02-18 19:56:58 -06:00
a92174e0b9 Merge pull request 'chore: bump silo-client to feat/kc-metadata-api' (#47) from feat/kc-metadata-api into main
Reviewed-on: #47
2026-02-19 01:43:01 +00:00
Zoe Forbes
edbaf65923 chore: bump silo-client to feat/kc-metadata-api
Tracks new .kc metadata and dependency resolution API methods
needed by Create module server integration (silo-mod#43).
2026-02-18 19:35:41 -06:00
80f8ec27a0 Merge pull request 'chore(deps): update silo-client to c5c8288e' (#36) from auto/update-silo-client-c5c8288e into main
Reviewed-on: #36
2026-02-18 22:34:25 +00:00
kindred-bot
6b3e8b7518 chore(deps): update silo-client to c5c8288e
Upstream: c5c8288eeb
2026-02-18 21:06:32 +00:00
b3fe98c696 Merge pull request 'fix: remove MinIO references and degraded mode' (#35) from fix/remove-minio-references into main
Reviewed-on: #35
2026-02-18 20:56:18 +00:00
Zoe Forbes
c537e2f08f fix: remove MinIO references and degraded mode
The silo server now uses filesystem storage instead of MinIO.

- Remove all MinIO references from docstrings, tooltips, and UI text
- Remove obsolete 'degraded' server mode (no separate storage service)
- Update Silo_Info display: 'File in MinIO' → 'File on Server'
- Update SiloOrigin class docstring
2026-02-18 14:55:57 -06:00
29b1f32fd9 Merge pull request 'refactor: migrate to kindred-addon-sdk for overlay, origin, and theme' (#34) from feat/migrate-to-sdk into main
Reviewed-on: #34
2026-02-17 17:05:34 +00:00
dca6380199 refactor: migrate to kindred-addon-sdk for overlay, origin, and theme (#250)
Replace FreeCADGui.registerEditingOverlay() with kindred_sdk.register_overlay().
Replace FreeCADGui.addOrigin()/removeOrigin() with kindred_sdk wrappers.
Replace hardcoded _MOCHA dict with kindred_sdk.get_theme_tokens().
Add sdk dependency to package.xml <kindred> element.
2026-02-17 11:03:21 -06:00
27f0cc0f34 feat: add <kindred> element to package.xml
Declares min_create_version=0.1.0, load_priority=60, pure_python=true,
and documents universal overlay context.
2026-02-17 11:03:20 -06:00
a5eff534b5 Merge pull request 'chore(deps): update silo-client to 5e6f2cb9' (#33) from auto/update-silo-client-5e6f2cb9 into main
Reviewed-on: #33
2026-02-17 14:48:26 +00:00
kindred-bot
1001424b16 chore(deps): update silo-client to 5e6f2cb9
Upstream: 5e6f2cb963
2026-02-17 14:48:03 +00:00
7e3127498a Merge pull request 'feat(schema): make schema name configurable (closes #28)' (#32) from feat/configurable-schema-name into main
Reviewed-on: #32
2026-02-16 19:20:02 +00:00
11 changed files with 621 additions and 117 deletions

1
.gitmodules vendored
View File

@@ -1,3 +1,4 @@
[submodule "silo-client"]
path = silo-client
url = https://git.kindred-systems.com/kindred/silo-client.git
branch = main

View File

@@ -106,7 +106,9 @@ def _register_silo_overlay():
return False
try:
FreeCADGui.registerEditingOverlay(
from kindred_sdk import register_overlay
register_overlay(
"silo", # overlay id
["Silo Origin"], # toolbar names to append
_silo_overlay_match, # match function

Binary file not shown.

Binary file not shown.

Binary file not shown.

317
freecad/bom_sync.py Normal file
View File

@@ -0,0 +1,317 @@
"""BOM extraction engine for FreeCAD Assembly documents.
Extracts cross-document ``App::Link`` components from an Assembly,
resolves Silo UUIDs to part numbers, diffs against the server BOM,
and applies adds/quantity updates via individual API calls.
No GUI dependencies -- usable in both desktop and headless mode.
Public API
----------
extract_bom_entries(doc) -> List[BomEntry]
resolve_entries(entries, client) -> (resolved, unresolved)
diff_bom(local_entries, remote_entries) -> BomDiff
apply_bom_diff(diff, parent_pn, client) -> BomResult
sync_bom_after_upload(doc, part_number, client) -> Optional[BomResult]
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
import FreeCAD
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclass
class BomEntry:
"""A single BOM line extracted from an Assembly."""
silo_item_id: Optional[str] # UUID from SiloItemId property
part_number: Optional[str] # resolved via get_item_by_uuid()
label: str # FreeCAD Label of the linked object
doc_path: str # FileName of the linked document
quantity: int # summed from ElementCount + individual links
consolidation_warning: bool = False # multiple individual links to same source
@dataclass
class BomDiff:
"""Result of diffing local assembly BOM against server BOM."""
added: List[Dict[str, Any]] = field(default_factory=list)
removed: List[Dict[str, Any]] = field(default_factory=list)
quantity_changed: List[Dict[str, Any]] = field(default_factory=list)
unchanged: List[Dict[str, Any]] = field(default_factory=list)
@dataclass
class BomResult:
"""Summary of a BOM sync operation."""
added_count: int = 0
updated_count: int = 0
unreferenced_count: int = 0
unresolved_count: int = 0
errors: List[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Assembly detection helpers
# ---------------------------------------------------------------------------
def _find_assembly(doc):
"""Return the first ``Assembly::AssemblyObject`` in *doc*, or ``None``."""
for obj in doc.Objects:
if obj.isDerivedFrom("Assembly::AssemblyObject"):
return obj
return None
def _get_silo_item_id(doc):
"""Read ``SiloItemId`` from the tracked object in *doc*.
Returns ``None`` if the document has no tracked object or no UUID.
"""
for obj in doc.Objects:
if hasattr(obj, "SiloItemId") and obj.SiloItemId:
return obj.SiloItemId
return None
def _link_count(link_obj) -> int:
"""Return the instance count for an ``App::Link``.
``ElementCount > 0`` means link array; otherwise single link (qty 1).
"""
element_count = getattr(link_obj, "ElementCount", 0)
return element_count if element_count > 0 else 1
# ---------------------------------------------------------------------------
# Extraction
# ---------------------------------------------------------------------------
def extract_bom_entries(doc) -> List[BomEntry]:
"""Walk the Assembly in *doc* and collect cross-document link entries.
Returns an empty list if the document has no Assembly or no
cross-document links. Only first-level children are extracted;
sub-assemblies commit their own BOMs separately.
"""
assembly = _find_assembly(doc)
if assembly is None:
return []
# Key by linked document path to merge duplicates.
entries: Dict[str, BomEntry] = {}
for obj in assembly.Group:
# Accept App::Link (single or array) and App::LinkElement.
if not (
obj.isDerivedFrom("App::Link") or obj.isDerivedFrom("App::LinkElement")
):
continue
linked = obj.getLinkedObject()
if linked is None:
continue
# Skip in-document links (construction / layout geometry).
if linked.Document == doc:
continue
linked_doc = linked.Document
doc_path = linked_doc.FileName or linked_doc.Name
qty = _link_count(obj)
if doc_path in entries:
entries[doc_path].quantity += qty
entries[doc_path].consolidation_warning = True
else:
entries[doc_path] = BomEntry(
silo_item_id=_get_silo_item_id(linked_doc),
part_number=None,
label=linked.Label,
doc_path=doc_path,
quantity=qty,
)
return list(entries.values())
# ---------------------------------------------------------------------------
# Resolution
# ---------------------------------------------------------------------------
def resolve_entries(
entries: List[BomEntry], client
) -> Tuple[List[BomEntry], List[BomEntry]]:
"""Resolve ``SiloItemId`` UUIDs to part numbers via the API.
Returns ``(resolved, unresolved)``. Unresolved entries have no
``SiloItemId`` or the UUID lookup failed.
"""
resolved: List[BomEntry] = []
unresolved: List[BomEntry] = []
for entry in entries:
if not entry.silo_item_id:
unresolved.append(entry)
continue
try:
item = client.get_item_by_uuid(entry.silo_item_id)
entry.part_number = item["part_number"]
resolved.append(entry)
except Exception:
unresolved.append(entry)
return resolved, unresolved
# ---------------------------------------------------------------------------
# Diff
# ---------------------------------------------------------------------------
def diff_bom(
local_entries: List[BomEntry],
remote_entries: List[Dict[str, Any]],
) -> BomDiff:
"""Diff local assembly BOM against server BOM.
*local_entries*: resolved ``BomEntry`` list.
*remote_entries*: raw dicts from ``client.get_bom()`` with keys
``child_part_number`` and ``quantity``.
"""
local_map = {e.part_number: e.quantity for e in local_entries}
remote_map = {e["child_part_number"]: e.get("quantity", 1) for e in remote_entries}
diff = BomDiff()
for pn, qty in local_map.items():
if pn not in remote_map:
diff.added.append({"part_number": pn, "quantity": qty})
elif remote_map[pn] != qty:
diff.quantity_changed.append(
{
"part_number": pn,
"local_quantity": qty,
"remote_quantity": remote_map[pn],
}
)
else:
diff.unchanged.append({"part_number": pn, "quantity": qty})
for pn, qty in remote_map.items():
if pn not in local_map:
diff.removed.append({"part_number": pn, "quantity": qty})
return diff
# ---------------------------------------------------------------------------
# Apply
# ---------------------------------------------------------------------------
def apply_bom_diff(diff: BomDiff, parent_pn: str, client) -> BomResult:
"""Apply adds and quantity updates to the server BOM.
Uses individual CRUD calls (Phase 1 fallback). Phase 2 replaces
this with a single ``POST /items/{pn}/bom/merge`` call.
Removed entries are NEVER deleted -- only warned about.
Each call is individually wrapped so one failure does not block others.
"""
result = BomResult()
for entry in diff.added:
try:
client.add_bom_entry(
parent_pn,
entry["part_number"],
quantity=entry["quantity"],
rel_type="component",
)
result.added_count += 1
except Exception as e:
result.errors.append(f"add {entry['part_number']}: {e}")
for entry in diff.quantity_changed:
try:
client.update_bom_entry(
parent_pn,
entry["part_number"],
quantity=entry["local_quantity"],
)
result.updated_count += 1
except Exception as e:
result.errors.append(f"update {entry['part_number']}: {e}")
result.unreferenced_count = len(diff.removed)
if diff.removed:
pns = ", ".join(e["part_number"] for e in diff.removed)
FreeCAD.Console.PrintWarning(
f"BOM sync: {result.unreferenced_count} server entries "
f"not in assembly (not deleted): {pns}\n"
)
return result
# ---------------------------------------------------------------------------
# Top-level orchestrator
# ---------------------------------------------------------------------------
def sync_bom_after_upload(doc, part_number: str, client) -> Optional[BomResult]:
"""Full BOM sync pipeline: extract, resolve, diff, apply.
Returns ``None`` if *doc* is not an assembly or has no cross-document
links. Returns a ``BomResult`` with summary counts otherwise.
"""
entries = extract_bom_entries(doc)
if not entries:
return None
resolved, unresolved = resolve_entries(entries, client)
# Log consolidation warnings.
for entry in entries:
if entry.consolidation_warning:
FreeCAD.Console.PrintWarning(
f"BOM sync: {entry.label} ({entry.doc_path}) has multiple "
f"individual links. Consider using a link array "
f"(ElementCount) for cleaner assembly management.\n"
)
# Log unresolved components.
for entry in unresolved:
FreeCAD.Console.PrintWarning(
f"BOM sync: {entry.label} ({entry.doc_path}) has no Silo "
f"part number -- excluded from BOM.\n"
)
if not resolved:
result = BomResult()
result.unresolved_count = len(unresolved)
return result
# Fetch current server BOM.
try:
remote = client.get_bom(part_number)
except Exception:
remote = []
diff = diff_bom(resolved, remote)
result = apply_bom_diff(diff, part_number, client)
result.unresolved_count = len(unresolved)
return result

View File

@@ -12,4 +12,17 @@
<subdirectory>./</subdirectory>
</workbench>
</content>
<!-- Kindred Create extensions -->
<kindred>
<min_create_version>0.1.0</min_create_version>
<load_priority>60</load_priority>
<pure_python>true</pure_python>
<dependencies>
<dependency>sdk</dependency>
</dependencies>
<contexts>
<context id="*" action="overlay"/>
</contexts>
</kindred>
</package>

View File

@@ -20,7 +20,9 @@ from silo_client import SiloClient, SiloSettings
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"
# Configuration - preferences take priority over env vars
SILO_PROJECTS_DIR = os.environ.get("SILO_PROJECTS_DIR", os.path.expanduser("~/projects"))
SILO_PROJECTS_DIR = os.environ.get(
"SILO_PROJECTS_DIR", os.path.expanduser("~/projects")
)
# ---------------------------------------------------------------------------
@@ -98,7 +100,9 @@ class FreeCADSiloSettings(SiloSettings):
param = FreeCAD.ParamGet(_PREF_GROUP)
return param.GetString("SslCertPath", "")
def save_auth(self, username: str, role: str = "", source: str = "", token: str = ""):
def save_auth(
self, username: str, role: str = "", source: str = "", token: str = ""
):
param = FreeCAD.ParamGet(_PREF_GROUP)
param.SetString("AuthUsername", username)
param.SetString("AuthRole", role)
@@ -167,7 +171,9 @@ def _get_ssl_verify() -> bool:
def _get_ssl_context():
from silo_client._ssl import build_ssl_context
return build_ssl_context(_fc_settings.get_ssl_verify(), _fc_settings.get_ssl_cert_path())
return build_ssl_context(
_fc_settings.get_ssl_verify(), _fc_settings.get_ssl_cert_path()
)
def _get_auth_headers() -> Dict[str, str]:
@@ -191,13 +197,13 @@ def _clear_auth():
# Server mode tracking
# ---------------------------------------------------------------------------
_server_mode = "offline" # "normal" | "read-only" | "degraded" | "offline"
_server_mode = "offline" # "normal" | "read-only" | "offline"
def _fetch_server_mode() -> str:
"""Fetch server mode from the /ready endpoint.
Returns one of: "normal", "read-only", "degraded", "offline".
Returns one of: "normal", "read-only", "offline".
"""
api_url = _get_api_url().rstrip("/")
base_url = api_url[:-4] if api_url.endswith("/api") else api_url
@@ -212,8 +218,6 @@ def _fetch_server_mode() -> str:
return "normal"
if status in ("read-only", "read_only", "readonly"):
return "read-only"
if status in ("degraded",):
return "degraded"
# Unknown status but server responded — treat as normal
return "normal"
except Exception:
@@ -224,7 +228,9 @@ def _fetch_server_mode() -> str:
# Icon helper
# ---------------------------------------------------------------------------
_ICON_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources", "icons")
_ICON_DIR = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "resources", "icons"
)
def _icon(name):
@@ -562,7 +568,7 @@ class SiloSync:
def upload_file(
self, part_number: str, file_path: str, comment: str = "Auto-save"
) -> Optional[Dict]:
"""Upload file to MinIO."""
"""Upload file to the server."""
try:
doc = FreeCAD.openDocument(file_path)
if not doc:
@@ -576,7 +582,7 @@ class SiloSync:
return None
def download_file(self, part_number: str) -> Optional[Path]:
"""Download latest file from MinIO."""
"""Download the latest revision file from the server."""
try:
item = self.client.get_item(part_number)
file_path = get_cad_file_path(part_number, item.get("description", ""))
@@ -623,7 +629,9 @@ def handle_kindred_url(url: str):
parts = [parsed.netloc] + [p for p in parsed.path.split("/") if p]
if len(parts) >= 2 and parts[0] == "item":
part_number = parts[1]
FreeCAD.Console.PrintMessage(f"Silo: Opening item {part_number} from kindred:// URL\n")
FreeCAD.Console.PrintMessage(
f"Silo: Opening item {part_number} from kindred:// URL\n"
)
_sync.open_item(part_number)
@@ -730,7 +738,9 @@ class Silo_New:
},
)
obj.Label = part_number
_sync.save_to_canonical_path(FreeCAD.ActiveDocument, force_rename=True)
_sync.save_to_canonical_path(
FreeCAD.ActiveDocument, force_rename=True
)
else:
_sync.create_document_for_item(result, save=True)
@@ -763,18 +773,74 @@ def _push_dag_after_upload(doc, part_number, revision_number):
result = _client.push_dag(part_number, revision_number, nodes, edges)
node_count = result.get("node_count", len(nodes))
edge_count = result.get("edge_count", len(edges))
FreeCAD.Console.PrintMessage(f"DAG synced: {node_count} nodes, {edge_count} edges\n")
FreeCAD.Console.PrintMessage(
f"DAG synced: {node_count} nodes, {edge_count} edges\n"
)
except Exception as e:
FreeCAD.Console.PrintWarning(f"DAG sync failed: {e}\n")
def _update_manifest_revision(file_path, revision_number):
"""Write revision_hash into the .kc manifest after a successful upload.
Failures are logged as warnings -- must never block save.
"""
try:
from kc_format import update_manifest_fields
update_manifest_fields(
file_path,
{
"revision_hash": str(revision_number),
},
)
except Exception as e:
FreeCAD.Console.PrintWarning(f"Manifest revision update failed: {e}\n")
def _push_bom_after_upload(doc, part_number, revision_number):
"""Extract and sync Assembly BOM after a successful upload.
Only runs for Assembly documents with cross-document links.
Failures are logged as warnings -- BOM sync must never block save.
"""
try:
from bom_sync import sync_bom_after_upload
result = sync_bom_after_upload(doc, part_number, _client)
if result is None:
return # Not an assembly or no cross-doc links
parts = []
if result.added_count:
parts.append(f"+{result.added_count} added")
if result.updated_count:
parts.append(f"~{result.updated_count} qty updated")
if result.unreferenced_count:
parts.append(f"!{result.unreferenced_count} unreferenced")
if result.unresolved_count:
FreeCAD.Console.PrintWarning(
f"BOM sync: {result.unresolved_count} components "
f"have no Silo part number\n"
)
if parts:
FreeCAD.Console.PrintMessage(f"BOM synced: {', '.join(parts)}\n")
else:
FreeCAD.Console.PrintMessage("BOM synced: no changes\n")
for err in result.errors:
FreeCAD.Console.PrintWarning(f"BOM sync error: {err}\n")
except Exception as e:
FreeCAD.Console.PrintWarning(f"BOM sync failed: {e}\n")
class Silo_Save:
"""Save locally and upload to MinIO."""
"""Save locally and upload to the server."""
def GetResources(self):
return {
"MenuText": "Save",
"ToolTip": "Save locally and upload to MinIO (Ctrl+S)",
"ToolTip": "Save locally and upload to server (Ctrl+S)",
"Pixmap": _icon("save"),
}
@@ -829,14 +895,18 @@ class Silo_Save:
FreeCAD.Console.PrintMessage(f"Saved: {file_path}\n")
# Try to upload to MinIO
# Try to upload to server
try:
result = _client._upload_file(part_number, str(file_path), properties, "Auto-save")
result = _client._upload_file(
part_number, str(file_path), properties, "Auto-save"
)
new_rev = result["revision_number"]
FreeCAD.Console.PrintMessage(f"Uploaded as revision {new_rev}\n")
_push_dag_after_upload(doc, part_number, new_rev)
_push_bom_after_upload(doc, part_number, new_rev)
_update_manifest_revision(str(file_path), new_rev)
except Exception as e:
FreeCAD.Console.PrintWarning(f"Upload failed: {e}\n")
@@ -866,7 +936,9 @@ class Silo_Commit:
obj = get_tracked_object(doc)
if not obj:
FreeCAD.Console.PrintError("No tracked object. Use 'New' to register first.\n")
FreeCAD.Console.PrintError(
"No tracked object. Use 'New' to register first.\n"
)
return
part_number = obj.SiloPartNumber
@@ -883,12 +955,16 @@ class Silo_Commit:
if not file_path:
return
result = _client._upload_file(part_number, str(file_path), properties, comment)
result = _client._upload_file(
part_number, str(file_path), properties, comment
)
new_rev = result["revision_number"]
FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n")
_push_dag_after_upload(doc, part_number, new_rev)
_push_bom_after_upload(doc, part_number, new_rev)
_update_manifest_revision(str(file_path), new_rev)
except Exception as e:
FreeCAD.Console.PrintError(f"Commit failed: {e}\n")
@@ -934,7 +1010,9 @@ def _check_pull_conflicts(part_number, local_path, doc=None):
server_updated = item.get("updated_at", "")
if server_updated:
# Parse ISO format timestamp
server_dt = datetime.datetime.fromisoformat(server_updated.replace("Z", "+00:00"))
server_dt = datetime.datetime.fromisoformat(
server_updated.replace("Z", "+00:00")
)
if server_dt > local_mtime:
conflicts.append("Server version is newer than local file.")
except Exception:
@@ -964,7 +1042,9 @@ class SiloPullDialog:
# Revision table
self._table = QtGui.QTableWidget()
self._table.setColumnCount(5)
self._table.setHorizontalHeaderLabels(["Rev", "Date", "Comment", "Status", "File"])
self._table.setHorizontalHeaderLabels(
["Rev", "Date", "Comment", "Status", "File"]
)
self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
@@ -1057,7 +1137,9 @@ def _pull_dependencies(part_number, progress_callback=None):
# Skip if already exists locally
existing = find_file_by_part_number(child_pn)
if existing and existing.exists():
FreeCAD.Console.PrintMessage(f" {child_pn}: already exists at {existing}\n")
FreeCAD.Console.PrintMessage(
f" {child_pn}: already exists at {existing}\n"
)
# Still recurse — this child may itself be an assembly with missing deps
_pull_dependencies(child_pn, progress_callback)
continue
@@ -1096,12 +1178,12 @@ def _pull_dependencies(part_number, progress_callback=None):
class Silo_Pull:
"""Download from MinIO / sync from database."""
"""Download revision file from the server."""
def GetResources(self):
return {
"MenuText": "Pull",
"ToolTip": "Download from MinIO with revision selection",
"ToolTip": "Download file with revision selection",
"Pixmap": _icon("pull"),
}
@@ -1137,14 +1219,18 @@ class Silo_Pull:
if not has_any_file:
if existing_local:
FreeCAD.Console.PrintMessage(f"Opening existing local file: {existing_local}\n")
FreeCAD.Console.PrintMessage(
f"Opening existing local file: {existing_local}\n"
)
FreeCAD.openDocument(str(existing_local))
else:
try:
item = _client.get_item(part_number)
new_doc = _sync.create_document_for_item(item, save=True)
if new_doc:
FreeCAD.Console.PrintMessage(f"Created local file for {part_number}\n")
FreeCAD.Console.PrintMessage(
f"Created local file for {part_number}\n"
)
else:
QtGui.QMessageBox.warning(
None,
@@ -1231,7 +1317,9 @@ class Silo_Pull:
progress.setValue(100)
progress.close()
if dep_pulled:
FreeCAD.Console.PrintMessage(f"Pulled {len(dep_pulled)} dependency file(s)\n")
FreeCAD.Console.PrintMessage(
f"Pulled {len(dep_pulled)} dependency file(s)\n"
)
# Close existing document if open, then reopen
if doc and doc.FileName == str(dest_path):
@@ -1251,12 +1339,12 @@ class Silo_Pull:
class Silo_Push:
"""Upload local files to MinIO."""
"""Upload local files to the server."""
def GetResources(self):
return {
"MenuText": "Push",
"ToolTip": "Upload local files that aren't in MinIO",
"ToolTip": "Upload local files that aren't on the server",
"Pixmap": _icon("push"),
}
@@ -1286,7 +1374,9 @@ class Silo_Push:
server_dt = datetime.fromisoformat(
server_time_str.replace("Z", "+00:00")
)
local_dt = datetime.fromtimestamp(local_mtime, tz=timezone.utc)
local_dt = datetime.fromtimestamp(
local_mtime, tz=timezone.utc
)
if local_dt > server_dt:
unuploaded.append(lf)
else:
@@ -1299,7 +1389,9 @@ class Silo_Push:
pass # Not in DB, skip
if not unuploaded:
QtGui.QMessageBox.information(None, "Push", "All local files are already uploaded.")
QtGui.QMessageBox.information(
None, "Push", "All local files are already uploaded."
)
return
msg = f"Found {len(unuploaded)} files to upload:\n\n"
@@ -1317,7 +1409,9 @@ class Silo_Push:
uploaded = 0
for item in unuploaded:
result = _sync.upload_file(item["part_number"], item["path"], "Synced from local")
result = _sync.upload_file(
item["part_number"], item["path"], "Synced from local"
)
if result:
uploaded += 1
@@ -1366,14 +1460,12 @@ class Silo_Info:
msg = f"<h3>{part_number}</h3>"
msg += f"<p><b>Type:</b> {item.get('item_type', '-')}</p>"
msg += f"<p><b>Description:</b> {item.get('description', '-')}</p>"
msg += (
f"<p><b>Projects:</b> {', '.join(project_codes) if project_codes else 'None'}</p>"
)
msg += f"<p><b>Projects:</b> {', '.join(project_codes) if project_codes else 'None'}</p>"
msg += f"<p><b>Current Revision:</b> {item.get('current_revision', 1)}</p>"
msg += f"<p><b>Local Revision:</b> {getattr(obj, 'SiloRevision', '-')}</p>"
has_file, _ = _client.has_file(part_number)
msg += f"<p><b>File in MinIO:</b> {'Yes' if has_file else 'No'}</p>"
msg += f"<p><b>File on Server:</b> {'Yes' if has_file else 'No'}</p>"
# Show current revision status
if revisions:
@@ -1434,7 +1526,9 @@ class Silo_TagProjects:
try:
# Get current projects for item
current_projects = _client.get_item_projects(part_number)
current_codes = {p.get("code", "") for p in current_projects if p.get("code")}
current_codes = {
p.get("code", "") for p in current_projects if p.get("code")
}
# Get all available projects
all_projects = _client.get_projects()
@@ -1545,7 +1639,9 @@ class Silo_Rollback:
dialog.setMinimumHeight(300)
layout = QtGui.QVBoxLayout(dialog)
label = QtGui.QLabel(f"Select a revision to rollback to (current: Rev {current_rev}):")
label = QtGui.QLabel(
f"Select a revision to rollback to (current: Rev {current_rev}):"
)
layout.addWidget(label)
# Revision table
@@ -1560,8 +1656,12 @@ class Silo_Rollback:
for i, rev in enumerate(prev_revisions):
table.setItem(i, 0, QtGui.QTableWidgetItem(str(rev["revision_number"])))
table.setItem(i, 1, QtGui.QTableWidgetItem(rev.get("status", "draft")))
table.setItem(i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10]))
table.setItem(i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or ""))
table.setItem(
i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10])
)
table.setItem(
i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or "")
)
table.resizeColumnsToContents()
layout.addWidget(table)
@@ -1587,7 +1687,9 @@ class Silo_Rollback:
def on_rollback():
selected = table.selectedItems()
if not selected:
QtGui.QMessageBox.warning(dialog, "Rollback", "Please select a revision")
QtGui.QMessageBox.warning(
dialog, "Rollback", "Please select a revision"
)
return
selected_rev[0] = int(table.item(selected[0].row(), 0).text())
dialog.accept()
@@ -1685,7 +1787,9 @@ class Silo_SetStatus:
# Update status
_client.update_revision(part_number, rev_num, status=status)
FreeCAD.Console.PrintMessage(f"Updated Rev {rev_num} status to '{status}'\n")
FreeCAD.Console.PrintMessage(
f"Updated Rev {rev_num} status to '{status}'\n"
)
QtGui.QMessageBox.information(
None, "Status Updated", f"Revision {rev_num} status set to '{status}'"
)
@@ -1774,7 +1878,9 @@ class Silo_Settings:
ssl_checkbox.setChecked(param.GetBool("SslVerify", True))
layout.addWidget(ssl_checkbox)
ssl_hint = QtGui.QLabel("Disable only for internal servers with self-signed certificates.")
ssl_hint = QtGui.QLabel(
"Disable only for internal servers with self-signed certificates."
)
ssl_hint.setWordWrap(True)
ssl_hint.setStyleSheet("color: #888; font-size: 11px;")
layout.addWidget(ssl_hint)
@@ -2053,7 +2159,9 @@ class Silo_BOM:
wu_table = QtGui.QTableWidget()
wu_table.setColumnCount(5)
wu_table.setHorizontalHeaderLabels(["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"])
wu_table.setHorizontalHeaderLabels(
["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"]
)
wu_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
wu_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
wu_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
@@ -2082,12 +2190,16 @@ class Silo_BOM:
bom_table.setItem(
row, 1, QtGui.QTableWidgetItem(entry.get("child_description", ""))
)
bom_table.setItem(row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", "")))
bom_table.setItem(
row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", ""))
)
qty = entry.get("quantity")
bom_table.setItem(
row, 3, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
)
bom_table.setItem(row, 4, QtGui.QTableWidgetItem(entry.get("unit") or ""))
bom_table.setItem(
row, 4, QtGui.QTableWidgetItem(entry.get("unit") or "")
)
ref_des = entry.get("reference_designators") or []
bom_table.setItem(row, 5, QtGui.QTableWidgetItem(", ".join(ref_des)))
bom_table.setItem(
@@ -2109,12 +2221,16 @@ class Silo_BOM:
wu_table.setItem(
row, 0, QtGui.QTableWidgetItem(entry.get("parent_part_number", ""))
)
wu_table.setItem(row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", "")))
wu_table.setItem(
row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", ""))
)
qty = entry.get("quantity")
wu_table.setItem(
row, 2, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
)
wu_table.setItem(row, 3, QtGui.QTableWidgetItem(entry.get("unit") or ""))
wu_table.setItem(
row, 3, QtGui.QTableWidgetItem(entry.get("unit") or "")
)
ref_des = entry.get("reference_designators") or []
wu_table.setItem(row, 4, QtGui.QTableWidgetItem(", ".join(ref_des)))
wu_table.resizeColumnsToContents()
@@ -2167,7 +2283,9 @@ class Silo_BOM:
try:
qty = float(qty_text)
except ValueError:
QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.")
QtGui.QMessageBox.warning(
dialog, "BOM", "Quantity must be a number."
)
return
unit = unit_input.text().strip() or None
@@ -2246,7 +2364,9 @@ class Silo_BOM:
try:
new_qty = float(qty_text)
except ValueError:
QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.")
QtGui.QMessageBox.warning(
dialog, "BOM", "Quantity must be a number."
)
return
new_unit = unit_input.text().strip() or None
@@ -2270,7 +2390,9 @@ class Silo_BOM:
)
load_bom()
except Exception as exc:
QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to update entry:\n{exc}")
QtGui.QMessageBox.warning(
dialog, "BOM", f"Failed to update entry:\n{exc}"
)
def on_remove():
selected = bom_table.selectedItems()
@@ -2296,7 +2418,9 @@ class Silo_BOM:
_client.delete_bom_entry(part_number, child_pn)
load_bom()
except Exception as exc:
QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to remove entry:\n{exc}")
QtGui.QMessageBox.warning(
dialog, "BOM", f"Failed to remove entry:\n{exc}"
)
add_btn.clicked.connect(on_add)
edit_btn.clicked.connect(on_edit)
@@ -2335,13 +2459,18 @@ class SiloEventListener(QtCore.QThread):
item_updated = QtCore.Signal(str) # part_number
revision_created = QtCore.Signal(str, int) # part_number, revision
connection_status = QtCore.Signal(str, int, str) # (status, retry_count, error_message)
server_mode_changed = QtCore.Signal(str) # "normal" / "read-only" / "degraded"
connection_status = QtCore.Signal(
str, int, str
) # (status, retry_count, error_message)
server_mode_changed = QtCore.Signal(str) # "normal" / "read-only" / "offline"
# DAG events
dag_updated = QtCore.Signal(str, int, int) # part_number, node_count, edge_count
dag_validated = QtCore.Signal(str, bool, int) # part_number, valid, failed_count
# BOM events
bom_merged = QtCore.Signal(str, int, int, int) # pn, added, updated, unreferenced
# Job lifecycle events
job_created = QtCore.Signal(str, str, str) # job_id, definition_name, part_number
job_claimed = QtCore.Signal(str, str) # job_id, runner_id
@@ -2421,7 +2550,9 @@ class SiloEventListener(QtCore.QThread):
req = urllib.request.Request(url, headers=headers, method="GET")
try:
self._response = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=90)
self._response = urllib.request.urlopen(
req, context=_get_ssl_context(), timeout=90
)
except urllib.error.HTTPError as e:
if e.code in (404, 501):
raise _SSEUnsupported()
@@ -2514,6 +2645,13 @@ class SiloEventListener(QtCore.QThread):
bool(payload.get("valid", False)),
int(payload.get("failed_count", 0)),
)
elif event_type == "bom.merged":
self.bom_merged.emit(
pn,
int(payload.get("added", 0)),
int(payload.get("updated", 0)),
int(payload.get("unreferenced", 0)),
)
class _SSEUnsupported(Exception):
@@ -2647,7 +2785,9 @@ class SiloAuthDockWidget:
layout.addLayout(btn_row)
# Keep the auth panel compact so the Activity panel below gets more space
self.widget.setSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Maximum)
self.widget.setSizePolicy(
QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Maximum
)
# -- Status refresh -----------------------------------------------------
@@ -2741,6 +2881,7 @@ class SiloAuthDockWidget:
self._event_listener.server_mode_changed.connect(self._on_server_mode)
self._event_listener.dag_updated.connect(self._on_dag_updated)
self._event_listener.dag_validated.connect(self._on_dag_validated)
self._event_listener.bom_merged.connect(self._on_bom_merged)
self._event_listener.job_created.connect(self._on_job_created)
self._event_listener.job_claimed.connect(self._on_job_claimed)
self._event_listener.job_progress.connect(self._on_job_progress)
@@ -2761,7 +2902,9 @@ class SiloAuthDockWidget:
FreeCAD.Console.PrintMessage("Silo: SSE connected\n")
self._seed_activity_feed()
elif status == "disconnected":
self._sse_label.setText(f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})...")
self._sse_label.setText(
f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})..."
)
self._sse_label.setStyleSheet("font-size: 11px; color: #FF9800;")
self._sse_label.setToolTip(error or "Connection lost")
FreeCAD.Console.PrintWarning(
@@ -2771,7 +2914,9 @@ class SiloAuthDockWidget:
self._sse_label.setText("Disconnected")
self._sse_label.setStyleSheet("font-size: 11px; color: #F44336;")
self._sse_label.setToolTip(error or "Max retries reached")
FreeCAD.Console.PrintError(f"Silo: SSE gave up after {retry} retries: {error}\n")
FreeCAD.Console.PrintError(
f"Silo: SSE gave up after {retry} retries: {error}\n"
)
elif status == "unsupported":
self._sse_label.setText("Not available")
self._sse_label.setStyleSheet("font-size: 11px; color: #888;")
@@ -2791,11 +2936,6 @@ class SiloAuthDockWidget:
"background: #FFC107; color: #000; padding: 4px; font-size: 11px;",
True,
),
"degraded": (
"MinIO unavailable \u2014 file ops limited",
"background: #FF9800; color: #000; padding: 4px; font-size: 11px;",
True,
),
"offline": (
"Disconnected from silo",
"background: #F44336; color: #fff; padding: 4px; font-size: 11px;",
@@ -2815,11 +2955,17 @@ class SiloAuthDockWidget:
self._append_activity_event(f"{part_number} updated", part_number)
def _on_remote_revision(self, part_number, revision):
FreeCAD.Console.PrintMessage(f"Silo: New revision {revision} for {part_number}\n")
FreeCAD.Console.PrintMessage(
f"Silo: New revision {revision} for {part_number}\n"
)
mw = FreeCADGui.getMainWindow()
if mw is not None:
mw.statusBar().showMessage(f"Silo: {part_number} rev {revision} available", 5000)
self._append_activity_event(f"{part_number} Rev {revision} created", part_number)
mw.statusBar().showMessage(
f"Silo: {part_number} rev {revision} available", 5000
)
self._append_activity_event(
f"{part_number} Rev {revision} created", part_number
)
def _append_activity_event(self, text, pn=""):
"""Prepend an event to the activity feed and rebuild the display."""
@@ -2845,9 +2991,9 @@ class SiloAuthDockWidget:
ts = datetime.now()
if updated:
try:
ts = datetime.fromisoformat(updated.replace("Z", "+00:00")).replace(
tzinfo=None
)
ts = datetime.fromisoformat(
updated.replace("Z", "+00:00")
).replace(tzinfo=None)
except (ValueError, AttributeError):
pass
self._activity_events.insert(0, (ts, text, pn))
@@ -2913,8 +3059,27 @@ class SiloAuthDockWidget:
)
self._append_activity_event(f"{status} \u2013 {part_number}", part_number)
def _on_bom_merged(self, part_number, added, updated, unreferenced):
parts = []
if added:
parts.append(f"+{added} added")
if updated:
parts.append(f"~{updated} qty changed")
if unreferenced:
parts.append(f"!{unreferenced} unreferenced")
summary = ", ".join(parts) if parts else "no changes"
FreeCAD.Console.PrintMessage(
f"Silo: BOM merged for {part_number} ({summary})\n"
)
self._append_activity_event(
f"\u2630 {part_number} \u2013 BOM synced ({summary})",
part_number,
)
def _on_job_created(self, job_id, definition_name, part_number):
FreeCAD.Console.PrintMessage(f"Silo: Job {definition_name} created for {part_number}\n")
FreeCAD.Console.PrintMessage(
f"Silo: Job {definition_name} created for {part_number}\n"
)
self._append_activity_event(
f"\u23f3 {part_number} \u2013 {definition_name} queued",
part_number,
@@ -2929,10 +3094,14 @@ class SiloAuthDockWidget:
self._append_activity_event(f"\u2717 Job {job_id[:8]} failed: {error}")
def _on_job_claimed(self, job_id, runner_id):
FreeCAD.Console.PrintMessage(f"Silo: Job {job_id[:8]} claimed by runner {runner_id}\n")
FreeCAD.Console.PrintMessage(
f"Silo: Job {job_id[:8]} claimed by runner {runner_id}\n"
)
def _on_job_progress(self, job_id, progress, message):
FreeCAD.Console.PrintMessage(f"Silo: Job {job_id[:8]} progress {progress}%: {message}\n")
FreeCAD.Console.PrintMessage(
f"Silo: Job {job_id[:8]} progress {progress}%: {message}\n"
)
def _on_job_cancelled(self, job_id):
FreeCAD.Console.PrintMessage(f"Silo: Job {job_id[:8]} cancelled\n")
@@ -3263,9 +3432,15 @@ class JobMonitorDialog:
status = job.get("status", "")
icon = _STATUS_ICONS.get(status, "?")
self._table.setItem(row, 0, QtGui.QTableWidgetItem(f"{icon} {status}"))
self._table.setItem(row, 1, QtGui.QTableWidgetItem(job.get("definition_name", "")))
self._table.setItem(row, 2, QtGui.QTableWidgetItem(job.get("part_number", "")))
self._table.setItem(row, 3, QtGui.QTableWidgetItem(job.get("runner_name", "")))
self._table.setItem(
row, 1, QtGui.QTableWidgetItem(job.get("definition_name", ""))
)
self._table.setItem(
row, 2, QtGui.QTableWidgetItem(job.get("part_number", ""))
)
self._table.setItem(
row, 3, QtGui.QTableWidgetItem(job.get("runner_name", ""))
)
progress = job.get("progress", 0)
progress_msg = job.get("progress_message", "")
@@ -3337,7 +3512,9 @@ class JobMonitorDialog:
_client.cancel_job(job_id)
FreeCAD.Console.PrintMessage(f"Silo: Job {job_id} cancelled\n")
except Exception as e:
QtGui.QMessageBox.warning(self.dialog, "Cancel Failed", f"Failed to cancel job:\n{e}")
QtGui.QMessageBox.warning(
self.dialog, "Cancel Failed", f"Failed to cancel job:\n{e}"
)
self._refresh()
def _trigger_job(self):
@@ -3346,7 +3523,9 @@ class JobMonitorDialog:
try:
definitions = _client.list_job_definitions()
except Exception as e:
QtGui.QMessageBox.warning(self.dialog, "Error", f"Failed to load job definitions:\n{e}")
QtGui.QMessageBox.warning(
self.dialog, "Error", f"Failed to load job definitions:\n{e}"
)
return
if not definitions:
@@ -3374,9 +3553,13 @@ class JobMonitorDialog:
try:
result = _client.trigger_job(name, part_number=pn)
FreeCAD.Console.PrintMessage(f"Silo: Job triggered: {result.get('id', '')}\n")
FreeCAD.Console.PrintMessage(
f"Silo: Job triggered: {result.get('id', '')}\n"
)
except Exception as e:
QtGui.QMessageBox.warning(self.dialog, "Trigger Failed", f"Failed to trigger job:\n{e}")
QtGui.QMessageBox.warning(
self.dialog, "Trigger Failed", f"Failed to trigger job:\n{e}"
)
self._refresh()
def on_job_event(self):
@@ -3438,7 +3621,9 @@ class RunnerAdminDialog:
# Runner table
self._table = QtGui.QTableWidget()
self._table.setColumnCount(5)
self._table.setHorizontalHeaderLabels(["Name", "Tags", "Status", "Last Heartbeat", "Jobs"])
self._table.setHorizontalHeaderLabels(
["Name", "Tags", "Status", "Last Heartbeat", "Jobs"]
)
self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
@@ -3494,7 +3679,9 @@ class RunnerAdminDialog:
def _register_runner(self):
from PySide import QtGui
name, ok = QtGui.QInputDialog.getText(self.dialog, "Register Runner", "Runner name:")
name, ok = QtGui.QInputDialog.getText(
self.dialog, "Register Runner", "Runner name:"
)
if not ok or not name:
return
@@ -3820,7 +4007,9 @@ class Silo_StartPanel:
dock = QtGui.QDockWidget("Silo", mw)
dock.setObjectName("SiloStartPanel")
dock.setWidget(content.widget)
dock.setAllowedAreas(QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea)
dock.setAllowedAreas(
QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea
)
mw.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dock)
def IsActive(self):
@@ -3854,7 +4043,9 @@ class _DiagWorker(QtCore.QThread):
self.result.emit("DNS", False, "no hostname in URL")
return
try:
addrs = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
addrs = socket.getaddrinfo(
hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM
)
first_ip = addrs[0][4][0] if addrs else "?"
self.result.emit("DNS", True, f"{hostname} -> {first_ip}")
except socket.gaierror as e:

View File

@@ -30,7 +30,7 @@ class SiloOrigin:
Key behaviors:
- Documents are always stored locally (hybrid local-remote model)
- Database tracks metadata, part numbers, and revision history
- MinIO stores revision snapshots for sync/backup
- Server stores revision files for sync/backup
- Identity is tracked by UUID (SiloItemId), displayed as part number
"""
@@ -388,9 +388,7 @@ class SiloOrigin:
# Upload to Silo
properties = collect_document_properties(doc)
_client._upload_file(
obj.SiloPartNumber, str(file_path), properties, comment=""
)
_client._upload_file(obj.SiloPartNumber, str(file_path), properties, comment="")
# Clear modified flag (Modified is on Gui.Document, not App.Document)
gui_doc = FreeCADGui.getDocument(doc.Name)
@@ -567,12 +565,9 @@ def register_silo_origin():
This should be called during workbench initialization to make
Silo available as a file origin.
"""
origin = get_silo_origin()
try:
FreeCADGui.addOrigin(origin)
FreeCAD.Console.PrintLog("Registered Silo origin\n")
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not register Silo origin: {e}\n")
from kindred_sdk import register_origin
register_origin(get_silo_origin())
def unregister_silo_origin():
@@ -582,9 +577,7 @@ def unregister_silo_origin():
"""
global _silo_origin
if _silo_origin:
try:
FreeCADGui.removeOrigin(_silo_origin)
FreeCAD.Console.PrintLog("Unregistered Silo origin\n")
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not unregister Silo origin: {e}\n")
from kindred_sdk import unregister_origin
unregister_origin(_silo_origin)
_silo_origin = None

View File

@@ -19,23 +19,10 @@ from PySide import QtCore, QtGui, QtWidgets
# ---------------------------------------------------------------------------
# Catppuccin Mocha palette
# ---------------------------------------------------------------------------
_MOCHA = {
"base": "#1e1e2e",
"mantle": "#181825",
"crust": "#11111b",
"surface0": "#313244",
"surface1": "#45475a",
"surface2": "#585b70",
"text": "#cdd6f4",
"subtext0": "#a6adc8",
"subtext1": "#bac2de",
"blue": "#89b4fa",
"green": "#a6e3a1",
"red": "#f38ba8",
"peach": "#fab387",
"lavender": "#b4befe",
"overlay0": "#6c7086",
}
# Catppuccin Mocha palette — sourced from kindred-addon-sdk
from kindred_sdk.theme import get_theme_tokens
_MOCHA = get_theme_tokens()
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"