python-materialsdb 0.3.2__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
materialsdb/gui/server.py CHANGED
@@ -9,14 +9,26 @@ from dataclasses import asdict
9
9
  from pathlib import Path
10
10
  from urllib.parse import parse_qs, unquote, urlparse
11
11
 
12
- from materialsdb import config, utils
12
+ from materialsdb import config, schemes, utils
13
13
  from materialsdb.construction import finite_or_none
14
14
  from materialsdb.ifc.material_builder import add_material
15
+ from materialsdb.store import FINGERPRINT_SCHEME
15
16
 
16
17
  STATIC_DIR = Path(__file__).with_name("static")
17
18
 
18
19
  _LISTENER_STALE_S = 5.0
19
20
 
21
+ # model statuses
22
+ _ABSENT = "absent"
23
+ _CURRENT = "current"
24
+ _CHANGED = "changed"
25
+ _UNKNOWN = "unknown"
26
+ _GONE = "gone"
27
+
28
+ # producer geometry is resolved per country and can round-trip through JSON;
29
+ # thickness comparisons use the shared micrometre tolerance (utils)
30
+ _SCALAR_REPORT_FIELDS = ("name", "description", "category", "color", "style_name")
31
+
20
32
 
21
33
  def _float(value):
22
34
  try:
@@ -26,7 +38,257 @@ def _float(value):
26
38
 
27
39
 
28
40
  def _resolve_display_name(names, lang):
