cctally 1.6.3 → 1.7.1
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 +22 -0
- package/bin/_cctally_alerts.py +231 -0
- package/bin/_cctally_cache.py +1432 -0
- package/bin/_cctally_config.py +560 -0
- package/bin/_cctally_dashboard.py +5218 -0
- package/bin/_cctally_db.py +1729 -0
- package/bin/_cctally_record.py +2120 -0
- package/bin/_cctally_refresh.py +812 -0
- package/bin/_cctally_release.py +751 -0
- package/bin/_cctally_setup.py +1571 -0
- package/bin/_cctally_sync_week.py +110 -0
- package/bin/_cctally_tui.py +4381 -0
- package/bin/_cctally_update.py +2132 -0
- package/bin/_lib_aggregators.py +712 -0
- package/bin/_lib_alerts_payload.py +194 -0
- package/bin/_lib_blocks.py +414 -0
- package/bin/_lib_diff_kernel.py +1618 -0
- package/bin/_lib_display_tz.py +361 -0
- package/bin/_lib_doctor.py +961 -0
- package/bin/_lib_five_hour.py +82 -0
- package/bin/_lib_jsonl.py +403 -0
- package/bin/_lib_pricing.py +520 -0
- package/bin/_lib_render.py +2785 -0
- package/bin/_lib_semver.py +105 -0
- package/bin/_lib_share.py +350 -32
- package/bin/_lib_share_templates.py +233 -44
- package/bin/_lib_subscription_weeks.py +492 -0
- package/bin/cctally +11061 -34659
- package/dashboard/static/assets/index-BgpoazlS.js +18 -0
- package/dashboard/static/assets/index-nJdUaGys.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +25 -1
- package/dashboard/static/assets/index-Z6V0XgqK.js +0 -18
- package/dashboard/static/assets/index-ZPC0pk-h.css +0 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Pure SemVer primitives — parse, format, bump-compute, sort-key.
|
|
2
|
+
|
|
3
|
+
Eager-imported from bin/cctally to back release-flow internals and the
|
|
4
|
+
update-banner version-compare predicate. Zero I/O, zero module-level
|
|
5
|
+
side effects; safe to import from any context (script, SourceFileLoader,
|
|
6
|
+
compile+exec).
|
|
7
|
+
|
|
8
|
+
Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
# Exported as a building block: bin/cctally's RELEASE_HEADER_RE and
|
|
15
|
+
# _cctally_release's _FORMULA_VERSION_RE both reuse this numeric-component
|
|
16
|
+
# pattern for SemVer matching.
|
|
17
|
+
_SEMVER_NUM = r'(?:0|[1-9]\d*)'
|
|
18
|
+
|
|
19
|
+
_SEMVER_RE = re.compile(
|
|
20
|
+
rf'^({_SEMVER_NUM})\.({_SEMVER_NUM})\.({_SEMVER_NUM})'
|
|
21
|
+
rf'(?:-([a-zA-Z][a-zA-Z0-9-]*)\.({_SEMVER_NUM}))?$'
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _release_parse_semver(s: str) -> tuple[int, int, int, str | None, int | None]:
|
|
26
|
+
"""Parse SemVer; raises ValueError on malformed input."""
|
|
27
|
+
m = _SEMVER_RE.match(s)
|
|
28
|
+
if not m:
|
|
29
|
+
raise ValueError(f"invalid semver: {s!r}")
|
|
30
|
+
major, minor, patch, prerelease_id, prerelease_n = m.groups()
|
|
31
|
+
return (
|
|
32
|
+
int(major),
|
|
33
|
+
int(minor),
|
|
34
|
+
int(patch),
|
|
35
|
+
prerelease_id,
|
|
36
|
+
int(prerelease_n) if prerelease_n is not None else None,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _release_format_semver(
|
|
41
|
+
major: int, minor: int, patch: int,
|
|
42
|
+
prerelease_id: str | None = None, prerelease_n: int | None = None,
|
|
43
|
+
) -> str:
|
|
44
|
+
base = f"{major}.{minor}.{patch}"
|
|
45
|
+
if prerelease_id is None:
|
|
46
|
+
return base
|
|
47
|
+
return f"{base}-{prerelease_id}.{prerelease_n}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _release_compute_next_version(
|
|
51
|
+
current: str | None, kind: str, bump: str | None, prerelease_id: str,
|
|
52
|
+
) -> str:
|
|
53
|
+
"""Pure function. Implements bump rules from spec Section 4.4."""
|
|
54
|
+
if current is None:
|
|
55
|
+
# First-ever release. Treat as 0.0.0 base.
|
|
56
|
+
cur_maj, cur_min, cur_pat, cur_id, cur_n = 0, 0, 0, None, None
|
|
57
|
+
else:
|
|
58
|
+
cur_maj, cur_min, cur_pat, cur_id, cur_n = _release_parse_semver(current)
|
|
59
|
+
is_prerelease = cur_id is not None
|
|
60
|
+
|
|
61
|
+
if kind == "finalize":
|
|
62
|
+
if not is_prerelease:
|
|
63
|
+
raise ValueError("cannot finalize: current version is not a prerelease")
|
|
64
|
+
return _release_format_semver(cur_maj, cur_min, cur_pat)
|
|
65
|
+
|
|
66
|
+
if kind == "prerelease":
|
|
67
|
+
if is_prerelease:
|
|
68
|
+
if bump is not None:
|
|
69
|
+
raise ValueError("--bump invalid when current version is a prerelease; rc counter increments only")
|
|
70
|
+
return _release_format_semver(cur_maj, cur_min, cur_pat, cur_id, cur_n + 1)
|
|
71
|
+
if bump is None:
|
|
72
|
+
raise ValueError("--bump required for first prerelease from stable version")
|
|
73
|
+
# Apply bump kind to current stable, then attach -<id>.1
|
|
74
|
+
nxt = _release_compute_next_version(current or "0.0.0", bump, None, prerelease_id)
|
|
75
|
+
nxt_maj, nxt_min, nxt_pat, _, _ = _release_parse_semver(nxt)
|
|
76
|
+
return _release_format_semver(nxt_maj, nxt_min, nxt_pat, prerelease_id, 1)
|
|
77
|
+
|
|
78
|
+
if is_prerelease:
|
|
79
|
+
raise ValueError("current version is a prerelease; run 'cctally release finalize' first or use --bump in a prerelease bump")
|
|
80
|
+
|
|
81
|
+
if kind == "patch":
|
|
82
|
+
return _release_format_semver(cur_maj, cur_min, cur_pat + 1)
|
|
83
|
+
if kind == "minor":
|
|
84
|
+
return _release_format_semver(cur_maj, cur_min + 1, 0)
|
|
85
|
+
if kind == "major":
|
|
86
|
+
return _release_format_semver(cur_maj + 1, 0, 0)
|
|
87
|
+
raise ValueError(f"unknown bump kind: {kind!r}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _release_semver_sort_key(
|
|
91
|
+
parsed: tuple[int, int, int, str | None, int | None],
|
|
92
|
+
) -> tuple:
|
|
93
|
+
"""Total-order sort key for `_release_parse_semver` output.
|
|
94
|
+
|
|
95
|
+
SemVer §11.4: a stable release has higher precedence than a pre-release
|
|
96
|
+
of the same MAJOR.MINOR.PATCH. Naive tuple comparison breaks because
|
|
97
|
+
Python rejects ``None < str`` at runtime. The key returned here makes
|
|
98
|
+
stable releases sort *after* their pre-releases by inverting the
|
|
99
|
+
"has-prerelease" axis: stable → ``(maj, min, pat, 1, "", 0)``,
|
|
100
|
+
pre-release → ``(maj, min, pat, 0, id, n)``.
|
|
101
|
+
"""
|
|
102
|
+
maj, min_, pat, pre_id, pre_n = parsed
|
|
103
|
+
if pre_id is None:
|
|
104
|
+
return (maj, min_, pat, 1, "", 0)
|
|
105
|
+
return (maj, min_, pat, 0, pre_id, pre_n)
|
package/bin/_lib_share.py
CHANGED
|
@@ -80,6 +80,7 @@ class ColumnSpec:
|
|
|
80
80
|
label: str
|
|
81
81
|
align: str = "left" # "left" | "right" | "center"
|
|
82
82
|
emphasis: bool = False
|
|
83
|
+
kind: str | None = None # "project" | "model" | None — privacy chokepoint signal
|
|
83
84
|
|
|
84
85
|
|
|
85
86
|
@dataclass(frozen=True)
|
|
@@ -358,7 +359,8 @@ def svg_rect(x: float, y: float, w: float, h: float, *,
|
|
|
358
359
|
|
|
359
360
|
def svg_text(x: float, y: float, text: str, *,
|
|
360
361
|
font_size: float, fill: str,
|
|
361
|
-
anchor: str = "start", weight: str = "normal"
|
|
362
|
+
anchor: str = "start", weight: str = "normal",
|
|
363
|
+
font_family: str | None = None) -> str:
|
|
362
364
|
attrs = {
|
|
363
365
|
"x": x,
|
|
364
366
|
"y": y,
|
|
@@ -368,6 +370,8 @@ def svg_text(x: float, y: float, text: str, *,
|
|
|
368
370
|
}
|
|
369
371
|
if weight and weight != "normal":
|
|
370
372
|
attrs["font-weight"] = weight
|
|
373
|
+
if font_family:
|
|
374
|
+
attrs["font-family"] = font_family
|
|
371
375
|
return f'<text {_serialize_attrs(attrs)}>{_xml_escape(text)}</text>'
|
|
372
376
|
|
|
373
377
|
|
|
@@ -769,6 +773,22 @@ def _collect_project_costs(snap: ShareSnapshot) -> dict[str, float]:
|
|
|
769
773
|
if p.project_label and p.project_label not in costs:
|
|
770
774
|
costs[p.project_label] = p.y_value
|
|
771
775
|
|
|
776
|
+
# project-typed columns (cross-tab Detail templates, issue #33). Sum the
|
|
777
|
+
# MoneyCell values for each kind='project' column across all rows; the
|
|
778
|
+
# column.label is the original project path (anon happens AFTER _collect).
|
|
779
|
+
# No current panel mixes ProjectCell rows AND project-typed columns — if a
|
|
780
|
+
# future template does, the `+=` here will double-count that project's
|
|
781
|
+
# total. Refactor to a (path, source) keyed accumulator if/when that lands.
|
|
782
|
+
for col in snap.columns:
|
|
783
|
+
if col.kind != "project":
|
|
784
|
+
continue
|
|
785
|
+
col_total = 0.0
|
|
786
|
+
for row in snap.rows:
|
|
787
|
+
cell = row.cells.get(col.key)
|
|
788
|
+
if isinstance(cell, MoneyCell):
|
|
789
|
+
col_total += cell.usd
|
|
790
|
+
costs[col.label] = costs.get(col.label, 0.0) + col_total
|
|
791
|
+
|
|
772
792
|
return costs
|
|
773
793
|
|
|
774
794
|
|
|
@@ -867,6 +887,21 @@ def _apply_anon_mapping(
|
|
|
867
887
|
cap=snap.chart.cap,
|
|
868
888
|
)
|
|
869
889
|
|
|
890
|
+
# Rewrite project-typed column headers (cross-tab Detail templates, issue
|
|
891
|
+
# #33). Fail-closed: any column.label not in `mapping` maps to "(unknown)",
|
|
892
|
+
# mirroring the ChartPoint arm above. Frozen-dataclass-compliant — we emit
|
|
893
|
+
# a new tuple of new ColumnSpec instances, never mutate snap.columns.
|
|
894
|
+
new_columns: list[ColumnSpec] = []
|
|
895
|
+
for col in snap.columns:
|
|
896
|
+
if col.kind == "project":
|
|
897
|
+
new_label = mapping.get(col.label, "(unknown)")
|
|
898
|
+
new_columns.append(ColumnSpec(
|
|
899
|
+
key=col.key, label=new_label,
|
|
900
|
+
align=col.align, emphasis=col.emphasis, kind=col.kind,
|
|
901
|
+
))
|
|
902
|
+
else:
|
|
903
|
+
new_columns.append(col)
|
|
904
|
+
|
|
870
905
|
# When ShareSnapshot grows a new field, add it to this constructor — the
|
|
871
906
|
# scrubber must thread every field through to preserve frozen semantics.
|
|
872
907
|
return ShareSnapshot(
|
|
@@ -874,7 +909,7 @@ def _apply_anon_mapping(
|
|
|
874
909
|
title=snap.title,
|
|
875
910
|
subtitle=snap.subtitle,
|
|
876
911
|
period=snap.period,
|
|
877
|
-
columns=
|
|
912
|
+
columns=tuple(new_columns),
|
|
878
913
|
rows=tuple(new_rows),
|
|
879
914
|
chart=new_chart,
|
|
880
915
|
totals=snap.totals,
|
|
@@ -957,20 +992,292 @@ _SVG_FOOTER_BASELINE = 18
|
|
|
957
992
|
# Vertical padding between stacked sections in `_stitch_svg`.
|
|
958
993
|
_SVG_SECTION_GAP = 20.0
|
|
959
994
|
|
|
995
|
+
# --- SVG table geometry (issue #38) ---
|
|
996
|
+
_SVG_TABLE_FONT = 11
|
|
997
|
+
_SVG_TABLE_CELL_PAD_X = 8
|
|
998
|
+
_SVG_TABLE_CELL_PAD_Y = 6
|
|
999
|
+
_SVG_TABLE_LINE_H_MULT = 1.4
|
|
1000
|
+
_SVG_TABLE_GAP = 16
|
|
1001
|
+
_SVG_TABLE_MAX_WRAP_LINES = 3
|
|
1002
|
+
_SVG_TABLE_MIN_COL_W = 24
|
|
1003
|
+
_SVG_AVG_GLYPH_WIDTH_FRACTION = 0.6
|
|
1004
|
+
_SVG_WRAP_BREAK_CHARS = (" ", "/", "-", "_")
|
|
1005
|
+
_SVG_ELLIPSIS = "…"
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _svg_text_width(text: str, font_size: float) -> float:
|
|
1009
|
+
"""Estimate rendered width of `text` at `font_size` in a sans-serif font.
|
|
1010
|
+
|
|
1011
|
+
Heuristic-only: actual width depends on the UA-selected font. SVG
|
|
1012
|
+
goldens are diffed as source text, so this function's job is
|
|
1013
|
+
determinism, not pixel-perfect measurement. Wrap-then-ellipsize
|
|
1014
|
+
layout tolerates moderate over/under-allocation.
|
|
1015
|
+
"""
|
|
1016
|
+
return len(text) * font_size * _SVG_AVG_GLYPH_WIDTH_FRACTION
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _wrap_for_width(text: str, content_w: float, font_size: float) -> list[str]:
|
|
1020
|
+
"""Wrap `text` into lines that each fit within `content_w` pixels.
|
|
1021
|
+
|
|
1022
|
+
Greedy left-to-right: binary-search the longest prefix that fits,
|
|
1023
|
+
then cut at the rightmost break-char inside the run. Cap output
|
|
1024
|
+
at `_SVG_TABLE_MAX_WRAP_LINES`. If the input still has tail after
|
|
1025
|
+
the cap, ellipsize the last emitted line until ellipsis fits.
|
|
1026
|
+
Empty text → [""]. Unbreakable token longer than `content_w` →
|
|
1027
|
+
hard-cut + ellipsis on the tail.
|
|
1028
|
+
"""
|
|
1029
|
+
if not text:
|
|
1030
|
+
return [""]
|
|
1031
|
+
if _svg_text_width(text, font_size) <= content_w:
|
|
1032
|
+
return [text]
|
|
1033
|
+
|
|
1034
|
+
lines: list[str] = []
|
|
1035
|
+
remaining = text
|
|
1036
|
+
while remaining and len(lines) < _SVG_TABLE_MAX_WRAP_LINES:
|
|
1037
|
+
lo, hi = 0, len(remaining)
|
|
1038
|
+
while lo < hi:
|
|
1039
|
+
mid = (lo + hi + 1) // 2
|
|
1040
|
+
if _svg_text_width(remaining[:mid], font_size) <= content_w:
|
|
1041
|
+
lo = mid
|
|
1042
|
+
else:
|
|
1043
|
+
hi = mid - 1
|
|
1044
|
+
fit_end = lo
|
|
1045
|
+
|
|
1046
|
+
if fit_end == len(remaining):
|
|
1047
|
+
lines.append(remaining)
|
|
1048
|
+
remaining = ""
|
|
1049
|
+
break
|
|
1050
|
+
|
|
1051
|
+
if fit_end == 0:
|
|
1052
|
+
# Even one character overflows — abort wrap, fall through to ellipsis.
|
|
1053
|
+
break
|
|
1054
|
+
|
|
1055
|
+
break_at = -1
|
|
1056
|
+
for ch in _SVG_WRAP_BREAK_CHARS:
|
|
1057
|
+
idx = remaining.rfind(ch, 0, fit_end + 1)
|
|
1058
|
+
if idx > break_at:
|
|
1059
|
+
break_at = idx
|
|
1060
|
+
|
|
1061
|
+
if break_at <= 0:
|
|
1062
|
+
lines.append(remaining[:fit_end])
|
|
1063
|
+
remaining = remaining[fit_end:]
|
|
1064
|
+
else:
|
|
1065
|
+
lines.append(remaining[:break_at + 1].rstrip())
|
|
1066
|
+
remaining = remaining[break_at + 1:]
|
|
1067
|
+
|
|
1068
|
+
if remaining:
|
|
1069
|
+
last = lines[-1] if lines else ""
|
|
1070
|
+
while last and _svg_text_width(last + _SVG_ELLIPSIS, font_size) > content_w:
|
|
1071
|
+
last = last[:-1]
|
|
1072
|
+
if last:
|
|
1073
|
+
lines[-1] = last + _SVG_ELLIPSIS
|
|
1074
|
+
elif lines:
|
|
1075
|
+
lines[-1] = _SVG_ELLIPSIS
|
|
1076
|
+
else:
|
|
1077
|
+
lines.append(_SVG_ELLIPSIS)
|
|
1078
|
+
|
|
1079
|
+
return lines or [_SVG_ELLIPSIS]
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _svg_table_anchor_and_x(align: str, col_x: float, col_w: float,
|
|
1083
|
+
pad_x: float) -> tuple[str, float]:
|
|
1084
|
+
"""Map `ColumnSpec.align` to SVG text-anchor + x-coordinate."""
|
|
1085
|
+
if align == "right":
|
|
1086
|
+
return "end", col_x + col_w - pad_x
|
|
1087
|
+
if align == "center":
|
|
1088
|
+
return "middle", col_x + col_w / 2
|
|
1089
|
+
return "start", col_x + pad_x
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _render_svg_table(
|
|
1093
|
+
snap: "ShareSnapshot", *, palette: dict,
|
|
1094
|
+
x: float, y: float, max_width: float,
|
|
1095
|
+
) -> tuple[str, float, float]:
|
|
1096
|
+
"""Render the cross-tab / project / sessions table body as SVG.
|
|
1097
|
+
|
|
1098
|
+
Returns (svg_fragment, total_height, used_width). Caller
|
|
1099
|
+
(`_render_svg`) uses the returned height to position the footer
|
|
1100
|
+
band, and `used_width` to size the outer canvas — at pathological
|
|
1101
|
+
`top_n` the min-width clamp can push `sum(widths)` past
|
|
1102
|
+
`max_width`, in which case the outer SVG expands so columns are
|
|
1103
|
+
never clipped (issue #38 follow-up: Codex P2 review on PR #40).
|
|
1104
|
+
Caller MUST short-circuit when `snap.columns` is empty — this
|
|
1105
|
+
helper is a precondition violation
|
|
1106
|
+
if called with `columns=()`.
|
|
1107
|
+
|
|
1108
|
+
Layout: greedy auto-size, shrink only oversized columns, clamp
|
|
1109
|
+
to `_SVG_TABLE_MIN_COL_W` minimum, wrap headers AND body cells
|
|
1110
|
+
with the same break-priority rule. Visual: Treatment A (HTML-
|
|
1111
|
+
mirror) — header band + alternating row stripes + body text;
|
|
1112
|
+
no borders or inter-column rules.
|
|
1113
|
+
"""
|
|
1114
|
+
cols = snap.columns
|
|
1115
|
+
rows = snap.rows
|
|
1116
|
+
n = len(cols)
|
|
1117
|
+
assert n > 0, "_render_svg_table: precondition snap.columns non-empty"
|
|
1118
|
+
|
|
1119
|
+
font_size = _SVG_TABLE_FONT
|
|
1120
|
+
pad_x = _SVG_TABLE_CELL_PAD_X
|
|
1121
|
+
pad_y = _SVG_TABLE_CELL_PAD_Y
|
|
1122
|
+
line_h = font_size * _SVG_TABLE_LINE_H_MULT
|
|
1123
|
+
|
|
1124
|
+
# 1. Pre-format every cell to plain text.
|
|
1125
|
+
cell_strs = [
|
|
1126
|
+
[_render_cell_text(row.cells.get(c.key, TextCell("")))
|
|
1127
|
+
for c in cols]
|
|
1128
|
+
for row in rows
|
|
1129
|
+
]
|
|
1130
|
+
|
|
1131
|
+
# 2. Natural per-column width = max(header, max body) + 2*pad_x.
|
|
1132
|
+
def _col_natural(i: int, c: "ColumnSpec") -> float:
|
|
1133
|
+
widths = [_svg_text_width(c.label, font_size)]
|
|
1134
|
+
for r in range(len(rows)):
|
|
1135
|
+
widths.append(_svg_text_width(cell_strs[r][i], font_size))
|
|
1136
|
+
return max(widths) + 2 * pad_x
|
|
1137
|
+
|
|
1138
|
+
nat_w = [_col_natural(i, c) for i, c in enumerate(cols)]
|
|
1139
|
+
|
|
1140
|
+
# 3+4. Fit or shrink.
|
|
1141
|
+
if sum(nat_w) <= max_width:
|
|
1142
|
+
widths = list(nat_w)
|
|
1143
|
+
else:
|
|
1144
|
+
fair = max_width / n
|
|
1145
|
+
oversize_idx = [i for i in range(n) if nat_w[i] > fair]
|
|
1146
|
+
oversize_set = set(oversize_idx)
|
|
1147
|
+
if not oversize_idx:
|
|
1148
|
+
scale = max_width / sum(nat_w)
|
|
1149
|
+
widths = [w * scale for w in nat_w]
|
|
1150
|
+
else:
|
|
1151
|
+
other_total = sum(nat_w[i] for i in range(n) if i not in oversize_set)
|
|
1152
|
+
total_oversize = sum(nat_w[i] for i in oversize_idx)
|
|
1153
|
+
budget = max_width - other_total
|
|
1154
|
+
scale = budget / total_oversize if total_oversize > 0 else 1.0
|
|
1155
|
+
widths = [
|
|
1156
|
+
(nat_w[i] * scale) if i in oversize_set else nat_w[i]
|
|
1157
|
+
for i in range(n)
|
|
1158
|
+
]
|
|
1159
|
+
# 4b. Min-width clamp (pathological top_n).
|
|
1160
|
+
widths = [max(w, _SVG_TABLE_MIN_COL_W) for w in widths]
|
|
1161
|
+
|
|
1162
|
+
# 5a. Header wrap.
|
|
1163
|
+
header_lines: list[list[str]] = []
|
|
1164
|
+
for i, c in enumerate(cols):
|
|
1165
|
+
content_w = widths[i] - 2 * pad_x
|
|
1166
|
+
header_lines.append(_wrap_for_width(c.label, content_w, font_size))
|
|
1167
|
+
max_header_lines = max((len(ls) for ls in header_lines), default=1)
|
|
1168
|
+
|
|
1169
|
+
# 5b. Body wrap.
|
|
1170
|
+
wrapped_body: list[list[list[str]]] = []
|
|
1171
|
+
row_lines: list[int] = []
|
|
1172
|
+
for r in range(len(rows)):
|
|
1173
|
+
cells_wrapped: list[list[str]] = []
|
|
1174
|
+
mx = 1
|
|
1175
|
+
for i in range(n):
|
|
1176
|
+
content_w = widths[i] - 2 * pad_x
|
|
1177
|
+
ls = _wrap_for_width(cell_strs[r][i], content_w, font_size)
|
|
1178
|
+
cells_wrapped.append(ls)
|
|
1179
|
+
mx = max(mx, len(ls))
|
|
1180
|
+
wrapped_body.append(cells_wrapped)
|
|
1181
|
+
row_lines.append(mx)
|
|
1182
|
+
|
|
1183
|
+
# 6. Heights.
|
|
1184
|
+
header_h = max_header_lines * line_h + 2 * pad_y
|
|
1185
|
+
body_heights = [nl * line_h + 2 * pad_y for nl in row_lines]
|
|
1186
|
+
total_h = header_h + sum(body_heights)
|
|
1187
|
+
|
|
1188
|
+
# 7. Emit. Column x-offsets are cumulative.
|
|
1189
|
+
col_xs = [x]
|
|
1190
|
+
for w in widths[:-1]:
|
|
1191
|
+
col_xs.append(col_xs[-1] + w)
|
|
1192
|
+
|
|
1193
|
+
# Band rects span the full rendered width — when min-col-w clamp
|
|
1194
|
+
# pushes sum(widths) past max_width, both the header band and the
|
|
1195
|
+
# alternating row stripes extend with the columns so right-side
|
|
1196
|
+
# cells sit on the band color, not on the outer SVG background.
|
|
1197
|
+
used_width = sum(widths)
|
|
1198
|
+
band_width = max(max_width, used_width)
|
|
1199
|
+
|
|
1200
|
+
pieces: list[str] = []
|
|
1201
|
+
|
|
1202
|
+
# Header band.
|
|
1203
|
+
pieces.append(svg_rect(x, y, band_width, header_h,
|
|
1204
|
+
fill=palette["table_header_bg"]))
|
|
1205
|
+
# Header text.
|
|
1206
|
+
for i, c in enumerate(cols):
|
|
1207
|
+
cx = col_xs[i]
|
|
1208
|
+
cw = widths[i]
|
|
1209
|
+
anchor, tx = _svg_table_anchor_and_x(c.align, cx, cw, pad_x)
|
|
1210
|
+
for j, line in enumerate(header_lines[i]):
|
|
1211
|
+
baseline = y + pad_y + font_size + j * line_h
|
|
1212
|
+
pieces.append(svg_text(
|
|
1213
|
+
tx, baseline, line,
|
|
1214
|
+
font_size=font_size, fill=palette["fg"],
|
|
1215
|
+
anchor=anchor, weight="bold", font_family="sans-serif",
|
|
1216
|
+
))
|
|
1217
|
+
|
|
1218
|
+
# Body rows.
|
|
1219
|
+
row_y = y + header_h
|
|
1220
|
+
for r, _ in enumerate(rows):
|
|
1221
|
+
rh = body_heights[r]
|
|
1222
|
+
row_bg = palette["table_row_alt"] if (r % 2 == 1) else palette["bg"]
|
|
1223
|
+
pieces.append(svg_rect(x, row_y, band_width, rh, fill=row_bg))
|
|
1224
|
+
|
|
1225
|
+
for i, c in enumerate(cols):
|
|
1226
|
+
cx = col_xs[i]
|
|
1227
|
+
cw = widths[i]
|
|
1228
|
+
anchor, tx = _svg_table_anchor_and_x(c.align, cx, cw, pad_x)
|
|
1229
|
+
for j, line in enumerate(wrapped_body[r][i]):
|
|
1230
|
+
baseline = row_y + pad_y + font_size + j * line_h
|
|
1231
|
+
pieces.append(svg_text(
|
|
1232
|
+
tx, baseline, line,
|
|
1233
|
+
font_size=font_size, fill=palette["fg"],
|
|
1234
|
+
anchor=anchor, font_family="sans-serif",
|
|
1235
|
+
))
|
|
1236
|
+
row_y += rh
|
|
1237
|
+
|
|
1238
|
+
return "".join(pieces), total_h, used_width
|
|
1239
|
+
|
|
960
1240
|
|
|
961
1241
|
def _render_svg(snap: ShareSnapshot, *, palette: dict,
|
|
962
|
-
branding: bool,
|
|
1242
|
+
branding: bool,
|
|
1243
|
+
include_chrome: bool = True,
|
|
1244
|
+
include_table: bool = True) -> str:
|
|
963
1245
|
"""Render snapshot to SVG.
|
|
964
1246
|
|
|
965
|
-
include_chrome=True
|
|
966
|
-
include_chrome=False → chart-only (HTML wrapper consumes this).
|
|
1247
|
+
include_chrome=True → standalone SVG with title/subtitle/timestamp/footer.
|
|
1248
|
+
include_chrome=False → chart(+optional table)-only (HTML wrapper consumes this).
|
|
1249
|
+
include_table=True → emit `_render_svg_table` body when snap.columns
|
|
1250
|
+
is non-empty. HTML wrapper passes False so the
|
|
1251
|
+
chart-slot embed stays table-free (HTML <table>
|
|
1252
|
+
is rendered separately as a sibling element).
|
|
967
1253
|
"""
|
|
1254
|
+
has_table = include_table and bool(snap.columns)
|
|
1255
|
+
chart_h = _SVG_CHART_H if snap.chart is not None else 0
|
|
1256
|
+
|
|
1257
|
+
# Pre-layout the table (we need its height before declaring outer SVG height).
|
|
1258
|
+
if has_table:
|
|
1259
|
+
table_y = _SVG_PADDING + (_SVG_HEADER_H if include_chrome else 0) + chart_h + _SVG_TABLE_GAP
|
|
1260
|
+
table_svg, table_h, table_w = _render_svg_table(
|
|
1261
|
+
snap, palette=palette, x=_SVG_PADDING, y=table_y, max_width=_SVG_WIDTH,
|
|
1262
|
+
)
|
|
1263
|
+
else:
|
|
1264
|
+
table_svg, table_h, table_w = "", 0.0, 0.0
|
|
1265
|
+
|
|
1266
|
+
# Canvas grows when the table's used width exceeds _SVG_WIDTH — happens
|
|
1267
|
+
# only when the min-col-w clamp fired (pathological top_n). Header /
|
|
1268
|
+
# chart / footer keep their original positioning anchored at _SVG_WIDTH;
|
|
1269
|
+
# the canvas just extends to the right so wide tables aren't clipped at
|
|
1270
|
+
# the viewBox. Issue #38 follow-up (Codex PR #40 P2).
|
|
1271
|
+
content_w = max(_SVG_WIDTH, table_w)
|
|
1272
|
+
|
|
1273
|
+
table_block_h = (_SVG_TABLE_GAP + table_h) if has_table else 0
|
|
1274
|
+
|
|
968
1275
|
if include_chrome:
|
|
969
|
-
height = _SVG_HEADER_H +
|
|
1276
|
+
height = _SVG_HEADER_H + chart_h + table_block_h + _SVG_FOOTER_H + (_SVG_PADDING * 2)
|
|
970
1277
|
else:
|
|
971
|
-
height =
|
|
1278
|
+
height = chart_h + table_block_h + (_SVG_PADDING * 2)
|
|
972
1279
|
|
|
973
|
-
pieces = []
|
|
1280
|
+
pieces: list[str] = []
|
|
974
1281
|
|
|
975
1282
|
if include_chrome:
|
|
976
1283
|
pieces.append(_render_svg_header(
|
|
@@ -978,7 +1285,6 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
|
|
|
978
1285
|
x=_SVG_PADDING, y=_SVG_PADDING, width=_SVG_WIDTH,
|
|
979
1286
|
))
|
|
980
1287
|
|
|
981
|
-
# Chart.
|
|
982
1288
|
chart_y = _SVG_PADDING + (_SVG_HEADER_H if include_chrome else 0)
|
|
983
1289
|
if snap.chart is not None:
|
|
984
1290
|
if isinstance(snap.chart, LineChart):
|
|
@@ -997,16 +1303,18 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
|
|
|
997
1303
|
x=_SVG_PADDING, y=chart_y, width=_SVG_WIDTH, height=_SVG_CHART_H,
|
|
998
1304
|
))
|
|
999
1305
|
|
|
1306
|
+
if has_table:
|
|
1307
|
+
pieces.append(table_svg)
|
|
1308
|
+
|
|
1000
1309
|
if include_chrome:
|
|
1310
|
+
footer_y = _SVG_PADDING + _SVG_HEADER_H + chart_h + table_block_h + _SVG_FOOTER_BASELINE
|
|
1001
1311
|
pieces.append(_render_svg_footer(
|
|
1002
1312
|
snap, palette=palette,
|
|
1003
|
-
x=_SVG_PADDING,
|
|
1004
|
-
|
|
1005
|
-
width=_SVG_WIDTH,
|
|
1006
|
-
branding=branding,
|
|
1313
|
+
x=_SVG_PADDING, y=footer_y,
|
|
1314
|
+
width=_SVG_WIDTH, branding=branding,
|
|
1007
1315
|
))
|
|
1008
1316
|
|
|
1009
|
-
total_w =
|
|
1317
|
+
total_w = content_w + (_SVG_PADDING * 2)
|
|
1010
1318
|
bg_rect = svg_rect(0, 0, total_w, height, fill=palette["bg"])
|
|
1011
1319
|
inner = bg_rect + "".join(pieces)
|
|
1012
1320
|
return (
|
|
@@ -1093,7 +1401,7 @@ def _render_html_fragment(snap: ShareSnapshot, *, palette: dict, branding: bool)
|
|
|
1093
1401
|
# rather than rendering empty chrome (an empty `<svg>` chart area or an
|
|
1094
1402
|
# `<table>` with no `<th>`/`<td>`).
|
|
1095
1403
|
chart_html = (
|
|
1096
|
-
f'<div style="margin-top:12px">{_render_svg(snap, palette=palette, branding=False, include_chrome=False)}</div>'
|
|
1404
|
+
f'<div style="margin-top:12px">{_render_svg(snap, palette=palette, branding=False, include_chrome=False, include_table=False)}</div>'
|
|
1097
1405
|
if snap.chart is not None else ""
|
|
1098
1406
|
)
|
|
1099
1407
|
title_html = f'<h1 style="font-size:20px;color:{palette["fg"]};margin:0">{_xml_escape(snap.title)}</h1>'
|
|
@@ -1221,14 +1529,14 @@ def _build_md_frontmatter(snap: ShareSnapshot) -> str:
|
|
|
1221
1529
|
"""
|
|
1222
1530
|
period = snap.period
|
|
1223
1531
|
period_iso = (
|
|
1224
|
-
f"{period.start
|
|
1225
|
-
f"{period.end
|
|
1532
|
+
f"{_format_generated_at_iso(period.start)}.."
|
|
1533
|
+
f"{_format_generated_at_iso(period.end)}"
|
|
1226
1534
|
)
|
|
1227
1535
|
anonymized = "true" if _snapshot_is_anonymized(snap) else "false"
|
|
1228
1536
|
lines = [
|
|
1229
1537
|
"---",
|
|
1230
1538
|
f"title: {_yaml_scalar(snap.title)}",
|
|
1231
|
-
f"generated_at: {snap.generated_at
|
|
1539
|
+
f"generated_at: {_format_generated_at_iso(snap.generated_at)}",
|
|
1232
1540
|
f"period: {period_iso}",
|
|
1233
1541
|
f"panel: {snap.cmd}",
|
|
1234
1542
|
]
|
|
@@ -1260,16 +1568,20 @@ def _yaml_scalar(s: str) -> str:
|
|
|
1260
1568
|
|
|
1261
1569
|
|
|
1262
1570
|
def _snapshot_is_anonymized(snap: ShareSnapshot) -> bool:
|
|
1263
|
-
"""Return True if every
|
|
1571
|
+
"""Return True if every project label (cell or column) is anon or sentinel.
|
|
1264
1572
|
|
|
1265
1573
|
`_scrub` rewrites labels to `project-<N>` (1-indexed, cost-descending).
|
|
1266
|
-
A snapshot with no `ProjectCell` rows
|
|
1267
|
-
anonymized because there was nothing to
|
|
1268
|
-
project-share sentinel for missing
|
|
1269
|
-
`cmd_project`'s `_proj_label_for`) — it is never a
|
|
1270
|
-
so it is counted as also-anonymized. Mixed snapshots
|
|
1271
|
-
some revealed) are reported False to keep the
|
|
1272
|
-
("are projects revealed in this MD?").
|
|
1574
|
+
A snapshot with no `ProjectCell` rows AND no `kind='project'` columns
|
|
1575
|
+
returns False (nothing was anonymized because there was nothing to
|
|
1576
|
+
anonymize). `(unknown)` is the project-share sentinel for missing
|
|
1577
|
+
project_path (see `cmd_project`'s `_proj_label_for`) — it is never a
|
|
1578
|
+
revealed real name, so it is counted as also-anonymized. Mixed snapshots
|
|
1579
|
+
(some scrubbed, some revealed) are reported False to keep the
|
|
1580
|
+
frontmatter semantic ("are projects revealed in this MD?").
|
|
1581
|
+
|
|
1582
|
+
Cross-tab Detail templates (issue #33) carry project labels in
|
|
1583
|
+
`kind='project'` columns rather than `ProjectCell` rows; we walk both
|
|
1584
|
+
surfaces so MD frontmatter `anonymized:` stays correct for those panels.
|
|
1273
1585
|
"""
|
|
1274
1586
|
cells = [
|
|
1275
1587
|
cell
|
|
@@ -1277,11 +1589,16 @@ def _snapshot_is_anonymized(snap: ShareSnapshot) -> bool:
|
|
|
1277
1589
|
for cell in row.cells.values()
|
|
1278
1590
|
if isinstance(cell, ProjectCell)
|
|
1279
1591
|
]
|
|
1280
|
-
if
|
|
1592
|
+
project_cols = [col for col in snap.columns if col.kind == "project"]
|
|
1593
|
+
if not cells and not project_cols:
|
|
1281
1594
|
return False
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1595
|
+
|
|
1596
|
+
def _is_anon(label: str) -> bool:
|
|
1597
|
+
return bool(re.fullmatch(r"project-\d+", label)) or label == "(unknown)"
|
|
1598
|
+
|
|
1599
|
+
return (
|
|
1600
|
+
all(_is_anon(c.label) for c in cells)
|
|
1601
|
+
and all(_is_anon(col.label) for col in project_cols)
|
|
1285
1602
|
)
|
|
1286
1603
|
|
|
1287
1604
|
|
|
@@ -1443,8 +1760,9 @@ def _stitch_md(sections: tuple[ComposedSection, ...], *,
|
|
|
1443
1760
|
parts.append(
|
|
1444
1761
|
"---\n"
|
|
1445
1762
|
f"title: {_yaml_scalar(opts.title)}\n"
|
|
1446
|
-
f"generated_at: {first_snap.generated_at
|
|
1447
|
-
f"period: {earliest
|
|
1763
|
+
f"generated_at: {_format_generated_at_iso(first_snap.generated_at)}\n"
|
|
1764
|
+
f"period: {_format_generated_at_iso(earliest)}.."
|
|
1765
|
+
f"{_format_generated_at_iso(latest)}\n"
|
|
1448
1766
|
f"panel: composed\n"
|
|
1449
1767
|
f"anonymized: {anon_field}\n"
|
|
1450
1768
|
f"cctally_version: {first_snap.version}\n"
|