cctally 1.93.0 → 1.94.0
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.
- package/CHANGELOG.md +48 -0
- package/bin/_cctally_cache.py +55 -6
- package/bin/_cctally_codex.py +35 -3
- package/bin/_cctally_config.py +108 -2
- package/bin/_cctally_core.py +37 -8
- package/bin/_cctally_dashboard.py +6 -0
- package/bin/_cctally_dashboard_share.py +744 -137
- package/bin/_cctally_dashboard_sources.py +9 -0
- package/bin/_cctally_db.py +315 -40
- package/bin/_cctally_doctor.py +188 -3
- package/bin/_cctally_five_hour.py +39 -12
- package/bin/_cctally_forecast.py +29 -47
- package/bin/_cctally_journal.py +79 -7
- package/bin/_cctally_parser.py +71 -1
- package/bin/_cctally_project.py +0 -2
- package/bin/_cctally_record.py +18 -0
- package/bin/_cctally_reporting.py +0 -9
- package/bin/_cctally_retention.py +2890 -0
- package/bin/_cctally_share.py +97 -92
- package/bin/_cctally_source_analytics.py +44 -16
- package/bin/_cctally_store.py +194 -14
- package/bin/_lib_artifact_retention.py +1645 -0
- package/bin/_lib_display_tz.py +18 -0
- package/bin/_lib_doctor.py +378 -24
- package/bin/_lib_share.py +1359 -183
- package/bin/_lib_share_templates.py +181 -69
- package/bin/_lib_view_models.py +12 -0
- package/bin/cctally +36 -4
- package/dashboard/static/assets/{index-HlIK7k8Q.js → index-CaUQziq_.js} +48 -48
- package/dashboard/static/assets/index-DUQjFSX7.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +3 -1
- package/dashboard/static/assets/index-DwWJOYxd.css +0 -1
|
@@ -33,6 +33,7 @@ cycle-free.
|
|
|
33
33
|
"""
|
|
34
34
|
from __future__ import annotations
|
|
35
35
|
|
|
36
|
+
import dataclasses as _dataclasses
|
|
36
37
|
import datetime as dt
|
|
37
38
|
import json
|
|
38
39
|
import pathlib
|
|
@@ -50,6 +51,7 @@ from _lib_fmt import stable_sum
|
|
|
50
51
|
from _lib_pricing import _calculate_entry_cost, claude_usage_dict
|
|
51
52
|
from _lib_five_hour import _canonical_5h_window_key
|
|
52
53
|
from _lib_dashboard_sources import source_domain_freshness
|
|
54
|
+
from _lib_display_tz import _resolve_tz, resolve_display_tz_name
|
|
53
55
|
|
|
54
56
|
|
|
55
57
|
# Share-CLI helpers consumed by the dashboard's share-data builders.
|
|
@@ -76,7 +78,8 @@ def _share_iso(*args, **kwargs):
|
|
|
76
78
|
# #279 S1 F3: cap on /api/share/* POST bodies. The share composer sends bigger
|
|
77
79
|
# payloads than the 4 KB settings POSTs (a multi-panel compose recipe), but
|
|
78
80
|
# must still be bounded — 64 KiB comfortably exceeds any real payload (render is
|
|
79
|
-
# one panel; compose is
|
|
81
|
+
# one panel; compose is up to the basket cap of 20 sections, each with a
|
|
82
|
+
# small options recipe).
|
|
80
83
|
_SHARE_POST_MAX_BYTES = 64 * 1024
|
|
81
84
|
|
|
82
85
|
|
|
@@ -1428,16 +1431,58 @@ def _share_codex_state_for_period(data_snap, *, panel: str, options: dict):
|
|
|
1428
1431
|
stats_conn.close()
|
|
1429
1432
|
|
|
1430
1433
|
|
|
1431
|
-
def
|
|
1434
|
+
def _share_resolved_display_tz(raw: object = None) -> str:
|
|
1435
|
+
"""The concrete IANA zone a share artifact states its dates in.
|
|
1436
|
+
|
|
1437
|
+
One resolution for the whole dashboard share surface (#503 S2 D7):
|
|
1438
|
+
the render handler injects it into `options`, the composite handler
|
|
1439
|
+
injects it into every composite section's options, and the Codex
|
|
1440
|
+
snapshot builders read it back instead of hardcoding `"UTC"` — so a
|
|
1441
|
+
composed document cannot carry one section labelled `(UTC)` beside
|
|
1442
|
+
another labelled `(Etc/UTC)`.
|
|
1443
|
+
|
|
1444
|
+
`raw` is a configuration token (`local` / `utc` / IANA) or None; None
|
|
1445
|
+
means "read the server's config".
|
|
1446
|
+
"""
|
|
1447
|
+
c = sys.modules["cctally"]
|
|
1448
|
+
token = raw if isinstance(raw, str) and raw else c.get_display_tz_pref(c.load_config())
|
|
1449
|
+
return resolve_display_tz_name(token)
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
def _share_parse_bucket_start(panel: str, label: object,
|
|
1453
|
+
zone=None) -> "dt.datetime | None":
|
|
1454
|
+
"""Lift a bucket label or a row timestamp into a period boundary.
|
|
1455
|
+
|
|
1456
|
+
A NAIVE parse is a calendar label (`2026-05-04`, `2026-05`), so it is
|
|
1457
|
+
grounded at midnight in the zone the artifact is labelled with —
|
|
1458
|
+
grounding it in UTC and then converting it into that zone reports the
|
|
1459
|
+
previous day everywhere west of UTC (#503 S2 D7). An AWARE parse
|
|
1460
|
+
(`first_seen` / `last_activity`, which the envelope serializes with an
|
|
1461
|
+
explicit offset) is a real instant and keeps its own offset.
|
|
1462
|
+
"""
|
|
1432
1463
|
try:
|
|
1433
1464
|
if panel == "monthly":
|
|
1434
|
-
|
|
1435
|
-
|
|
1465
|
+
parsed = dt.datetime.strptime(str(label), "%Y-%m")
|
|
1466
|
+
else:
|
|
1467
|
+
parsed = dt.datetime.fromisoformat(str(label))
|
|
1436
1468
|
except ValueError:
|
|
1437
1469
|
return None
|
|
1470
|
+
if parsed.tzinfo is None:
|
|
1471
|
+
return parsed.replace(tzinfo=zone or dt.timezone.utc)
|
|
1472
|
+
return parsed
|
|
1473
|
+
|
|
1438
1474
|
|
|
1475
|
+
def _share_codex_period_bounds(*, state, panel: str, options: dict, rows,
|
|
1476
|
+
display_tz: "str | None" = None) -> tuple:
|
|
1477
|
+
"""The period bounds for a Codex source snapshot.
|
|
1439
1478
|
|
|
1440
|
-
|
|
1479
|
+
`display_tz` is the zone the SNAPSHOT will be labelled with, and a
|
|
1480
|
+
bucket label is grounded at ITS midnight (#503 S2 D7). The period
|
|
1481
|
+
families resolve that from the panel's own value before calling
|
|
1482
|
+
here, and that can differ from the options-derived default —
|
|
1483
|
+
grounding in one zone while labelling with another states a date
|
|
1484
|
+
the artifact's own rows do not use.
|
|
1485
|
+
"""
|
|
1441
1486
|
now_override, start_override, err = _share_resolve_period(panel, options)
|
|
1442
1487
|
if err is not None:
|
|
1443
1488
|
raise ValueError("source capability unavailable")
|
|
@@ -1447,23 +1492,82 @@ def _share_codex_period_bounds(*, state, panel: str, options: dict, rows) -> tup
|
|
|
1447
1492
|
end = end.astimezone(dt.timezone.utc)
|
|
1448
1493
|
if start_override is not None:
|
|
1449
1494
|
return start_override.astimezone(dt.timezone.utc), end
|
|
1495
|
+
zone = _resolve_tz(
|
|
1496
|
+
display_tz or _share_resolved_display_tz(options.get("display_tz")),
|
|
1497
|
+
fallback=dt.timezone.utc)
|
|
1450
1498
|
starts = []
|
|
1451
1499
|
for row in rows:
|
|
1452
1500
|
if not isinstance(row, Mapping):
|
|
1453
1501
|
continue
|
|
1454
1502
|
raw = row.get("first_seen") or row.get("last_activity") or row.get("label")
|
|
1455
|
-
parsed = _share_parse_bucket_start(
|
|
1503
|
+
parsed = _share_parse_bucket_start(
|
|
1504
|
+
"monthly" if panel == "monthly" else "daily", raw, zone)
|
|
1456
1505
|
if parsed is not None:
|
|
1457
1506
|
starts.append(parsed)
|
|
1458
1507
|
if panel == "current-week":
|
|
1459
1508
|
starts = [
|
|
1460
1509
|
parsed for row in rows if isinstance(row, Mapping)
|
|
1461
|
-
if (parsed := _share_parse_bucket_start(
|
|
1510
|
+
if (parsed := _share_parse_bucket_start(
|
|
1511
|
+
"weekly", row.get("label"), zone)) is not None
|
|
1462
1512
|
]
|
|
1463
1513
|
return (max(starts) if starts else end - dt.timedelta(days=7)), end
|
|
1464
1514
|
return (min(starts) if starts else end), end
|
|
1465
1515
|
|
|
1466
1516
|
|
|
1517
|
+
# What the block column states when the row carries no parseable start.
|
|
1518
|
+
_CODEX_BLOCK_LABEL_UNKNOWN = "(unknown)"
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
def _codex_block_label(ls, row) -> str:
|
|
1522
|
+
"""The block-start text a Codex quota artifact states.
|
|
1523
|
+
|
|
1524
|
+
`bin/_cctally_dashboard_sources.py` renders each block's `label` as
|
|
1525
|
+
`%H:%M %b %d` for the dashboard chip, where the compact form is the
|
|
1526
|
+
point and the year is context the surrounding page supplies. An
|
|
1527
|
+
artifact leaves cctally, so it names its year: every Claude blocks
|
|
1528
|
+
artifact states a full ISO instant, and this column stated the chip
|
|
1529
|
+
string instead (#503 S2 M5, the sixth site of the D4 class).
|
|
1530
|
+
|
|
1531
|
+
The absent-`start_at` fallback does NOT reach for `label`. That field
|
|
1532
|
+
is only ever the yearless chip, so falling back to it re-introduced
|
|
1533
|
+
the exact string the fix removed — and neither D4 tripwire could see
|
|
1534
|
+
it, because one scans the six share-builder modules (which do not
|
|
1535
|
+
include `_cctally_dashboard_sources.py`, where the chip is formatted)
|
|
1536
|
+
and the other scans committed goldens (no golden covers this panel).
|
|
1537
|
+
A row with no parseable start falls back to its own `resets_at`,
|
|
1538
|
+
stated as a full ISO instant and labelled as a reset so the column is
|
|
1539
|
+
not read as a start. `(unknown)` discarded information the row still
|
|
1540
|
+
carried and left the block unidentifiable in the artifact; it remains
|
|
1541
|
+
only for a row that carries neither field (#503 S2 second review N4,
|
|
1542
|
+
third review).
|
|
1543
|
+
"""
|
|
1544
|
+
for field, prefix in (("start_at", ""), ("resets_at", "resets ")):
|
|
1545
|
+
rendered = _codex_block_instant(ls, row, field)
|
|
1546
|
+
if rendered is not None:
|
|
1547
|
+
return prefix + rendered
|
|
1548
|
+
return _CODEX_BLOCK_LABEL_UNKNOWN
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
def _codex_block_instant(ls, row, field: str) -> "str | None":
|
|
1552
|
+
"""One of the row's instants as `…Z`, or None when it has none."""
|
|
1553
|
+
raw = row.get(field)
|
|
1554
|
+
if raw:
|
|
1555
|
+
try:
|
|
1556
|
+
parsed = parse_iso_datetime(str(raw), f"codex.block.{field}")
|
|
1557
|
+
except ValueError:
|
|
1558
|
+
pass
|
|
1559
|
+
else:
|
|
1560
|
+
# NORMALIZED to UTC before formatting. `parse_iso_datetime`
|
|
1561
|
+
# ends with a bare `astimezone()`, so it hands back a
|
|
1562
|
+
# host-local datetime, and `_format_generated_at_iso` keeps
|
|
1563
|
+
# whatever offset it is given — the cell would otherwise read
|
|
1564
|
+
# `+03:00` on one machine and `Z` on another, where every
|
|
1565
|
+
# other blocks artifact states `…Z`.
|
|
1566
|
+
return ls._format_generated_at_iso(
|
|
1567
|
+
parsed.astimezone(dt.timezone.utc))
|
|
1568
|
+
return None
|
|
1569
|
+
|
|
1570
|
+
|
|
1467
1571
|
def _build_codex_source_share_snapshot(ls, *, state, panel: str,
|
|
1468
1572
|
template_id: str, options: dict):
|
|
1469
1573
|
"""Adapt S4 normalized data through canonical Codex share kernels."""
|
|
@@ -1494,7 +1598,13 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
|
|
|
1494
1598
|
all_rows = tuple(weekly.get("rows", ()))
|
|
1495
1599
|
source_rows = all_rows[-1:] if all_rows else ()
|
|
1496
1600
|
command = "codex-weekly"
|
|
1497
|
-
|
|
1601
|
+
# RESOLVED, never passed through (#503 S2 review F9). The panel
|
|
1602
|
+
# value is a display label, and `_lib_view_models._display_tz_label`
|
|
1603
|
+
# returns the literal `local` for a `None` zone — so a caller that
|
|
1604
|
+
# omits `options["display_tz"]` would put `(local)` back into an
|
|
1605
|
+
# artifact, which D7 exists to prevent.
|
|
1606
|
+
display_tz = _share_resolved_display_tz(
|
|
1607
|
+
weekly.get("display_tz") or options.get("display_tz"))
|
|
1498
1608
|
elif panel in ("daily", "monthly", "weekly", "trend"):
|
|
1499
1609
|
periods = data.get("periods")
|
|
1500
1610
|
period_key = "weekly" if panel == "trend" else panel
|
|
@@ -1503,7 +1613,9 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
|
|
|
1503
1613
|
raise ValueError("source capability unavailable")
|
|
1504
1614
|
source_rows = tuple(panel_data.get("rows", ()))
|
|
1505
1615
|
command = f"codex-{period_key}"
|
|
1506
|
-
|
|
1616
|
+
# Resolved, never passed through — see the `current-week` branch.
|
|
1617
|
+
display_tz = _share_resolved_display_tz(
|
|
1618
|
+
panel_data.get("display_tz") or options.get("display_tz"))
|
|
1507
1619
|
elif panel == "forecast":
|
|
1508
1620
|
quota = data.get("quota")
|
|
1509
1621
|
panel_data = quota if isinstance(quota, Mapping) else {}
|
|
@@ -1534,7 +1646,10 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
|
|
|
1534
1646
|
}))
|
|
1535
1647
|
return ls.ShareSnapshot(
|
|
1536
1648
|
cmd="codex-quota", title="Codex Quota Forecast", subtitle=None,
|
|
1537
|
-
period=ls.PeriodSpec(
|
|
1649
|
+
period=ls.PeriodSpec(
|
|
1650
|
+
start=start, end=end, label=None,
|
|
1651
|
+
display_tz=_share_resolved_display_tz(options.get("display_tz")),
|
|
1652
|
+
),
|
|
1538
1653
|
columns=(
|
|
1539
1654
|
ls.ColumnSpec(key="limit", label="Limit"),
|
|
1540
1655
|
ls.ColumnSpec(key="current", label="Current", align="right"),
|
|
@@ -1549,7 +1664,7 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
|
|
|
1549
1664
|
panel_data = data.get(panel) if isinstance(data.get(panel), Mapping) else {}
|
|
1550
1665
|
source_rows = tuple(panel_data.get("rows", ())) if isinstance(panel_data, Mapping) else ()
|
|
1551
1666
|
command = "codex-session"
|
|
1552
|
-
display_tz = "
|
|
1667
|
+
display_tz = _share_resolved_display_tz(options.get("display_tz"))
|
|
1553
1668
|
elif panel == "projects":
|
|
1554
1669
|
panel_data = data.get("projects") if isinstance(data.get("projects"), Mapping) else {}
|
|
1555
1670
|
source_rows = tuple(panel_data.get("rows", ())) if isinstance(panel_data, Mapping) else ()
|
|
@@ -1567,7 +1682,10 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
|
|
|
1567
1682
|
}) for row in source_rows if isinstance(row, Mapping))
|
|
1568
1683
|
return ls.ShareSnapshot(
|
|
1569
1684
|
cmd="project", title="Codex Project Usage", subtitle=None,
|
|
1570
|
-
period=ls.PeriodSpec(
|
|
1685
|
+
period=ls.PeriodSpec(
|
|
1686
|
+
start=start, end=end, label=None,
|
|
1687
|
+
display_tz=_share_resolved_display_tz(options.get("display_tz")),
|
|
1688
|
+
),
|
|
1571
1689
|
columns=(
|
|
1572
1690
|
ls.ColumnSpec(key="project", label="Project"),
|
|
1573
1691
|
ls.ColumnSpec(key="tokens", label="Tokens", align="right"),
|
|
@@ -1579,7 +1697,15 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
|
|
|
1579
1697
|
template_id=template_id, source="codex", source_label="Codex",
|
|
1580
1698
|
availability=availability, availability_reason=reason,
|
|
1581
1699
|
)
|
|
1582
|
-
|
|
1700
|
+
elif panel == "blocks":
|
|
1701
|
+
# NAMED rather than left as the chain's `else`. The three
|
|
1702
|
+
# `PeriodSpec` sites in this function are addressed BY ORDINAL
|
|
1703
|
+
# from the test suite, and an unnamed branch cannot be attributed
|
|
1704
|
+
# to a panel from the source, so the driver's ordinal-to-panel
|
|
1705
|
+
# mapping was unassertable (#503 S2 third review). Naming it also
|
|
1706
|
+
# turns an unrecognised panel into an error rather than silently
|
|
1707
|
+
# rendering it as a blocks artifact; `required_domain` above
|
|
1708
|
+
# already rejects every panel this chain does not list.
|
|
1583
1709
|
quota = data.get("quota")
|
|
1584
1710
|
panel_data = quota if isinstance(quota, Mapping) else {}
|
|
1585
1711
|
source_rows = tuple(panel_data.get("blocks", ())) if isinstance(panel_data, Mapping) else ()
|
|
@@ -1594,23 +1720,29 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
|
|
|
1594
1720
|
def cells(row):
|
|
1595
1721
|
percent = row.get("current_percent", 0.0)
|
|
1596
1722
|
return {
|
|
1597
|
-
"label": ls.TextCell(
|
|
1723
|
+
"label": ls.TextCell(_codex_block_label(ls, row)),
|
|
1598
1724
|
"usage": ls.TextCell(f"{float(percent or 0.0):.1f}%"),
|
|
1599
1725
|
"resets": ls.TextCell(str(row.get("resets_at", "—"))),
|
|
1600
1726
|
}
|
|
1601
1727
|
rows = tuple(ls.Row(cells=cells(row)) for row in source_rows if isinstance(row, Mapping))
|
|
1602
1728
|
return ls.ShareSnapshot(
|
|
1603
1729
|
cmd="codex-quota", title="Codex Quota Windows", subtitle=None,
|
|
1604
|
-
period=ls.PeriodSpec(
|
|
1730
|
+
period=ls.PeriodSpec(
|
|
1731
|
+
start=start, end=end, label=None,
|
|
1732
|
+
display_tz=_share_resolved_display_tz(options.get("display_tz")),
|
|
1733
|
+
),
|
|
1605
1734
|
columns=columns, rows=rows, chart=None,
|
|
1606
1735
|
totals=(), notes=(), generated_at=end,
|
|
1607
1736
|
version=sys.modules["cctally"]._share_resolve_version(),
|
|
1608
1737
|
template_id=template_id, source="codex", source_label="Codex",
|
|
1609
1738
|
availability=availability, availability_reason=reason,
|
|
1610
1739
|
)
|
|
1740
|
+
else:
|
|
1741
|
+
raise ValueError(f"unsupported codex source panel: {panel}")
|
|
1611
1742
|
|
|
1612
1743
|
start, end = _share_codex_period_bounds(
|
|
1613
1744
|
state=state, panel=panel, options=options, rows=source_rows,
|
|
1745
|
+
display_tz=display_tz,
|
|
1614
1746
|
)
|
|
1615
1747
|
normalized_rows = tuple(
|
|
1616
1748
|
SimpleNamespace(
|
|
@@ -1637,27 +1769,6 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
|
|
|
1637
1769
|
)
|
|
1638
1770
|
|
|
1639
1771
|
|
|
1640
|
-
def _share_plain_value(value):
|
|
1641
|
-
if isinstance(value, Mapping):
|
|
1642
|
-
return {str(key): _share_plain_value(item) for key, item in value.items()}
|
|
1643
|
-
if isinstance(value, (tuple, list)):
|
|
1644
|
-
return [_share_plain_value(item) for item in value]
|
|
1645
|
-
if isinstance(value, dt.datetime):
|
|
1646
|
-
return value.astimezone(dt.timezone.utc).isoformat()
|
|
1647
|
-
return value
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
def _share_state_domain(state, panel: str):
|
|
1651
|
-
data = state.data if isinstance(state.data, Mapping) else {}
|
|
1652
|
-
if panel in ("daily", "monthly", "weekly", "current-week"):
|
|
1653
|
-
periods = data.get("periods") if isinstance(data.get("periods"), Mapping) else {}
|
|
1654
|
-
key = "weekly" if panel == "current-week" else panel
|
|
1655
|
-
return periods.get(key)
|
|
1656
|
-
if panel == "blocks":
|
|
1657
|
-
return data.get("quota")
|
|
1658
|
-
return data.get(panel)
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
1772
|
def _share_apply_current_week_freshness(snapshot, state, panel: str):
|
|
1662
1773
|
"""Qualify retained current-week actuals with provider-local evidence age."""
|
|
1663
1774
|
if (
|
|
@@ -1672,46 +1783,124 @@ def _share_apply_current_week_freshness(snapshot, state, panel: str):
|
|
|
1672
1783
|
return replace(snapshot, notes=tuple(snapshot.notes) + (note,))
|
|
1673
1784
|
|
|
1674
1785
|
|
|
1786
|
+
# The meaning of `data_digest`, as a stored value (#503 S3 §4).
|
|
1787
|
+
#
|
|
1788
|
+
# `data_digest_at_add` lives in localStorage basket items and is replayed at
|
|
1789
|
+
# compose time, so redefining what the digest hashes would mark every stored
|
|
1790
|
+
# section outdated exactly once. Version 2 is that redefinition: two digests
|
|
1791
|
+
# are compared ONLY when the stored version equals this one. A missing or
|
|
1792
|
+
# older version is NOT COMPARABLE, which is not drifted — no badge, no third
|
|
1793
|
+
# badge state. `KERNEL_VERSION` is untouched: it versions the RENDERER
|
|
1794
|
+
# contract (`_lib_share.py`), which has not changed.
|
|
1795
|
+
_SHARE_DATA_DIGEST_VERSION = 2
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
def _share_digest_value(ls, value):
|
|
1799
|
+
"""Structurally project one value for `_data_digest` (#503 S3 §4).
|
|
1800
|
+
|
|
1801
|
+
`_data_digest` serializes with `default=str`, and its own contract warns
|
|
1802
|
+
that arbitrary objects then hash as a per-process-unstable `repr`. The
|
|
1803
|
+
projected snapshot fields are nested frozen dataclasses — cells, `Row`,
|
|
1804
|
+
`ColumnSpec`, the chart union, chart points — so handing them over
|
|
1805
|
+
directly is exactly that hazard. This converts them instead:
|
|
1806
|
+
|
|
1807
|
+
- a `PeriodSpec` becomes its CIVIL dates plus zone and label, never its
|
|
1808
|
+
raw `start`/`end` instants (`period_civil_dates` already honours S2's
|
|
1809
|
+
`civil_bucket` discriminator, so a `daily` bucket is not shifted a day
|
|
1810
|
+
west of UTC);
|
|
1811
|
+
- any other dataclass becomes a mapping of its declared fields, carrying
|
|
1812
|
+
`__type__` so the cell and chart unions stay discriminated (a
|
|
1813
|
+
`TextCell("5")` must not hash as a `DateCell("5")`);
|
|
1814
|
+
- mappings recurse structurally and tuples/lists preserve order;
|
|
1815
|
+
- a `datetime` outside a `PeriodSpec` becomes a normalized UTC ISO string;
|
|
1816
|
+
- anything else RAISES, so the callers' empty-digest fallback stays
|
|
1817
|
+
defensive rather than silently hashing a `repr`.
|
|
1818
|
+
"""
|
|
1819
|
+
if value is None or isinstance(value, (str, bool, int, float)):
|
|
1820
|
+
return value
|
|
1821
|
+
if isinstance(value, ls.PeriodSpec):
|
|
1822
|
+
start_civil, end_civil = ls.period_civil_dates(value)
|
|
1823
|
+
return {
|
|
1824
|
+
"__type__": "PeriodSpec",
|
|
1825
|
+
"civil": [start_civil, end_civil],
|
|
1826
|
+
"display_tz": value.display_tz,
|
|
1827
|
+
"label": value.label,
|
|
1828
|
+
}
|
|
1829
|
+
if _dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
1830
|
+
projected = {"__type__": type(value).__name__}
|
|
1831
|
+
for field in _dataclasses.fields(value):
|
|
1832
|
+
projected[field.name] = _share_digest_value(
|
|
1833
|
+
ls, getattr(value, field.name))
|
|
1834
|
+
return projected
|
|
1835
|
+
if isinstance(value, Mapping):
|
|
1836
|
+
return {str(key): _share_digest_value(ls, item)
|
|
1837
|
+
for key, item in value.items()}
|
|
1838
|
+
if isinstance(value, (tuple, list)):
|
|
1839
|
+
return [_share_digest_value(ls, item) for item in value]
|
|
1840
|
+
if isinstance(value, dt.datetime):
|
|
1841
|
+
return value.astimezone(dt.timezone.utc).isoformat()
|
|
1842
|
+
raise TypeError(
|
|
1843
|
+
f"share digest cannot serialize {type(value).__name__}")
|
|
1844
|
+
|
|
1845
|
+
|
|
1846
|
+
def _share_snapshot_digest_projection(ls, snapshot):
|
|
1847
|
+
"""The canonical projection of ONE built, pre-toggle `ShareSnapshot`.
|
|
1848
|
+
|
|
1849
|
+
Two snapshot fields are excluded BY NAME. `generated_at` is a wall clock,
|
|
1850
|
+
and hashing a wall clock is what made every section drift with elapsed
|
|
1851
|
+
time. `version` is a renderer concern `KERNEL_VERSION` already covers.
|
|
1852
|
+
Nothing from the ambient process state enters — no `data_version`, no raw
|
|
1853
|
+
provider domain, no clock.
|
|
1854
|
+
"""
|
|
1855
|
+
return {
|
|
1856
|
+
"title": snapshot.title,
|
|
1857
|
+
"subtitle": snapshot.subtitle,
|
|
1858
|
+
"period": _share_digest_value(ls, snapshot.period),
|
|
1859
|
+
"columns": _share_digest_value(ls, snapshot.columns),
|
|
1860
|
+
"rows": _share_digest_value(ls, snapshot.rows),
|
|
1861
|
+
"chart": _share_digest_value(ls, snapshot.chart),
|
|
1862
|
+
"totals": _share_digest_value(ls, snapshot.totals),
|
|
1863
|
+
"notes": _share_digest_value(ls, snapshot.notes),
|
|
1864
|
+
"template_id": snapshot.template_id,
|
|
1865
|
+
"source": snapshot.source,
|
|
1866
|
+
"source_label": snapshot.source_label,
|
|
1867
|
+
"availability": snapshot.availability,
|
|
1868
|
+
"availability_reason": snapshot.availability_reason,
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
|
|
1675
1872
|
def _share_digest_input(*, panel: str, template_id: str, source: str,
|
|
1676
|
-
|
|
1677
|
-
|
|
1873
|
+
snapshots, account: "str | None" = None, ls=None):
|
|
1874
|
+
"""The digest payload: a projection of the built snapshots, and nothing else.
|
|
1875
|
+
|
|
1876
|
+
`snapshots` MUST be the PRE-toggle tuple. `_share_apply_content_toggles`
|
|
1877
|
+
strips `chart` and `columns`/`rows` per `show_chart`/`show_table`, and a
|
|
1878
|
+
Codex `blocks` section carries its window identity ONLY in its rows — its
|
|
1879
|
+
title and period label are constants — so hashing the toggled tuple
|
|
1880
|
+
collapses two different five-hour windows on one civil day into one
|
|
1881
|
+
digest. Hashing pre-toggle also makes both toggles genuinely render-only,
|
|
1882
|
+
which is what the render handler's comment has always promised.
|
|
1883
|
+
|
|
1884
|
+
One definition for every request: version 1 forked on `source_explicit`
|
|
1885
|
+
and kept a separate legacy payload, which left that branch carrying the
|
|
1886
|
+
defect this rewrite removes. `_SHARE_DATA_DIGEST_VERSION` is how a stored
|
|
1887
|
+
digest's meaning moves; see `docs/share-gotchas.md`.
|
|
1888
|
+
"""
|
|
1889
|
+
ls = ls if ls is not None else _share_load_lib()
|
|
1678
1890
|
# #341 Task 4: the captured account participates in the digest so switching
|
|
1679
1891
|
# the focused account registers as data drift in the composer (spec §4).
|
|
1680
|
-
#
|
|
1892
|
+
# It stays TOP-LEVEL rather than being read off the snapshots: an
|
|
1893
|
+
# account-focus change must change identity even when two scoped snapshots
|
|
1894
|
+
# happen to hold equal values. Absent → omitted.
|
|
1681
1895
|
account_key = {"account": account} if account is not None else {}
|
|
1682
|
-
if not source_explicit:
|
|
1683
|
-
return {
|
|
1684
|
-
"panel": panel,
|
|
1685
|
-
"template_id": template_id,
|
|
1686
|
-
"panel_data": panel_data,
|
|
1687
|
-
**account_key,
|
|
1688
|
-
}
|
|
1689
|
-
providers = []
|
|
1690
|
-
for state, snapshot in zip(states, snapshots):
|
|
1691
|
-
providers.append({
|
|
1692
|
-
"source": state.source,
|
|
1693
|
-
"data_version": state.data_version,
|
|
1694
|
-
"availability": state.availability,
|
|
1695
|
-
**(
|
|
1696
|
-
{"hero_freshness": source_domain_freshness(state, "hero")}
|
|
1697
|
-
if panel == "current-week" else {}
|
|
1698
|
-
),
|
|
1699
|
-
"period": {
|
|
1700
|
-
"start": snapshot.period.start,
|
|
1701
|
-
"end": snapshot.period.end,
|
|
1702
|
-
"display_tz": snapshot.period.display_tz,
|
|
1703
|
-
},
|
|
1704
|
-
"data": _share_plain_value(_share_state_domain(state, panel)),
|
|
1705
|
-
})
|
|
1706
1896
|
return {
|
|
1707
1897
|
"panel": panel,
|
|
1708
1898
|
"template_id": template_id,
|
|
1709
1899
|
"source": source,
|
|
1710
|
-
"
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
),
|
|
1900
|
+
"snapshots": [
|
|
1901
|
+
_share_snapshot_digest_projection(ls, snapshot)
|
|
1902
|
+
for snapshot in snapshots
|
|
1903
|
+
],
|
|
1715
1904
|
**account_key,
|
|
1716
1905
|
}
|
|
1717
1906
|
|
|
@@ -1953,8 +2142,14 @@ def _handle_share_render_post_impl(handler) -> None:
|
|
|
1953
2142
|
# truth. Inject before `_share_apply_period_override` so the
|
|
1954
2143
|
# daily panel rebuild and per-day cross-tab bucketing both see
|
|
1955
2144
|
# the user's display tz instead of falling back to UTC.
|
|
1956
|
-
|
|
1957
|
-
|
|
2145
|
+
#
|
|
2146
|
+
# RESOLVED here, once (#503 S2 D7). `get_display_tz_pref` returns a
|
|
2147
|
+
# configuration TOKEN whose default is the literal `local`, and that
|
|
2148
|
+
# token used to travel all the way into `PeriodSpec.display_tz`, so
|
|
2149
|
+
# the artifact stated a zone that names no zone. The resolution is
|
|
2150
|
+
# unconditional rather than gated on the key being absent, because a
|
|
2151
|
+
# caller-supplied token is a token too.
|
|
2152
|
+
options["display_tz"] = _share_resolved_display_tz(options.get("display_tz"))
|
|
1958
2153
|
if not isinstance(panel, str) or not panel:
|
|
1959
2154
|
handler._respond_json(400, {
|
|
1960
2155
|
"error": "missing or non-string panel",
|
|
@@ -2023,7 +2218,7 @@ def _handle_share_render_post_impl(handler) -> None:
|
|
|
2023
2218
|
data_snap = snap_ref.get() if snap_ref is not None else None
|
|
2024
2219
|
ls = _share_load_lib()
|
|
2025
2220
|
try:
|
|
2026
|
-
source_snaps,
|
|
2221
|
+
source_snaps, _source_states, _panel_data = _share_build_source_snapshots(
|
|
2027
2222
|
ls=ls,
|
|
2028
2223
|
template=template,
|
|
2029
2224
|
template_id=template_id,
|
|
@@ -2045,6 +2240,11 @@ def _handle_share_render_post_impl(handler) -> None:
|
|
|
2045
2240
|
except Exception as exc:
|
|
2046
2241
|
_share_public_failure(handler, exc, phase="render provider")
|
|
2047
2242
|
return
|
|
2243
|
+
# TWO tuples, and the order is the whole point (#503 S3 §4). The digest
|
|
2244
|
+
# hashes what the BUILDERS produced; `render()` receives the toggled
|
|
2245
|
+
# versions. Reassigning `source_snaps` first — which is what this site
|
|
2246
|
+
# did — hands the digest a snapshot whose rows a render knob erased.
|
|
2247
|
+
digest_snaps = source_snaps
|
|
2048
2248
|
source_snaps = tuple(
|
|
2049
2249
|
_share_apply_content_toggles(item, options) for item in source_snaps
|
|
2050
2250
|
)
|
|
@@ -2089,24 +2289,27 @@ def _handle_share_render_post_impl(handler) -> None:
|
|
|
2089
2289
|
"svg": "image/svg+xml",
|
|
2090
2290
|
}[fmt]
|
|
2091
2291
|
|
|
2092
|
-
# data_digest hashes
|
|
2093
|
-
#
|
|
2094
|
-
# / branding / reveal_projects / format
|
|
2095
|
-
#
|
|
2096
|
-
#
|
|
2097
|
-
#
|
|
2098
|
-
|
|
2099
|
-
panel=panel,
|
|
2100
|
-
template_id=template_id,
|
|
2101
|
-
source=source,
|
|
2102
|
-
source_explicit=source_explicit,
|
|
2103
|
-
states=source_states,
|
|
2104
|
-
snapshots=source_snaps,
|
|
2105
|
-
panel_data=panel_data,
|
|
2106
|
-
account=account,
|
|
2107
|
-
)
|
|
2292
|
+
# data_digest hashes a canonical projection of the BUILT, PRE-TOGGLE
|
|
2293
|
+
# snapshots — what the artifact is made of — and nothing else. NOT the
|
|
2294
|
+
# rendering toggles (theme / branding / reveal_projects / format /
|
|
2295
|
+
# show_chart / show_table), and NOT the wall clock or the raw provider
|
|
2296
|
+
# state. Used by the composer to detect "section data has drifted since
|
|
2297
|
+
# add-time" (spec §5.2 / §7.1); flipping anon-on-export must not register
|
|
2298
|
+
# as drift, since the underlying data is identical.
|
|
2108
2299
|
try:
|
|
2109
|
-
|
|
2300
|
+
# The projection is INSIDE the guard, matching the compose site.
|
|
2301
|
+
# `_share_digest_value` raises on a value it cannot serialize, and
|
|
2302
|
+
# `do_POST` has no exception guard of its own, so building the input
|
|
2303
|
+
# outside this `try` turned a projection failure into a dropped
|
|
2304
|
+
# connection instead of the empty digest the fallback promises.
|
|
2305
|
+
data_digest = ls._data_digest(_share_digest_input(
|
|
2306
|
+
panel=panel,
|
|
2307
|
+
template_id=template_id,
|
|
2308
|
+
source=source,
|
|
2309
|
+
snapshots=digest_snaps,
|
|
2310
|
+
account=account,
|
|
2311
|
+
ls=ls,
|
|
2312
|
+
))
|
|
2110
2313
|
except Exception:
|
|
2111
2314
|
# Defensive: digest is non-blocking for the response — fall
|
|
2112
2315
|
# back to an empty string and let the composer treat it as
|
|
@@ -2155,6 +2358,7 @@ def _handle_share_render_post_impl(handler) -> None:
|
|
|
2155
2358
|
"options": options,
|
|
2156
2359
|
"generated_at": _share_now_utc_iso(),
|
|
2157
2360
|
"data_digest": data_digest,
|
|
2361
|
+
"data_digest_version": _SHARE_DATA_DIGEST_VERSION,
|
|
2158
2362
|
**({"source": source} if source_explicit else {}),
|
|
2159
2363
|
**account_meta,
|
|
2160
2364
|
},
|
|
@@ -2226,8 +2430,9 @@ def _handle_share_compose_post_impl(handler) -> None:
|
|
|
2226
2430
|
# Resolve display_tz from config once (client `ShareOptions`
|
|
2227
2431
|
# does not carry it); applied to every section's options below
|
|
2228
2432
|
# so daily panel rebuilds and per-day cross-tab cells bucket in
|
|
2229
|
-
# the user's display tz, not UTC.
|
|
2230
|
-
|
|
2433
|
+
# the user's display tz, not UTC. Resolved to a CONCRETE IANA zone
|
|
2434
|
+
# (#503 S2 D7) so no section states the token `local` as its zone.
|
|
2435
|
+
composite_display_tz = _share_resolved_display_tz()
|
|
2231
2436
|
|
|
2232
2437
|
composed_sections: list = []
|
|
2233
2438
|
section_results: list[dict] = []
|
|
@@ -2297,9 +2502,15 @@ def _handle_share_compose_post_impl(handler) -> None:
|
|
|
2297
2502
|
composite_opts = {**sec_opts, "reveal_projects": reveal_projects,
|
|
2298
2503
|
"theme": theme, "format": fmt,
|
|
2299
2504
|
"no_branding": no_branding}
|
|
2300
|
-
|
|
2505
|
+
# Not `setdefault`: a section that arrived carrying its own
|
|
2506
|
+
# `display_tz` is carrying a TOKEN, which must be resolved too.
|
|
2507
|
+
composite_opts["display_tz"] = (
|
|
2508
|
+
_share_resolved_display_tz(sec_opts["display_tz"])
|
|
2509
|
+
if isinstance(sec_opts.get("display_tz"), str) and sec_opts["display_tz"]
|
|
2510
|
+
else composite_display_tz
|
|
2511
|
+
)
|
|
2301
2512
|
try:
|
|
2302
|
-
source_snaps,
|
|
2513
|
+
source_snaps, _source_states, _panel_data = _share_build_source_snapshots(
|
|
2303
2514
|
ls=ls,
|
|
2304
2515
|
template=template,
|
|
2305
2516
|
template_id=template_id,
|
|
@@ -2326,10 +2537,13 @@ def _handle_share_compose_post_impl(handler) -> None:
|
|
|
2326
2537
|
handler, exc, phase=f"compose section {idx} provider",
|
|
2327
2538
|
)
|
|
2328
2539
|
return
|
|
2329
|
-
# Same content toggles as the single-section render path
|
|
2540
|
+
# Same content toggles as the single-section render path, and the
|
|
2541
|
+
# same two-tuple ordering (#503 S3 §4): `digest_snaps` is what the
|
|
2542
|
+
# builders produced, `source_snaps` is what `compose()` renders.
|
|
2330
2543
|
# Per-section `show_chart`/`show_table` from the basket
|
|
2331
2544
|
# recipe are applied here; the composite anon flag is
|
|
2332
2545
|
# already merged into composite_opts upstream.
|
|
2546
|
+
digest_snaps = source_snaps
|
|
2333
2547
|
source_snaps = tuple(
|
|
2334
2548
|
_share_apply_content_toggles(item, composite_opts)
|
|
2335
2549
|
for item in source_snaps
|
|
@@ -2338,10 +2552,14 @@ def _handle_share_compose_post_impl(handler) -> None:
|
|
|
2338
2552
|
# under one merged alias namespace. Scrubbing per section here is what
|
|
2339
2553
|
# made `project-1` denote a different project in each section.
|
|
2340
2554
|
#
|
|
2341
|
-
# The digest below is unaffected by the removal
|
|
2342
|
-
#
|
|
2343
|
-
#
|
|
2344
|
-
# "
|
|
2555
|
+
# The digest below is unaffected by the removal, and the reason has
|
|
2556
|
+
# changed with version 2. `_share_digest_input` now reads `title`,
|
|
2557
|
+
# `subtitle`, `columns`, `rows`, `chart`, `totals` and `notes` off each
|
|
2558
|
+
# snapshot, labels included — so the claim can no longer rest on "it
|
|
2559
|
+
# never reads a label". It rests on ORDER instead: the digest hashes
|
|
2560
|
+
# `digest_snaps`, the snapshots the builders produced, and every
|
|
2561
|
+
# anonymization happens later inside `compose()`. Nothing scrubbed can
|
|
2562
|
+
# reach the digest, so no basket section spuriously reads "Outdated".
|
|
2345
2563
|
|
|
2346
2564
|
# Defensive: digest is non-blocking metadata — fall back to
|
|
2347
2565
|
# "" on failure rather than 500-ing the whole compose
|
|
@@ -2351,29 +2569,42 @@ def _handle_share_compose_post_impl(handler) -> None:
|
|
|
2351
2569
|
panel=panel,
|
|
2352
2570
|
template_id=template_id,
|
|
2353
2571
|
source=source,
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
snapshots=source_snaps,
|
|
2357
|
-
panel_data=panel_data,
|
|
2572
|
+
snapshots=digest_snaps,
|
|
2573
|
+
ls=ls,
|
|
2358
2574
|
# #341 Task 4: carry the section's captured account so a
|
|
2359
2575
|
# focus-changed section re-digests as drift (matching render).
|
|
2360
|
-
account=
|
|
2576
|
+
account=section_account,
|
|
2361
2577
|
))
|
|
2362
2578
|
except Exception:
|
|
2363
2579
|
digest_now = ""
|
|
2580
|
+
# Two digests are comparable only when they mean the same thing
|
|
2581
|
+
# (#503 S3 §4). A section stored before `_SHARE_DATA_DIGEST_VERSION`
|
|
2582
|
+
# existed, or under an older one, is NOT COMPARABLE — and not
|
|
2583
|
+
# comparable is not drifted, so it carries no badge rather than a
|
|
2584
|
+
# spurious "Outdated" the user cannot clear. An absent field is the
|
|
2585
|
+
# legacy case and reads as not comparable, which is the fail-safe
|
|
2586
|
+
# direction: it under-reports drift once instead of over-reporting it
|
|
2587
|
+
# for every stored section.
|
|
2588
|
+
digest_comparable = (
|
|
2589
|
+
snap_recipe.get("data_digest_version_at_add")
|
|
2590
|
+
== _SHARE_DATA_DIGEST_VERSION
|
|
2591
|
+
)
|
|
2592
|
+
drift_detected = digest_comparable and digest_now != digest_at_add
|
|
2364
2593
|
composed_sections.extend(
|
|
2365
|
-
ls.ComposedSection(
|
|
2366
|
-
snap=item,
|
|
2367
|
-
drift_detected=(digest_now != digest_at_add),
|
|
2368
|
-
)
|
|
2594
|
+
ls.ComposedSection(snap=item, drift_detected=drift_detected)
|
|
2369
2595
|
for item in source_snaps
|
|
2370
2596
|
)
|
|
2371
2597
|
section_results.append({
|
|
2372
2598
|
"snapshot_id": f"{idx:02d}",
|
|
2373
2599
|
"source": source,
|
|
2374
|
-
"drift_detected":
|
|
2600
|
+
"drift_detected": drift_detected,
|
|
2375
2601
|
"data_digest_at_add": digest_at_add,
|
|
2376
2602
|
"data_digest_now": digest_now,
|
|
2603
|
+
# ADDITIVE (docs/cli-contract.md): a consumer that does not know
|
|
2604
|
+
# this key keeps reading `drift_detected`, which is already
|
|
2605
|
+
# false whenever this is false.
|
|
2606
|
+
"digest_comparable": digest_comparable,
|
|
2607
|
+
"data_digest_version": _SHARE_DATA_DIGEST_VERSION,
|
|
2377
2608
|
})
|
|
2378
2609
|
|
|
2379
2610
|
compose_opts = ls.ComposeOptions(
|
|
@@ -2529,21 +2760,164 @@ def _handle_share_presets_post_impl(handler) -> None:
|
|
|
2529
2760
|
})
|
|
2530
2761
|
return
|
|
2531
2762
|
|
|
2763
|
+
# #503 S3 §1. Absent means false, which is the fail-safe direction and is
|
|
2764
|
+
# what makes this compatible with a caller written before the field
|
|
2765
|
+
# existed: an unwitting save can no longer destroy a stored recipe.
|
|
2766
|
+
overwrite = bool(req.get("overwrite", False))
|
|
2767
|
+
|
|
2532
2768
|
saved_at = _share_now_utc_iso()
|
|
2533
2769
|
record = {
|
|
2534
2770
|
"template_id": template_id, "options": options,
|
|
2535
2771
|
"source": source, "saved_at": saved_at,
|
|
2536
2772
|
}
|
|
2537
2773
|
|
|
2774
|
+
# The OUTCOME is decided under the lock; the RESPONSE is written after it.
|
|
2775
|
+
# `config_writer_lock` is a cross-process `fcntl.flock`, and a client that
|
|
2776
|
+
# reads its socket slowly would otherwise hold it for the length of that
|
|
2777
|
+
# write, blocking every other config writer in every other process.
|
|
2778
|
+
conflict = False
|
|
2538
2779
|
with sys.modules["cctally"].config_writer_lock():
|
|
2539
2780
|
cfg = _load_config_unlocked()
|
|
2540
2781
|
share = cfg.setdefault("share", {})
|
|
2541
2782
|
presets = share.setdefault("presets", {})
|
|
2542
2783
|
panel_bucket = presets.setdefault(panel, {})
|
|
2543
|
-
|
|
2544
|
-
|
|
2784
|
+
# Decided INSIDE the writer lock (spec §1): a client-side name-list
|
|
2785
|
+
# preflight can go stale between its GET and this write, so the
|
|
2786
|
+
# preflight is an optimisation and this is the authority. Nothing is
|
|
2787
|
+
# persisted on this branch — `save_config` is the only writer.
|
|
2788
|
+
if name in panel_bucket and not overwrite:
|
|
2789
|
+
conflict = True
|
|
2790
|
+
else:
|
|
2791
|
+
panel_bucket[name] = record
|
|
2792
|
+
save_config(cfg)
|
|
2793
|
+
if conflict:
|
|
2794
|
+
handler._respond_json(409, _SHARE_PRESET_CONFLICT("name", name))
|
|
2795
|
+
return
|
|
2545
2796
|
handler._respond_json(200, {"panel": panel, "name": name, **record})
|
|
2546
2797
|
|
|
2798
|
+
|
|
2799
|
+
# The one conflict body both preset mutations answer with (spec §1). Stable
|
|
2800
|
+
# machine-readable `code`, the offending field, and a message that names only
|
|
2801
|
+
# the preset the caller already sent.
|
|
2802
|
+
def _SHARE_PRESET_CONFLICT(field: str, name: str) -> dict:
|
|
2803
|
+
return {
|
|
2804
|
+
"code": "preset_name_conflict",
|
|
2805
|
+
"error": f"a preset named {name!r} already exists",
|
|
2806
|
+
"field": field,
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
|
|
2810
|
+
def _share_preset_name_error(field: str) -> dict:
|
|
2811
|
+
return {
|
|
2812
|
+
"error": "name must be 1-64 chars and contain no '/'",
|
|
2813
|
+
"field": field,
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
|
|
2817
|
+
def _handle_share_presets_rename_post_impl(handler) -> None:
|
|
2818
|
+
"""Rename a preset atomically, keeping its identity (#503 S3 §1).
|
|
2819
|
+
|
|
2820
|
+
Body: ``{panel, from_name, to_name, overwrite}``. CSRF-gated.
|
|
2821
|
+
|
|
2822
|
+
Rename was not an operation: the client issued ``savePreset`` then
|
|
2823
|
+
``deletePreset``, rebuilding the record from the four fields it happened
|
|
2824
|
+
to hold. That dropped ``source`` (the server defaults it to ``claude``,
|
|
2825
|
+
so a renamed Codex preset started showing a Claude chip), reset
|
|
2826
|
+
``saved_at`` even though the recipe had not changed, and silently
|
|
2827
|
+
overwrote any preset already holding the target name.
|
|
2828
|
+
|
|
2829
|
+
So this MOVES THE STORED RECORD WHOLE — ``bucket[to] = bucket.pop(from)``
|
|
2830
|
+
— under ONE ``config_writer_lock`` with one ``save_config``. Nothing
|
|
2831
|
+
reconstructs the record, so every field it carries survives, including
|
|
2832
|
+
fields added later.
|
|
2833
|
+
|
|
2834
|
+
A self-rename is rejected explicitly: a move-then-delete on one key
|
|
2835
|
+
deletes the record, and the client-side guard is not sufficient because
|
|
2836
|
+
this endpoint is independently reachable.
|
|
2837
|
+
|
|
2838
|
+
Write discipline is the presets POST's: ``config_writer_lock`` +
|
|
2839
|
+
``_load_config_unlocked`` + ``save_config``. Never ``load_config`` inside
|
|
2840
|
+
the lock — ``fcntl.flock`` is per-fd and self-deadlocks.
|
|
2841
|
+
"""
|
|
2842
|
+
if not handler._check_origin_csrf():
|
|
2843
|
+
return
|
|
2844
|
+
try:
|
|
2845
|
+
length = int(handler.headers.get("Content-Length", "0") or "0")
|
|
2846
|
+
except ValueError:
|
|
2847
|
+
length = 0
|
|
2848
|
+
if length > _SHARE_POST_MAX_BYTES:
|
|
2849
|
+
handler._respond_json(400, {"error": "body too large (max 64 KiB)"})
|
|
2850
|
+
return
|
|
2851
|
+
try:
|
|
2852
|
+
raw = handler.rfile.read(length) if length > 0 else b""
|
|
2853
|
+
req = json.loads(raw) if raw else {}
|
|
2854
|
+
except (ValueError, json.JSONDecodeError):
|
|
2855
|
+
handler._respond_json(400, {"error": "malformed json"})
|
|
2856
|
+
return
|
|
2857
|
+
if not isinstance(req, dict):
|
|
2858
|
+
handler._respond_json(400, {"error": "expected JSON object"})
|
|
2859
|
+
return
|
|
2860
|
+
|
|
2861
|
+
panel = req.get("panel")
|
|
2862
|
+
from_name = req.get("from_name")
|
|
2863
|
+
to_name = req.get("to_name")
|
|
2864
|
+
overwrite = bool(req.get("overwrite", False))
|
|
2865
|
+
|
|
2866
|
+
if not isinstance(panel, str) or not panel:
|
|
2867
|
+
handler._respond_json(400, {
|
|
2868
|
+
"error": "missing or non-string panel",
|
|
2869
|
+
"field": "panel",
|
|
2870
|
+
})
|
|
2871
|
+
return
|
|
2872
|
+
tpl_mod = handler._share_load_templates_module()
|
|
2873
|
+
if panel not in tpl_mod.SHARE_CAPABLE_PANELS:
|
|
2874
|
+
handler._respond_json(400, {
|
|
2875
|
+
"error": f"unknown share panel: {panel!r}",
|
|
2876
|
+
"field": "panel",
|
|
2877
|
+
})
|
|
2878
|
+
return
|
|
2879
|
+
for value, field in ((from_name, "from_name"), (to_name, "to_name")):
|
|
2880
|
+
if (not isinstance(value, str) or not value
|
|
2881
|
+
or "/" in value or len(value) > 64):
|
|
2882
|
+
handler._respond_json(400, _share_preset_name_error(field))
|
|
2883
|
+
return
|
|
2884
|
+
if from_name == to_name:
|
|
2885
|
+
handler._respond_json(400, {
|
|
2886
|
+
"error": "from_name and to_name are the same preset",
|
|
2887
|
+
"field": "to_name",
|
|
2888
|
+
})
|
|
2889
|
+
return
|
|
2890
|
+
|
|
2891
|
+
# Outcome decided under the lock, response written after it — the same
|
|
2892
|
+
# rule the save handler above states, for the same reason.
|
|
2893
|
+
outcome = "ok"
|
|
2894
|
+
record = None
|
|
2895
|
+
with sys.modules["cctally"].config_writer_lock():
|
|
2896
|
+
cfg = _load_config_unlocked()
|
|
2897
|
+
share = cfg.get("share") or {}
|
|
2898
|
+
presets = share.get("presets") or {}
|
|
2899
|
+
panel_bucket = presets.get(panel) or {}
|
|
2900
|
+
if from_name not in panel_bucket:
|
|
2901
|
+
outcome = "missing"
|
|
2902
|
+
# The target is checked under the SAME lock that performs the move,
|
|
2903
|
+
# so two renames racing onto one name cannot both win.
|
|
2904
|
+
elif to_name in panel_bucket and not overwrite:
|
|
2905
|
+
outcome = "conflict"
|
|
2906
|
+
else:
|
|
2907
|
+
record = panel_bucket.pop(from_name)
|
|
2908
|
+
panel_bucket[to_name] = record
|
|
2909
|
+
save_config(cfg)
|
|
2910
|
+
if outcome == "missing":
|
|
2911
|
+
handler._respond_json(404, {"error": "no such preset"})
|
|
2912
|
+
return
|
|
2913
|
+
if outcome == "conflict":
|
|
2914
|
+
handler._respond_json(409, _SHARE_PRESET_CONFLICT("to_name", to_name))
|
|
2915
|
+
return
|
|
2916
|
+
handler._respond_json(200, {
|
|
2917
|
+
"panel": panel, "name": to_name,
|
|
2918
|
+
**(record if isinstance(record, dict) else {"record": record}),
|
|
2919
|
+
})
|
|
2920
|
+
|
|
2547
2921
|
def _handle_share_presets_delete_impl(handler) -> None:
|
|
2548
2922
|
"""Remove a preset by `(panel, name)`.
|
|
2549
2923
|
|
|
@@ -2570,19 +2944,25 @@ def _handle_share_presets_delete_impl(handler) -> None:
|
|
|
2570
2944
|
return
|
|
2571
2945
|
panel = _urlparse.unquote(parts[4])
|
|
2572
2946
|
name = _urlparse.unquote(parts[5])
|
|
2947
|
+
# Outcome decided under the lock, response written after it — the same
|
|
2948
|
+
# rule the save and rename handlers state, for the same reason.
|
|
2949
|
+
missing = False
|
|
2573
2950
|
with sys.modules["cctally"].config_writer_lock():
|
|
2574
2951
|
cfg = _load_config_unlocked()
|
|
2575
2952
|
share = cfg.get("share") or {}
|
|
2576
2953
|
presets = share.get("presets") or {}
|
|
2577
2954
|
panel_bucket = presets.get(panel) or {}
|
|
2578
2955
|
if name not in panel_bucket:
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2956
|
+
missing = True
|
|
2957
|
+
else:
|
|
2958
|
+
del panel_bucket[name]
|
|
2959
|
+
# Tidy empty buckets so GET stays clean.
|
|
2960
|
+
if not panel_bucket:
|
|
2961
|
+
presets.pop(panel, None)
|
|
2962
|
+
save_config(cfg)
|
|
2963
|
+
if missing:
|
|
2964
|
+
handler._respond_json(404, {"error": "no such preset"})
|
|
2965
|
+
return
|
|
2586
2966
|
handler.send_response(204)
|
|
2587
2967
|
handler.send_header("Content-Length", "0")
|
|
2588
2968
|
handler.end_headers()
|
|
@@ -2600,14 +2980,148 @@ def _handle_share_presets_delete_impl(handler) -> None:
|
|
|
2600
2980
|
# `_check_origin_csrf`. The frontend posts fire-and-forget after
|
|
2601
2981
|
# every successful export — history failures are non-fatal.
|
|
2602
2982
|
|
|
2983
|
+
# #503 S3 §3 — a history row is a discriminated union on `kind`.
|
|
2984
|
+
#
|
|
2985
|
+
# `"panel"` is every field the row has always carried. `"composed"` is one
|
|
2986
|
+
# multi-section export: `panel: null`, a bounded `sections[]`, and the
|
|
2987
|
+
# composite knobs. There is NO migration and none is needed: a missing `kind`
|
|
2988
|
+
# READS as `"panel"` (normalized in the GET response only, never written
|
|
2989
|
+
# back), and a composed row's `panel: null` makes an older client's
|
|
2990
|
+
# `h.panel === panel` filter false, so it hides the row rather than
|
|
2991
|
+
# mis-rendering it. `docs/share-gotchas.md` records that `share.*` keys need
|
|
2992
|
+
# no formal migration; this shape keeps that true.
|
|
2993
|
+
_SHARE_HISTORY_KINDS = ("panel", "composed")
|
|
2994
|
+
|
|
2995
|
+
# Twenty is the composer basket cap, so a composed row is bounded by
|
|
2996
|
+
# construction — this is the server saying so rather than trusting it.
|
|
2997
|
+
_SHARE_HISTORY_COMPOSED_MAX_SECTIONS = 20
|
|
2998
|
+
|
|
2999
|
+
# The fields that belong to exactly one branch. A row carrying the other
|
|
3000
|
+
# branch's fields is rejected rather than silently half-read.
|
|
3001
|
+
_SHARE_HISTORY_PANEL_ONLY_FIELDS = ("template_id", "options", "source",
|
|
3002
|
+
"account")
|
|
3003
|
+
_SHARE_HISTORY_COMPOSED_ONLY_FIELDS = ("sections", "composite")
|
|
3004
|
+
|
|
3005
|
+
|
|
3006
|
+
class _ShareHistoryError(ValueError):
|
|
3007
|
+
"""Carry a 400 envelope out of the history validators."""
|
|
3008
|
+
|
|
3009
|
+
def __init__(self, payload: Mapping):
|
|
3010
|
+
super().__init__(str(payload.get("error", "invalid history row")))
|
|
3011
|
+
self.payload = dict(payload)
|
|
3012
|
+
|
|
3013
|
+
|
|
3014
|
+
def _share_history_read_kind(record) -> str:
|
|
3015
|
+
"""The branch a STORED row belongs to. Absent is the legacy panel row."""
|
|
3016
|
+
if not isinstance(record, Mapping):
|
|
3017
|
+
return "panel"
|
|
3018
|
+
kind = record.get("kind")
|
|
3019
|
+
return kind if kind in _SHARE_HISTORY_KINDS else "panel"
|
|
3020
|
+
|
|
3021
|
+
|
|
3022
|
+
def _share_history_normalize_record(record):
|
|
3023
|
+
"""The read-side shape of one stored row (response only, never written).
|
|
3024
|
+
|
|
3025
|
+
A panel row keeps its S4 `source` default; a composed row has no
|
|
3026
|
+
top-level source to default, and inventing one would put a provider
|
|
3027
|
+
label on a document that has one per section.
|
|
3028
|
+
"""
|
|
3029
|
+
if not isinstance(record, Mapping):
|
|
3030
|
+
return record
|
|
3031
|
+
kind = _share_history_read_kind(record)
|
|
3032
|
+
if kind == "composed":
|
|
3033
|
+
return {**record, "kind": "composed", "panel": record.get("panel")}
|
|
3034
|
+
return {**record, "kind": "panel",
|
|
3035
|
+
"source": record.get("source", "claude")}
|
|
3036
|
+
|
|
3037
|
+
|
|
3038
|
+
def _share_history_validate_section(tpl_mod, sec, idx: int) -> dict:
|
|
3039
|
+
"""One composed section, held to the same invariants a panel row is."""
|
|
3040
|
+
field = f"sections[{idx}]"
|
|
3041
|
+
if not isinstance(sec, Mapping):
|
|
3042
|
+
raise _ShareHistoryError({
|
|
3043
|
+
"error": f"{field} must be an object", "field": field})
|
|
3044
|
+
panel = sec.get("panel")
|
|
3045
|
+
template_id = sec.get("template_id")
|
|
3046
|
+
options = sec.get("options")
|
|
3047
|
+
if options is None:
|
|
3048
|
+
options = {}
|
|
3049
|
+
if not isinstance(panel, str) or panel not in tpl_mod.SHARE_CAPABLE_PANELS:
|
|
3050
|
+
raise _ShareHistoryError({
|
|
3051
|
+
"error": f"unknown share panel: {panel!r}",
|
|
3052
|
+
"field": f"{field}.panel"})
|
|
3053
|
+
if not isinstance(template_id, str) or not template_id:
|
|
3054
|
+
raise _ShareHistoryError({
|
|
3055
|
+
"error": "missing or non-string template_id",
|
|
3056
|
+
"field": f"{field}.template_id"})
|
|
3057
|
+
try:
|
|
3058
|
+
template = tpl_mod.get_template(template_id)
|
|
3059
|
+
except KeyError:
|
|
3060
|
+
raise _ShareHistoryError({
|
|
3061
|
+
"error": f"unknown template_id: {template_id!r}",
|
|
3062
|
+
"field": f"{field}.template_id"}) from None
|
|
3063
|
+
if template.panel != panel:
|
|
3064
|
+
raise _ShareHistoryError({
|
|
3065
|
+
"error": (f"template_id {template_id!r} belongs to panel "
|
|
3066
|
+
f"{template.panel!r}, not {panel!r}"),
|
|
3067
|
+
"field": f"{field}.template_id"})
|
|
3068
|
+
if not isinstance(options, Mapping):
|
|
3069
|
+
raise _ShareHistoryError({
|
|
3070
|
+
"error": "options must be an object",
|
|
3071
|
+
"field": f"{field}.options"})
|
|
3072
|
+
try:
|
|
3073
|
+
source, _ = _share_source_selection(dict(sec))
|
|
3074
|
+
except ValueError:
|
|
3075
|
+
raise _ShareHistoryError({
|
|
3076
|
+
"code": "source_capability_unavailable",
|
|
3077
|
+
"error": "source capability unavailable",
|
|
3078
|
+
"field": f"{field}.source"}) from None
|
|
3079
|
+
try:
|
|
3080
|
+
account = _share_account_selection(dict(sec))
|
|
3081
|
+
except ValueError:
|
|
3082
|
+
raise _ShareHistoryError({
|
|
3083
|
+
"error": "malformed account key",
|
|
3084
|
+
"field": f"{field}.account"}) from None
|
|
3085
|
+
return {
|
|
3086
|
+
"panel": panel, "template_id": template_id,
|
|
3087
|
+
"options": dict(options), "source": source,
|
|
3088
|
+
**({"account": account} if account is not None else {}),
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
|
|
3092
|
+
def _share_history_validate_composite(composite) -> dict:
|
|
3093
|
+
"""The composite knobs a composed row states about the document."""
|
|
3094
|
+
if composite is None:
|
|
3095
|
+
composite = {}
|
|
3096
|
+
if not isinstance(composite, Mapping):
|
|
3097
|
+
raise _ShareHistoryError({
|
|
3098
|
+
"error": "composite must be an object", "field": "composite"})
|
|
3099
|
+
title = composite.get("title")
|
|
3100
|
+
if not isinstance(title, str) or not title or len(title) > 200:
|
|
3101
|
+
raise _ShareHistoryError({
|
|
3102
|
+
"error": "composite.title must be 1-200 chars",
|
|
3103
|
+
"field": "composite.title"})
|
|
3104
|
+
theme = composite.get("theme", "light")
|
|
3105
|
+
if theme not in ("light", "dark"):
|
|
3106
|
+
raise _ShareHistoryError({
|
|
3107
|
+
"error": f"unknown theme: {theme!r}", "field": "composite.theme"})
|
|
3108
|
+
knobs = {}
|
|
3109
|
+
for key in ("reveal_projects", "no_branding"):
|
|
3110
|
+
value = composite.get(key, False)
|
|
3111
|
+
if not isinstance(value, bool):
|
|
3112
|
+
raise _ShareHistoryError({
|
|
3113
|
+
"error": f"composite.{key} must be a boolean",
|
|
3114
|
+
"field": f"composite.{key}"})
|
|
3115
|
+
knobs[key] = value
|
|
3116
|
+
return {"title": title, "theme": theme, **knobs}
|
|
3117
|
+
|
|
3118
|
+
|
|
2603
3119
|
def _handle_share_history_get_impl(handler) -> None:
|
|
2604
3120
|
"""Return the recent-shares ring buffer (newest last, spec §11.4)."""
|
|
2605
3121
|
cfg = sys.modules["cctally"].load_config()
|
|
2606
3122
|
history = (cfg.get("share") or {}).get("history") or []
|
|
2607
3123
|
handler._respond_json(200, {"history": [
|
|
2608
|
-
(
|
|
2609
|
-
if isinstance(record, dict) else record)
|
|
2610
|
-
for record in history
|
|
3124
|
+
_share_history_normalize_record(record) for record in history
|
|
2611
3125
|
]})
|
|
2612
3126
|
|
|
2613
3127
|
def _handle_share_history_post_impl(handler) -> None:
|
|
@@ -2639,11 +3153,37 @@ def _handle_share_history_post_impl(handler) -> None:
|
|
|
2639
3153
|
if not isinstance(req, dict):
|
|
2640
3154
|
handler._respond_json(400, {"error": "expected JSON object"})
|
|
2641
3155
|
return
|
|
3156
|
+
# #503 S3 §3. An absent `kind` is the legacy panel row and validates
|
|
3157
|
+
# exactly as it always has; an unknown value is refused rather than
|
|
3158
|
+
# silently filed as one of the two branches.
|
|
3159
|
+
kind = req.get("kind", "panel")
|
|
3160
|
+
if kind not in _SHARE_HISTORY_KINDS:
|
|
3161
|
+
handler._respond_json(400, {
|
|
3162
|
+
"error": f"unknown history kind: {kind!r}",
|
|
3163
|
+
"field": "kind",
|
|
3164
|
+
})
|
|
3165
|
+
return
|
|
3166
|
+
tpl_mod = handler._share_load_templates_module()
|
|
3167
|
+
if kind == "composed":
|
|
3168
|
+
try:
|
|
3169
|
+
record = _share_history_composed_record(tpl_mod, req)
|
|
3170
|
+
except _ShareHistoryError as exc:
|
|
3171
|
+
handler._respond_json(400, exc.payload)
|
|
3172
|
+
return
|
|
3173
|
+
_share_history_append(handler, record)
|
|
3174
|
+
return
|
|
3175
|
+
for field in _SHARE_HISTORY_COMPOSED_ONLY_FIELDS:
|
|
3176
|
+
if field in req:
|
|
3177
|
+
handler._respond_json(400, {
|
|
3178
|
+
"error": f"{field} belongs to a composed row",
|
|
3179
|
+
"field": field,
|
|
3180
|
+
})
|
|
3181
|
+
return
|
|
2642
3182
|
panel = req.get("panel")
|
|
2643
3183
|
template_id = req.get("template_id")
|
|
2644
3184
|
options = req.get("options") or {}
|
|
2645
|
-
fmt
|
|
2646
|
-
|
|
3185
|
+
# `fmt` and `destination` are read below from
|
|
3186
|
+
# `_share_history_advisory_strings(req)`, which validates them.
|
|
2647
3187
|
try:
|
|
2648
3188
|
source, _ = _share_source_selection(req)
|
|
2649
3189
|
account = _share_account_selection(req)
|
|
@@ -2659,7 +3199,6 @@ def _handle_share_history_post_impl(handler) -> None:
|
|
|
2659
3199
|
"field": "panel",
|
|
2660
3200
|
})
|
|
2661
3201
|
return
|
|
2662
|
-
tpl_mod = handler._share_load_templates_module()
|
|
2663
3202
|
if panel not in tpl_mod.SHARE_CAPABLE_PANELS:
|
|
2664
3203
|
handler._respond_json(400, {
|
|
2665
3204
|
"error": f"unknown share panel: {panel!r}",
|
|
@@ -2695,25 +3234,15 @@ def _handle_share_history_post_impl(handler) -> None:
|
|
|
2695
3234
|
"field": "options",
|
|
2696
3235
|
})
|
|
2697
3236
|
return
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
if fmt is not None and not isinstance(fmt, str):
|
|
2703
|
-
handler._respond_json(400, {
|
|
2704
|
-
"error": "format must be a string if provided",
|
|
2705
|
-
"field": "format",
|
|
2706
|
-
})
|
|
2707
|
-
return
|
|
2708
|
-
if destination is not None and not isinstance(destination, str):
|
|
2709
|
-
handler._respond_json(400, {
|
|
2710
|
-
"error": "destination must be a string if provided",
|
|
2711
|
-
"field": "destination",
|
|
2712
|
-
})
|
|
3237
|
+
try:
|
|
3238
|
+
fmt, destination = _share_history_advisory_strings(req)
|
|
3239
|
+
except _ShareHistoryError as exc:
|
|
3240
|
+
handler._respond_json(400, exc.payload)
|
|
2713
3241
|
return
|
|
2714
3242
|
|
|
2715
3243
|
record = {
|
|
2716
3244
|
"recipe_id": _share_history_recipe_id(),
|
|
3245
|
+
"kind": "panel",
|
|
2717
3246
|
"panel": panel,
|
|
2718
3247
|
"template_id": template_id,
|
|
2719
3248
|
"options": options,
|
|
@@ -2723,6 +3252,84 @@ def _handle_share_history_post_impl(handler) -> None:
|
|
|
2723
3252
|
"destination": destination,
|
|
2724
3253
|
"exported_at": _share_now_utc_iso(),
|
|
2725
3254
|
}
|
|
3255
|
+
_share_history_append(handler, record)
|
|
3256
|
+
|
|
3257
|
+
|
|
3258
|
+
def _share_history_advisory_strings(req: Mapping) -> tuple:
|
|
3259
|
+
"""`format` and `destination` — display hints, not contracts.
|
|
3260
|
+
|
|
3261
|
+
Any non-empty string is accepted; the frontend uses them only as row
|
|
3262
|
+
labels in the dropdown. None/missing is allowed (mirrors how the CLI
|
|
3263
|
+
doesn't always know which destination produced the export).
|
|
3264
|
+
"""
|
|
3265
|
+
values = []
|
|
3266
|
+
for field in ("format", "destination"):
|
|
3267
|
+
value = req.get(field)
|
|
3268
|
+
if value is not None and not isinstance(value, str):
|
|
3269
|
+
raise _ShareHistoryError({
|
|
3270
|
+
"error": f"{field} must be a string if provided",
|
|
3271
|
+
"field": field,
|
|
3272
|
+
})
|
|
3273
|
+
values.append(value)
|
|
3274
|
+
return tuple(values)
|
|
3275
|
+
|
|
3276
|
+
|
|
3277
|
+
def _share_history_composed_record(tpl_mod, req: Mapping) -> dict:
|
|
3278
|
+
"""Validate and build ONE composed history row (#503 S3 §3).
|
|
3279
|
+
|
|
3280
|
+
Every section is held to exactly the invariants a panel row is — panel
|
|
3281
|
+
membership, template ownership, options shape, source and account —
|
|
3282
|
+
applied per section, because a section that would 400 on replay is the
|
|
3283
|
+
same poisoned dropdown row a bad panel row would be.
|
|
3284
|
+
"""
|
|
3285
|
+
for field in _SHARE_HISTORY_PANEL_ONLY_FIELDS:
|
|
3286
|
+
if field in req:
|
|
3287
|
+
raise _ShareHistoryError({
|
|
3288
|
+
"error": f"{field} belongs to a panel row",
|
|
3289
|
+
"field": field,
|
|
3290
|
+
})
|
|
3291
|
+
panel = req.get("panel")
|
|
3292
|
+
if panel is not None:
|
|
3293
|
+
raise _ShareHistoryError({
|
|
3294
|
+
"error": "a composed row carries no panel",
|
|
3295
|
+
"field": "panel",
|
|
3296
|
+
})
|
|
3297
|
+
sections_in = req.get("sections")
|
|
3298
|
+
if (not isinstance(sections_in, list) or not sections_in
|
|
3299
|
+
or len(sections_in) > _SHARE_HISTORY_COMPOSED_MAX_SECTIONS):
|
|
3300
|
+
raise _ShareHistoryError({
|
|
3301
|
+
"error": (
|
|
3302
|
+
"sections must hold 1-"
|
|
3303
|
+
f"{_SHARE_HISTORY_COMPOSED_MAX_SECTIONS} entries"
|
|
3304
|
+
),
|
|
3305
|
+
"field": "sections",
|
|
3306
|
+
})
|
|
3307
|
+
sections = [
|
|
3308
|
+
_share_history_validate_section(tpl_mod, sec, idx)
|
|
3309
|
+
for idx, sec in enumerate(sections_in)
|
|
3310
|
+
]
|
|
3311
|
+
composite = _share_history_validate_composite(req.get("composite"))
|
|
3312
|
+
fmt, destination = _share_history_advisory_strings(req)
|
|
3313
|
+
return {
|
|
3314
|
+
"recipe_id": _share_history_recipe_id(),
|
|
3315
|
+
"kind": "composed",
|
|
3316
|
+
# EXPLICIT null, not an absent key: it is what makes an older
|
|
3317
|
+
# client's `h.panel === panel` filter hide this row.
|
|
3318
|
+
"panel": None,
|
|
3319
|
+
"sections": sections,
|
|
3320
|
+
"composite": composite,
|
|
3321
|
+
"format": fmt,
|
|
3322
|
+
"destination": destination,
|
|
3323
|
+
"exported_at": _share_now_utc_iso(),
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
|
|
3327
|
+
def _share_history_append(handler, record: dict) -> None:
|
|
3328
|
+
"""Append one row to the ring buffer and answer with it.
|
|
3329
|
+
|
|
3330
|
+
Write discipline matches the presets handlers: `config_writer_lock` +
|
|
3331
|
+
`_load_config_unlocked` + `save_config`.
|
|
3332
|
+
"""
|
|
2726
3333
|
with sys.modules["cctally"].config_writer_lock():
|
|
2727
3334
|
cfg = _load_config_unlocked()
|
|
2728
3335
|
share = cfg.setdefault("share", {})
|