29
- return str(names.get(lang) or names.get("") or "")
41
+ for key in (lang, "", "en"):
42
+ value = names.get(key)
43
+ if value:
44
+ return str(value)
45
+ return str(next((value for value in names.values() if value), ""))
46
+
47
+
48
+ def _active_model_path(state) -> str | None:
49
+ """The ``model_path`` of the listener seen most recently (non-empty)."""
50
+ listeners = [listener for listener in state.listeners.values() if listener.get("model_path")]
51
+ if not listeners:
52
+ return None
53
+ return max(listeners, key=lambda listener: listener.get("last_seen") or 0.0)["model_path"]
54
+
55
+
56
+ def _status_from_entry(store_, entry, material_id) -> str:
57
+ """Derive a material's model status from its cached model-map entry."""
58
+ if entry is None:
59
+ return _ABSENT
60
+ digest = store_.material_fingerprint(material_id)
61
+ if digest is None:
62
+ return _GONE
63
+ # A model entry without a per-layer map (no org_layer) is a legacy
64
+ # baseline: there is nothing to match or report, so it is never `changed`.
65
+ if not entry.get("layers"):
66
+ return _UNKNOWN
67
+ fingerprint = entry.get("fingerprint")
68
+ if not fingerprint or entry.get("scheme") != FINGERPRINT_SCHEME:
69
+ return _UNKNOWN
70
+ if fingerprint != digest:
71
+ return _CHANGED
72
+ return _CURRENT
73
+
74
+
75
+ def _material_status(store_, model_path, material_id) -> str:
76
+ """Status of ``material_id`` in the model cached under ``model_path``."""
77
+ if not model_path:
78
+ return _ABSENT
79
+ entry = store_.get_model_materials(model_path).get(material_id)
80
+ return _status_from_entry(store_, entry, material_id)
81
+
82
+
83
+ def _new_layers(store_, material_id) -> list[dict]:
84
+ """The current store layers of ``material_id`` as {layer_id, thick} (metres)."""
85
+ from materialsdb.gui.listener import build_add_materials_payload
86
+
87
+ payload, _missing = build_add_materials_payload(store_, [{"id": material_id}])
88
+ layers = []
89
+ for entry in payload["materials"]:
90
+ org_layer = (entry.get("psets") or {}).get("materialsdb.org_layer") or {}
91
+ thick = org_layer.get("thick")
92
+ if thick is None:
93
+ thick = (entry.get("layer") or {}).get("thick_m")
94
+ layers.append({"layer_id": str(entry.get("layer_id") or ""), "thick": thick})
95
+ return layers
96
+
97
+
98
+ def _layer_candidates(model_layer, new_layers) -> list[str]:
99
+ """New layer ids a single model layer can be updated to, in preference order."""
100
+ model_layer_id = model_layer.get("layer_id")
101
+ if model_layer_id:
102
+ for new_layer in new_layers:
103
+ if new_layer["layer_id"] == str(model_layer_id):
104
+ return [new_layer["layer_id"]]
105
+ thick = model_layer.get("thick")
106
+ matches = []
107
+ if thick:
108
+ matches = [
109
+ new_layer["layer_id"]
110
+ for new_layer in new_layers
111
+ if new_layer["thick"] is not None and abs(new_layer["thick"] - thick) <= utils.THICKNESS_EPS_M
112
+ ]
113
+ if matches:
114
+ return matches
115
+ if len(new_layers) == 1:
116
+ # λ-only materials have a single layer with no thickness to match on
117
+ return [new_layers[0]["layer_id"]]
118
+ # No thickness match and several variants: propose them so the user can
119
+ # choose (the model layer's own thickness is never changed).
120
+ return [new_layer["layer_id"] for new_layer in new_layers]
121
+
122
+
123
+ def _layer_key(model_layer) -> str:
124
+ """Stable per-model-layer key: the IfcMaterial entity id, falling back to
125
+ the org_layer GUID for older maps."""
126
+ return str(model_layer.get("entity_id") or model_layer.get("layer_id") or "")
127
+
128
+
129
+ def _match_layers(store_, material_id, model_layers) -> dict:
130
+ """Match the model's layers onto the current store version.
131
+
132
+ Returns ``{state, mapping, candidates}`` where state is one of
133
+ ``updatable`` / ``ambiguous`` / ``unmatched`` / ``unknown``; keys are
134
+ per-model-layer entity ids."""
135
+ summary = store_.get_summary(material_id)
136
+ if summary is None:
137
+ return {"state": _UNKNOWN, "mapping": {}, "candidates": {}}
138
+ if summary.type != "simple":
139
+ # btk/complex materials cannot be updated in place layer by layer
140
+ return {"state": "unmatched", "mapping": {}, "candidates": {}}
141
+ if not model_layers:
142
+ return {"state": _UNKNOWN, "mapping": {}, "candidates": {}}
143
+ new_layers = _new_layers(store_, material_id)
144
+ if not new_layers:
145
+ return {"state": "unmatched", "mapping": {}, "candidates": {}}
146
+
147
+ mapping: dict[str, str] = {}
148
+ candidates: dict[str, list[str]] = {}
149
+ unmatched = False
150
+ for model_layer in model_layers:
151
+ key = _layer_key(model_layer)
152
+ matches = _layer_candidates(model_layer, new_layers)
153
+ if len(matches) == 1:
154
+ mapping[key] = matches[0]
155
+ elif len(matches) > 1:
156
+ candidates[key] = matches
157
+ else:
158
+ unmatched = True
159
+ if unmatched:
160
+ return {"state": "unmatched", "mapping": mapping, "candidates": candidates}
161
+ if candidates:
162
+ return {"state": "ambiguous", "mapping": mapping, "candidates": candidates}
163
+ return {"state": "updatable", "mapping": mapping, "candidates": {}}
164
+
165
+
166
+ def _force_plan(store_, model_path, choices) -> tuple[list[dict], dict, list[str]]:
167
+ """Plan a force update of every material used by walls/slabs/roofs.
168
+
169
+ Returns ``(entries, unresolved, skipped)``: resolvable model layers become
170
+ ``mode="update"`` ``force=True`` payload entries; layers the user has not
171
+ chosen yet land in ``unresolved`` with their candidate new-layer ids;
172
+ materials no longer in the store are ``skipped``."""
173
+ from materialsdb.gui.listener import build_add_materials_payload
174
+
175
+ model = store_.get_model_materials(model_path)
176
+ entries: list[dict] = []
177
+ unresolved: dict = {}
178
+ skipped: list[str] = []
179
+ for material_id, entry in model.items():
180
+ if not entry.get("used_in"):
181
+ continue
182
+ if store_.material_fingerprint(material_id) is None:
183
+ skipped.append(material_id)
184
+ continue
185
+ model_layers = entry.get("layers") or []
186
+ mapping = dict(_match_layers(store_, material_id, model_layers)["mapping"])
187
+ for entity_id, new_layer_id in (choices or {}).items():
188
+ if any(_layer_key(layer) == str(entity_id) for layer in model_layers):
189
+ mapping[str(entity_id)] = str(new_layer_id)
190
+ missing = [layer for layer in model_layers if _layer_key(layer) not in mapping]
191
+ if missing:
192
+ unresolved[material_id] = [
193
+ {
194
+ "entity_id": _layer_key(layer),
195
+ "thick": layer.get("thick"),
196
+ "candidates": _layer_candidates(layer, _new_layers(store_, material_id)),
197
+ }
198
+ for layer in missing
199
+ ]
200
+ continue
201
+ payload, _missing = build_add_materials_payload(
202
+ store_, [{"id": material_id, "mode": "update", "force": True, "update": mapping}]
203
+ )
204
+ for entry in payload["materials"]:
205
+ layer_id = str(entry.get("layer_id") or "")
206
+ targets = {key: value for key, value in mapping.items() if value == layer_id}
207
+ if not targets:
208
+ continue
209
+ entry["update"] = targets
210
+ entries.append(entry)
211
+ return entries, unresolved, skipped
212
+
213
+
214
+ def _restyle_plan(store_, model_path, scheme, ignore_producer_color):
215
+ """(entries, skipped): style-only entries for every model material still in the store."""
216
+ from materialsdb.gui.listener import style_for
217
+
218
+ entries, skipped = [], []
219
+ for material_id in store_.get_model_materials(model_path):
220
+ material = store_.get(material_id)
221
+ if material is None:
222
+ skipped.append(material_id)
223
+ continue
224
+ style_name, rgb = style_for(
225
+ getattr(material.information, "color", None),
226
+ str(getattr(material.information, "group", "") or ""),
227
+ scheme=scheme,
228
+ ignore_producer_color=ignore_producer_color,
229
+ )
230
+ entries.append(
231
+ {
232
+ "material_id": material_id,
233
+ "style_name": style_name,
234
+ "style_color": [round(component, 6) for component in rgb],
235
+ }
236
+ )
237
+ return entries, skipped
238
+
239
+
240
+ def _lambda_value(entry) -> float | None:
241
+ thermal = (entry.get("psets") or {}).get("materialsdb.org_thermal") or {}
242
+ return thermal.get("lambda_value_dry")
243
+
244
+
245
+ def _change_report(store_, model_path, material_id, model_layers, mapping) -> tuple[list[dict], bool]:
246
+ """Old → new schedule of the fields that changed since the push snapshot.
247
+
248
+ Snapshots are keyed by the *model* layer id recorded at push time, which may
249
+ have been re-keyed upstream; pair each model layer to its matched current
250
+ store layer via ``mapping`` (model layer id → new layer id). Returns
251
+ ``(report, has_snapshot)``; ``([], False)`` when nothing was pushed."""
252
+ from materialsdb.gui.listener import build_add_materials_payload
253
+
254
+ payload, _missing = build_add_materials_payload(store_, [{"id": material_id}])
255
+ entries = payload["materials"]
256
+ by_layer = {str(entry.get("layer_id") or ""): entry for entry in entries}
257
+ fallback_entry = entries[0] if entries else None
258
+ report: list[dict] = []
259
+ has_snapshot = False
260
+ scalars_done: set[str] = set()
261
+ for model_layer in model_layers:
262
+ model_layer_id = str(model_layer.get("layer_id") or "")
263
+ snapshot = store_.get_pushed_material(model_path, material_id, model_layer_id or None)
264
+ if snapshot is None:
265
+ continue
266
+ has_snapshot = True
267
+ new_layer_id = mapping.get(_layer_key(model_layer))
268
+ paired_entry = by_layer.get(new_layer_id) if new_layer_id is not None else None
269
+ # a model layer with no matched current layer can still surface the
270
+ # material-level scalar changes against any current entry
271
+ comparison_entry = paired_entry if paired_entry is not None else fallback_entry
272
+ if comparison_entry is None:
273
+ continue
274
+ for field in _SCALAR_REPORT_FIELDS:
275
+ if field in scalars_done:
276
+ continue
277
+ old, new = snapshot.get(field), comparison_entry.get(field)
278
+ if old != new:
279
+ report.append({"field": field, "old": old, "new": new})
280
+ scalars_done.add(field)
281
+ if paired_entry is None:
282
+ # thickness/lambda can only be compared against a matched layer
283
+ continue
284
+ old_thick = (snapshot.get("layer") or {}).get("thick_m")
285
+ new_thick = (paired_entry.get("layer") or {}).get("thick_m")
286
+ if old_thick != new_thick:
287
+ report.append({"field": "thickness", "old": old_thick, "new": new_thick})
288
+ old_lambda, new_lambda = _lambda_value(snapshot), _lambda_value(paired_entry)
289
+ if old_lambda != new_lambda:
290
+ report.append({"field": "lambda", "old": old_lambda, "new": new_lambda})
291
+ return report, has_snapshot
30
292
 
31
293
 
32
294
  def _progress_cb(state):
@@ -108,6 +370,8 @@ class GuiState:
108
370
  self.file = None
109
371
  # client_id -> {"model_path": str, "last_seen": float, "pending": dict | None, "last_status": dict | None}
110
372
  self.listeners = {}
373
+ # most recent model path whose map was cached (served when offline)
374
+ self.last_model_path: str | None = None
111
375
  # constructions pushed from a model (newest first, capped at 20)
112
376
  self.incoming = []
113
377
  self.updates_available = False
@@ -215,14 +479,28 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
215
479
  self._send(400, {"error": "client_id required"})
216
480
  return
217
481
  self._prune_listeners()
482
+ model_path = str(payload.get("model_path") or "")
483
+ if model_path:
484
+ self.state.last_model_path = model_path
218
485
  self.state.listeners[client_id] = {
219
- "model_path": str(payload.get("model_path") or ""),
486
+ "model_path": model_path,
220
487
  "last_seen": time.time(),
221
488
  "pending": None,
222
489
  "last_status": None,
223
490
  }
224
491
  self._send(200, {"ok": True})
225
492
 
493
+ def _listener_model(self, payload):
494
+ model_path = str(payload.get("model_path") or "")
495
+ materials = payload.get("materials")
496
+ if not isinstance(materials, dict):
497
+ self._send(400, {"error": "materials must be an object"})
498
+ return
499
+ if model_path:
500
+ self.state.last_model_path = model_path
501
+ self.state.resolve_store().set_model_materials(model_path, materials)
502
+ self._send(200, {"ok": True})
503
+
226
504
  def _listener_poll(self, parsed):
227
505
  client_id = (parse_qs(parsed.query).get("client_id") or [""])[0]
228
506
  listener = self.state.listeners.get(client_id)
@@ -244,6 +522,78 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
244
522
  except ConnectionError:
245
523
  self.close_connection = True
246
524
 
525
+ def _model_force_update(self, store_, payload):
526
+ """Queue an in-place refresh of every model material used by
527
+ walls/slabs/roofs, regardless of its fingerprint state."""
528
+ model_path = _active_model_path(self.state) or self.state.last_model_path
529
+ if not model_path:
530
+ self._send(409, {"error": "no model connected"})
531
+ return
532
+ client_id = next(
533
+ (cid for cid, listener in self.state.listeners.items() if listener.get("model_path") == model_path),
534
+ None,
535
+ )
536
+ if client_id is None:
537
+ self._send(409, {"error": "no listener connected for the active model"})
538
+ return
539
+ entries, unresolved, skipped = _force_plan(store_, model_path, payload.get("choices") or {})
540
+ if entries:
541
+ resolved = {"action": "add_materials", "materials": entries}
542
+ store_.record_pushed_materials(model_path, entries)
543
+ listener = self.state.listeners[client_id]
544
+ listener["pending"] = resolved
545
+ listener["last_status"] = {"status": "pending", "detail": ""}
546
+ self._send(
547
+ 200,
548
+ {
549
+ "queued": len(entries),
550
+ "unresolved": unresolved,
551
+ "skipped": skipped,
552
+ "counts": {
553
+ "queued": len(entries),
554
+ "unresolved": len(unresolved),
555
+ "skipped": len(skipped),
556
+ },
557
+ },
558
+ )
559
+
560
+ def _model_restyle(self, store_, payload):
561
+ """Queue a style-only restyle of every material cached for a model.
562
+
563
+ With an explicit ``client_id`` the named listener's model is targeted;
564
+ otherwise the active model (most recent listener, then last cached path)
565
+ keeps the historical behaviour."""
566
+ requested = str(payload.get("client_id") or "")
567
+ if requested:
568
+ listener = self.state.listeners.get(requested)
569
+ if listener is None:
570
+ self._send(404, {"error": f"unknown listener: {requested}"})
571
+ return
572
+ if not listener.get("model_path"):
573
+ self._send(409, {"error": "listener has no model connected"})
574
+ return
575
+ client_id = requested
576
+ model_path = listener["model_path"]
577
+ else:
578
+ model_path = _active_model_path(self.state) or self.state.last_model_path
579
+ if not model_path:
580
+ self._send(409, {"error": "no model connected"})
581
+ return
582
+ client_id = next(
583
+ (cid for cid, listener in self.state.listeners.items() if listener.get("model_path") == model_path),
584
+ None,
585
+ )
586
+ if client_id is None:
587
+ self._send(409, {"error": "no listener connected for the active model"})
588
+ return
589
+ scheme = config.get_scheme() or schemes.DEFAULT_SCHEME
590
+ entries, skipped = _restyle_plan(store_, model_path, scheme, config.get_ignore_producer_color())
591
+ if entries:
592
+ listener = self.state.listeners[client_id]
593
+ listener["pending"] = {"action": "restyle", "materials": entries}
594
+ listener["last_status"] = {"status": "pending", "detail": ""}
595
+ self._send(200, {"queued": len(entries), "skipped": skipped, "scheme": scheme})
596
+
247
597
  def _listener_send(self, store_, payload):
248
598
  client_id = str(payload.get("client_id") or "")
249
599
  listener = self.state.listeners.get(client_id)
@@ -252,6 +602,7 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
252
602
  return
253
603
  action = str(payload.get("action") or "add_materials")
254
604
  missing = []
605
+ warnings = []
255
606
  if action == "add_materials":
256
607
  from materialsdb.gui.listener import build_add_materials_payload
257
608
 
@@ -270,14 +621,23 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
270
621
  return
271
622
  queued = len(resolved["construction"]["layers"])
272
623
  summary = f"construction '{resolved['construction']['name']}' ({queued} layer(s))"
624
+ warnings = [str(w) for w in resolved["construction"].get("warnings") or []]
273
625
  else:
274
626
  self._send(400, {"error": f"unsupported action: {action}"})
275
627
  return
628
+ # record what was pushed so a later task can diff the model against it
629
+ model_path = listener.get("model_path")
630
+ if model_path:
631
+ if action == "add_materials":
632
+ entries = resolved["materials"]
633
+ else:
634
+ entries = [layer["material"] for layer in resolved["construction"]["layers"] if layer.get("material")]
635
+ store_.record_pushed_materials(model_path, entries)
276
636
  listener["pending"] = resolved
277
637
  # a fresh push invalidates the previous applied/error mark: the
278
638
  # dropdown shows a pending state until the add-on reports the new one
279
639
  listener["last_status"] = {"status": "pending", "detail": ""}
280
- self._send(200, {"ok": True, "queued": queued, "summary": summary, "missing": missing})
640
+ self._send(200, {"ok": True, "queued": queued, "summary": summary, "missing": missing, "warnings": warnings})
281
641
 
282
642
  def _listener_status(self, payload):
283
643
  client_id = str(payload.get("client_id") or "")
@@ -299,6 +659,56 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
299
659
  ]
300
660
  self._send(200, {"clients": clients})
301
661
 
662
+ def _model_changes(self, store_):
663
+ """Status of every cached model material, with the change report."""
664
+ model_path = _active_model_path(self.state) or self.state.last_model_path
665
+ if not model_path:
666
+ self._send(
667
+ 200,
668
+ {
669
+ "model_path": None,
670
+ "seen_at": None,
671
+ "counts": {"changed": 0, "gone": 0, "unknown": 0},
672
+ "changed": [],
673
+ "gone": [],
674
+ "unknown": [],
675
+ },
676
+ )
677
+ return
678
+ model = store_.get_model_materials(model_path)
679
+ changed, gone, unknown = [], [], []
680
+ for material_id, entry in model.items():
681
+ status = _status_from_entry(store_, entry, material_id)
682
+ if status == _CHANGED:
683
+ model_layers = entry.get("layers") or []
684
+ matching = _match_layers(store_, material_id, model_layers)
685
+ report, has_snapshot = _change_report(
686
+ store_, model_path, material_id, model_layers, matching["mapping"]
687
+ )
688
+ changed.append(
689
+ {
690
+ "material_id": material_id,
691
+ "matching": matching,
692
+ "report": report,
693
+ "has_snapshot": has_snapshot,
694
+ }
695
+ )
696
+ elif status == _GONE:
697
+ gone.append(material_id)
698
+ elif status == _UNKNOWN:
699
+ unknown.append(material_id)
700
+ self._send(
701
+ 200,
702
+ {
703
+ "model_path": model_path,
704
+ "seen_at": store_.model_seen_at(model_path),
705
+ "counts": {"changed": len(changed), "gone": len(gone), "unknown": len(unknown)},
706
+ "changed": changed,
707
+ "gone": gone,
708
+ "unknown": unknown,
709
+ },
710
+ )
711
+
302
712
  def _composer_push(self, payload):
303
713
  name = str(payload.get("name") or "").strip()
304
714
  layers = payload.get("layers")
@@ -388,6 +798,23 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
388
798
  return
389
799
  self._send(200, content_type="text/javascript; charset=utf-8", raw=js.encode("utf-8"))
390
800
  return
801
+ if parsed.path == "/settings.html":
802
+ try:
803
+ html = (STATIC_DIR / "settings.html").read_text(encoding="utf-8")
804
+ except FileNotFoundError:
805
+ self._send(404, {"error": "not found"})
806
+ return
807
+ html = html.replace("__TOKEN__", self.state.token)
808
+ self._send(200, content_type="text/html; charset=utf-8", raw=html.encode("utf-8"))
809
+ return
810
+ if parsed.path == "/settings.js":
811
+ try:
812
+ js = (STATIC_DIR / "settings.js").read_text(encoding="utf-8")
813
+ except FileNotFoundError:
814
+ self._send(404, {"error": "not found"})
815
+ return
816
+ self._send(200, content_type="text/javascript; charset=utf-8", raw=js.encode("utf-8"))
817
+ return
391
818
  if parsed.path == "/picker-core.js":
392
819
  try:
393
820
  js = (STATIC_DIR / "picker-core.js").read_text(encoding="utf-8")
@@ -413,10 +840,13 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
413
840
  lang=params.get("lang") or None,
414
841
  )
415
842
  lang = params.get("lang") or config.get_lang()
843
+ model_path = _active_model_path(self.state) or self.state.last_model_path
844
+ model = store_.get_model_materials(model_path) if model_path else {}
416
845
  materials = []
417
846
  for row in rows:
418
847
  item = asdict(row)
419
848
  item["display_name"] = _resolve_display_name(item["names"], lang)
849
+ item["model"] = _status_from_entry(store_, model.get(item["id"]), item["id"])
420
850
  materials.append(item)
421
851
  self._send(200, {"materials": materials})
422
852
  return
@@ -441,10 +871,27 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
441
871
 
442
872
  self._send(200, {"constructions": cm.list_constructions()})
443
873
  return
874
+ if parsed.path == "/api/schemes":
875
+ self._schemes_list()
876
+ return
877
+ if parsed.path.startswith("/api/schemes/"):
878
+ self._scheme_get(parsed)
879
+ return
444
880
  if parsed.path == "/api/config":
445
881
  from materialsdb import config as cfg
446
882
 
447
- self._send(200, {"lang": cfg.get_lang(), "country": cfg.get_country()})
883
+ stored_scheme = cfg.get_scheme()
884
+ self._send(
885
+ 200,
886
+ {
887
+ "lang": cfg.get_lang(),
888
+ "country": cfg.get_country(),
889
+ # effective scheme: a stored-but-absent name resolves to the default
890
+ "scheme": stored_scheme if stored_scheme in schemes.available() else schemes.DEFAULT_SCHEME,
891
+ "schemes": list(schemes.available()),
892
+ "ignore_producer_color": cfg.get_ignore_producer_color(),
893
+ },
894
+ )
448
895
  return
449
896
  if parsed.path == "/api/listener/poll":
450
897
  if not self._authorized():
@@ -467,6 +914,9 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
467
914
  if parsed.path == "/api/updates":
468
915
  self._send(200, {"updates_available": self.state.updates_available})
469
916
  return
917
+ if parsed.path == "/api/model/changes":
918
+ self._model_changes(store_)
919
+ return
470
920
  if parsed.path.startswith("/api/constructions/"):
471
921
  from materialsdb import construction as cm
472
922
 
@@ -575,12 +1025,22 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
575
1025
  self._composer_push(payload)
576
1026
  elif parsed.path == "/api/composer/incoming/consume":
577
1027
  self._composer_consume(payload)
1028
+ elif parsed.path == "/api/schemes/import":
1029
+ self._scheme_import(payload)
1030
+ elif parsed.path.startswith("/api/schemes/"):
1031
+ self._scheme_post(parsed, payload)
578
1032
  elif parsed.path.startswith("/api/constructions/"):
579
1033
  self._construction_save(store_, parsed, payload)
580
1034
  elif parsed.path == "/api/listener/register":
581
1035
  self._listener_register(payload)
1036
+ elif parsed.path == "/api/listener/model":
1037
+ self._listener_model(payload)
582
1038
  elif parsed.path == "/api/listener/send":
583
1039
  self._listener_send(store_, payload)
1040
+ elif parsed.path == "/api/model/force-update":
1041
+ self._model_force_update(store_, payload)
1042
+ elif parsed.path == "/api/model/restyle":
1043
+ self._model_restyle(store_, payload)
584
1044
  elif parsed.path == "/api/listener/status":
585
1045
  self._listener_status(payload)
586
1046
  else:
@@ -717,8 +1177,104 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
717
1177
  config.set_lang(str(lang).lower())
718
1178
  if country:
719
1179
  config.set_country(str(country).upper())
1180
+ if "scheme" in payload:
1181
+ scheme = str(payload["scheme"])
1182
+ if scheme not in schemes.available():
1183
+ self._send(400, {"error": f"unknown scheme: {scheme}"})
1184
+ return
1185
+ config.set_scheme(scheme)
1186
+ if "ignore_producer_color" in payload:
1187
+ config.set_ignore_producer_color(bool(payload["ignore_producer_color"]))
1188
+ self._send(200, {"ok": True})
1189
+
1190
+ def _schemes_list(self):
1191
+ names = list(schemes.available())
1192
+ self._send(
1193
+ 200,
1194
+ {
1195
+ "schemes": [{"name": name, "builtin": name in schemes.BUILTIN} for name in names],
1196
+ "error": schemes.load_error(),
1197
+ },
1198
+ )
1199
+
1200
+ def _scheme_get(self, parsed):
1201
+ rest = unquote(parsed.path[len("/api/schemes/") :])
1202
+ export = rest.endswith("/export")
1203
+ name = rest[: -len("/export")] if export else rest
1204
+ palette = schemes.get(name)
1205
+ if palette is None:
1206
+ self._send(404, {"error": f"unknown scheme: {name}"})
1207
+ return
1208
+ if export:
1209
+ self._send(200, schemes.export_payload(name))
1210
+ return
1211
+ self._send(200, {"name": name, "builtin": name in schemes.BUILTIN, "categories": palette})
1212
+
1213
+ def _scheme_post(self, parsed, payload):
1214
+ rest = unquote(parsed.path[len("/api/schemes/") :])
1215
+ if rest.endswith("/rename"):
1216
+ self._scheme_rename(rest[: -len("/rename")], payload)
1217
+ elif rest.endswith("/delete"):
1218
+ self._scheme_delete(rest[: -len("/delete")])
1219
+ else:
1220
+ self._scheme_save(rest, payload)
1221
+
1222
+ def _scheme_save(self, name, payload):
1223
+ if name in schemes.BUILTIN:
1224
+ self._send(400, {"error": f"built-in scheme {name!r} cannot be modified"})
1225
+ return
1226
+ categories = payload.get("categories") if isinstance(payload, dict) else None
1227
+ try:
1228
+ schemes.save(name, categories)
1229
+ except schemes.SchemeError as err:
1230
+ self._send(400, {"error": str(err)})
1231
+ return
1232
+ self._send(200, {"ok": True})
1233
+
1234
+ def _scheme_rename(self, name, payload):
1235
+ new_name = payload.get("new_name") if isinstance(payload, dict) else None
1236
+ if not isinstance(new_name, str):
1237
+ self._send(400, {"error": "new_name must be a string"})
1238
+ return
1239
+ new_name = new_name.strip() # schemes.rename stores the stripped name; config must match
1240
+ try:
1241
+ schemes.rename(name, new_name)
1242
+ except schemes.SchemeError as err:
1243
+ self._send(400, {"error": str(err)})
1244
+ return
1245
+ if config.get_scheme() == name:
1246
+ config.set_scheme(new_name)
720
1247
  self._send(200, {"ok": True})
721
1248
 
1249
+ def _scheme_delete(self, name):
1250
+ if schemes.get(name) is None:
1251
+ self._send(404, {"error": f"unknown scheme: {name}"})
1252
+ return
1253
+ if name in schemes.BUILTIN:
1254
+ self._send(400, {"error": f"built-in scheme {name!r} cannot be deleted"})
1255
+ return
1256
+ active = config.get_scheme() == name
1257
+ try:
1258
+ schemes.delete(name)
1259
+ except schemes.SchemeError as err:
1260
+ self._send(400, {"error": str(err)})
1261
+ return
1262
+ if active:
1263
+ config.set_scheme(schemes.DEFAULT_SCHEME)
1264
+ self._send(200, {"ok": True})
1265
+
1266
+ def _scheme_import(self, payload):
1267
+ name = payload.get("name") if isinstance(payload, dict) else None
1268
+ if isinstance(name, str) and name in schemes.available():
1269
+ self._send(409, {"error": f"a scheme named {name!r} already exists"})
1270
+ return
1271
+ try:
1272
+ imported = schemes.import_payload(payload)
1273
+ except schemes.SchemeError as err:
1274
+ self._send(400, {"error": str(err)})
1275
+ return
1276
+ self._send(200, {"name": imported})
1277
+
722
1278
  def _refresh(self, payload):
723
1279
  job = self.state.refresh_job
724
1280
  if job is not None and job["status"] == "running":