From 586644bcff672bb293076973713672de8427c409 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 17 Jul 2026 18:08:24 +0200 Subject: [PATCH] feat: track task scope status and version --- docs/CARD.md | 35 +++++++++ react/src/main.tsx | 31 ++++++-- schemas/card-source.schema.json | 50 +++++++++++++ scripts/check_repository.py | 54 ++++++++++++++ tools/render_card.py | 125 ++++++++++++++++++++++++++------ 5 files changed, 264 insertions(+), 31 deletions(-) diff --git a/docs/CARD.md b/docs/CARD.md index a1c766d..5cb3723 100644 --- a/docs/CARD.md +++ b/docs/CARD.md @@ -45,6 +45,41 @@ której powstaje `https:///`. `show_repository_qr: true` wymaga `repository_url`. Adres podglądu `localhost` nie powinien być publikowany na karcie. +### Status i wersja tasków w zakresie karty + +Wiersze `front_page_scope.scope_table.rows[]` mogą opisywać stan opracowania +pojedynczego tasku. JSON przechowuje stabilny angielski klucz `status`, a +renderer pokazuje jego krótką polską etykietę w HTML i PDF. + +| Klucz | Etykieta | Znaczenie | +|---|---|---| +| `not-started` | brak | task nie został jeszcze opracowany | +| `draft` | robocze | istnieje niepełna wersja robocza | +| `in-progress` | w trakcie | task jest aktualnie opracowywany | +| `review` | do przeglądu | materiał czeka na sprawdzenie | +| `ready` | opracowane | materiał jest sprawdzony i gotowy do użycia | +| `blocked` | zablokowane | dalsza praca wymaga rozwiązania zależności | + +Pole `version` ma format `vNN.NN`, na przykład `v00.01`. Drugi człon +zwiększamy przy zaakceptowanej rewizji treści lub materiałów tasku. Pierwszy +człon zwiększamy, gdy zmienia się kontrakt dydaktyczny albo wymagany rezultat; +po takim zwiększeniu drugi człon zaczyna się od `00`. + +```json +{ + "chapter": "5.4", + "task": "Task04", + "idea_tex": "arytmetyka adresów, alokator", + "priority": "obowiązkowe", + "status": "in-progress", + "version": "v00.01", + "key": true +} +``` + +Status i wersję zmieniamy wyłącznie w źródłowym `card_source.json`; pliki +HTML, TeX i PDF pozostają artefaktami generatora. + ### Efekty i kryteria - `educational_requirements` — wymagania `WE`; diff --git a/react/src/main.tsx b/react/src/main.tsx index 6eed64a..875b516 100644 --- a/react/src/main.tsx +++ b/react/src/main.tsx @@ -1775,7 +1775,27 @@ function NeovimPanel({ ); } +function ScopeTableCell({ column, row }: { column: string; row: any }) { + if (column === "chapter" || column === "task" || column === "version") { + return {row[column]}; + } + if (column === "idea") { + return ; + } + if (column === "status") { + return ( + + + {row.status_label} + + + ); + } + return {row[column]}; +} + function FrontPage({ front }: { front: any }) { + const scopeColumns: string[] = front.scope_columns ?? ["chapter", "task", "idea", "priority"]; return ( - - {row.chapter} - - - {row.task} - - - {row.priority} + {scopeColumns.map(column => ( + + ))} ))} diff --git a/schemas/card-source.schema.json b/schemas/card-source.schema.json index 73c8004..5682eec 100644 --- a/schemas/card-source.schema.json +++ b/schemas/card-source.schema.json @@ -42,6 +42,9 @@ "title_block": { "$ref": "#/$defs/titleBlock" }, + "front_page_scope": { + "$ref": "#/$defs/frontPageScope" + }, "dictionary_refs": { "type": "object", "properties": { @@ -355,6 +358,53 @@ ], "additionalProperties": false }, + "frontPageScope": { + "type": "object", + "required": ["title", "content_tex"], + "properties": { + "title": { "type": "string", "minLength": 1 }, + "content_tex": { "type": "string" }, + "scope_title": { "type": "string", "minLength": 1 }, + "scope_content_tex": { "type": "string" }, + "scope_footer_tex": { "type": "string" }, + "scope_table": { "$ref": "#/$defs/scopeTable" } + }, + "additionalProperties": false + }, + "scopeTable": { + "type": "object", + "required": ["rows"], + "properties": { + "headers": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "rows": { + "type": "array", + "items": { "$ref": "#/$defs/scopeTaskRow" } + } + }, + "additionalProperties": false + }, + "scopeTaskRow": { + "type": "object", + "required": ["chapter", "idea_tex", "priority"], + "properties": { + "chapter": { "type": "string", "minLength": 1 }, + "task": { "type": "string" }, + "idea_tex": { "type": "string" }, + "priority": { "type": "string" }, + "status": { + "enum": ["not-started", "draft", "in-progress", "review", "ready", "blocked"] + }, + "version": { + "type": "string", + "pattern": "^v[0-9]{2}\\.[0-9]{2}$" + }, + "key": { "type": "boolean" } + }, + "additionalProperties": false + }, "sideMarginLayout": { "type": "object", "required": ["columns"], diff --git a/scripts/check_repository.py b/scripts/check_repository.py index 55f50cd..131b6be 100644 --- a/scripts/check_repository.py +++ b/scripts/check_repository.py @@ -16,6 +16,14 @@ SCHEMAS = ROOT / "schemas" TEMPLATES = ROOT / "templates" EXAMPLE_CARD = ROOT / "examples" / "card" EXAMPLE_SERIES = ROOT / "examples" / "series" / "series.json" +sys.path.insert(0, str(ROOT)) + +from tools.render_card import ( # noqa: E402 + SCOPE_TASK_STATUS_LABELS, + scope_table_columns, + scope_task_status_label, + scope_task_version, +) def load_json(path: Path) -> dict: @@ -47,6 +55,51 @@ def check_json_files() -> None: assert series.get("cards") +def check_scope_task_metadata() -> None: + rows = [ + { + "chapter": "5.4", + "task": "Task04", + "idea_tex": "alokator", + "priority": "obowiązkowe", + "status": "in-progress", + "version": "v00.01", + } + ] + assert scope_table_columns(rows) == [ + "chapter", + "task", + "idea", + "priority", + "status", + "version", + ] + assert SCOPE_TASK_STATUS_LABELS == { + "not-started": "brak", + "draft": "robocze", + "in-progress": "w trakcie", + "review": "do przeglądu", + "ready": "opracowane", + "blocked": "zablokowane", + } + assert scope_task_status_label("in-progress") == "w trakcie" + assert scope_task_version("v00.01") == "v00.01" + for invalid in ("started", "done"): + try: + scope_task_status_label(invalid) + except ValueError: + pass + else: + raise AssertionError(f"status {invalid!r} nie został odrzucony") + for invalid in ("0.1", "v0.01", "v00.1"): + try: + scope_task_version(invalid) + except ValueError: + pass + else: + raise AssertionError(f"wersja {invalid!r} nie została odrzucona") + + def check_optional_json_schema() -> None: try: import jsonschema @@ -269,6 +322,7 @@ def check_external_references() -> None: def main() -> int: check_json_files() + check_scope_task_metadata() check_optional_json_schema() check_render() check_section_assets() diff --git a/tools/render_card.py b/tools/render_card.py index dad7edf..8571347 100644 --- a/tools/render_card.py +++ b/tools/render_card.py @@ -40,6 +40,15 @@ UUID_RE = re.compile( UUID_PATH_RE = re.compile( r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" ) +SCOPE_TASK_STATUS_LABELS = { + "not-started": "brak", + "draft": "robocze", + "in-progress": "w trakcie", + "review": "do przeglądu", + "ready": "opracowane", + "blocked": "zablokowane", +} +SCOPE_TASK_VERSION_RE = re.compile(r"^v\d{2}\.\d{2}$") def configure_card_root(card_root: Path) -> None: @@ -850,25 +859,72 @@ def render_front_matter(card: dict) -> list[str]: return lines +def scope_task_status_label(value: object) -> str: + status = str(value or "").strip() + if not status: + return "" + try: + return SCOPE_TASK_STATUS_LABELS[status] + except KeyError as error: + allowed = ", ".join(SCOPE_TASK_STATUS_LABELS) + raise ValueError(f"nieznany status tasku {status!r}; dozwolone: {allowed}") from error + + +def scope_task_version(value: object) -> str: + version = str(value or "").strip() + if version and not SCOPE_TASK_VERSION_RE.fullmatch(version): + raise ValueError( + f"niepoprawna wersja tasku {version!r}; oczekiwany format v00.01" + ) + return version + + +def scope_table_columns(rows: list[dict]) -> list[str]: + columns = ["chapter"] + if any(str(row.get("task", "")).strip() for row in rows): + columns.append("task") + columns.extend(["idea", "priority"]) + if any(str(row.get("status", "")).strip() for row in rows): + columns.append("status") + if any(str(row.get("version", "")).strip() for row in rows): + columns.append("version") + return columns + + +def scope_table_default_headers(columns: list[str]) -> list[str]: + labels = { + "chapter": "K&R", + "task": "Numer tasku", + "idea": "Najważniejsza idea", + "priority": "Priorytet", + "status": "Status", + "version": "Version", + } + return [labels[column] for column in columns] + + def render_front_scope_table_tex(scope: dict) -> list[str]: table = scope.get("scope_table") or {} rows = table.get("rows") or [] if not rows: return [] - has_task_column = any(str(row.get("task", "")).strip() for row in rows) - default_headers = ( - ["K&R", "Numer tasku", "Najważniejsza idea", "Priorytet"] - if has_task_column - else ["K&R", "Najważniejsza idea", "Priorytet"] - ) + columns = scope_table_columns(rows) + has_task_column = "task" in columns + has_status_column = "status" in columns + has_version_column = "version" in columns + default_headers = scope_table_default_headers(columns) headers = table.get("headers") or default_headers if len(headers) != len(default_headers): headers = default_headers - column_spec = ( - r"@{}>{\ttfamily}p{1.05cm}>{\ttfamily}p{1.55cm}X>{\raggedright\arraybackslash}p{3.25cm}@{}" - if has_task_column - else r"@{}>{\ttfamily}p{1.25cm}X>{\raggedright\arraybackslash}p{3.55cm}@{}" - ) + column_parts = [r">{\ttfamily\raggedright\arraybackslash}p{0.85cm}"] + if has_task_column: + column_parts.append(r">{\ttfamily\raggedright\arraybackslash}p{1.45cm}") + column_parts.extend(["X", r">{\raggedright\arraybackslash}p{2.45cm}"]) + if has_status_column: + column_parts.append(r">{\raggedright\arraybackslash}p{1.75cm}") + if has_version_column: + column_parts.append(r">{\ttfamily\raggedright\arraybackslash}p{1.50cm}") + column_spec = "@{}" + "".join(column_parts) + "@{}" lines = [ r"\vspace{0.55em}", rf"\small\begin{{tabularx}}{{\textwidth}}{{{column_spec}}}", @@ -879,7 +935,12 @@ def render_front_scope_table_tex(scope: dict) -> list[str]: if has_task_column: cells.append(rf"\texttt{{{tex_escape(row.get('task', ''))}}}") cells.extend([row.get("idea_tex", ""), tex_escape(row.get("priority", ""))]) - if str(row.get("task", "")).strip().lower() == "task04": + if has_status_column: + cells.append(tex_escape(scope_task_status_label(row.get("status", "")))) + if has_version_column: + cells.append(rf"\texttt{{{tex_escape(scope_task_version(row.get('version', '')))}}}") + is_key = bool(row.get("key", str(row.get("task", "")).strip().lower() == "task04")) + if is_key: framed_cells = [] for index, cell in enumerate(cells): alignment = "|l|" if index == 0 else "l|" @@ -3080,23 +3141,32 @@ def render_main_html(data: dict) -> str: if scope.get("scope_title"): table = scope.get("scope_table") or {} rows = table.get("rows") or [] - has_task_column = any(str(row.get("task", "")).strip() for row in rows) - default_headers = ( - ["K&R", "Numer tasku", "Najważniejsza idea", "Priorytet"] - if has_task_column - else ["K&R", "Najważniejsza idea", "Priorytet"] - ) + columns = scope_table_columns(rows) + has_task_column = "task" in columns + has_status_column = "status" in columns + has_version_column = "version" in columns + default_headers = scope_table_default_headers(columns) headers = table.get("headers") or default_headers if len(headers) != len(default_headers): headers = default_headers scope_table_html = "" if rows: table_rows = "".join( - f'' + f'' f'{html_escape(row.get("chapter", ""))}' + (f'{html_escape(row.get("task", ""))}' if has_task_column else "") + f'{latex_inline_to_html(row.get("idea_tex", ""), equation_numbers, figure_numbers)}' - + f'{html_escape(row.get("priority", ""))}' + + f'{html_escape(row.get("priority", ""))}' + + ( + f'' + f'{html_escape(scope_task_status_label(row.get("status", "")))}' + if has_status_column else "" + ) + + ( + f'{html_escape(scope_task_version(row.get("version", "")))}' + if has_version_column else "" + ) + + '' for row in rows ) table_class = "scope-table scope-table--task" if has_task_column else "scope-table" @@ -3133,7 +3203,7 @@ def render_main_html(data: dict) -> str: .hero h1{font-size:24px;line-height:1.15;margin:0 0 4px;border-bottom:1px solid #111;padding-bottom:4px}.hero .byline{float:right;margin-top:-31px;font-size:15px} .meta{clear:both;display:grid;grid-template-columns:148px minmax(0,1fr);gap:4px 18px;padding:34px 0 24px;border-bottom:1px solid #111;font-size:17px}.meta dt{color:#333}.meta dd{margin:0;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace} .front-scope{padding:0 0 3mm;border-bottom:1px solid var(--line-soft)}.front-scope h2{font-size:24px;line-height:1.15;margin:0 0 3mm}.front-scope p{font-size:16px} -.scope-table-wrap{margin:3mm 0;overflow:hidden}.scope-table{width:100%;border-collapse:collapse;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:11px;line-height:1.22}.scope-table th,.scope-table td{border:0;border-bottom:1px solid #888;padding:4px 7px;text-align:left;vertical-align:top}.scope-table th{border-bottom:2px solid #555;background:#f3f3f3;font-weight:700}.scope-table th:first-child,.scope-table td:first-child{width:12%}.scope-table th:last-child,.scope-table td:last-child{width:34%}.scope-table--task th:first-child,.scope-table--task td:first-child{width:9%}.scope-table--task th:nth-child(2),.scope-table--task td:nth-child(2){width:14%}.scope-table--task th:last-child,.scope-table--task td:last-child{width:29%}.scope-table tr.scope-row--key td{font-weight:700;background:#f4f4f4;border-top:2px solid #111;border-bottom:2px solid #111}.scope-table tr.scope-row--key td:first-child{border-left:2px solid #111}.scope-table tr.scope-row--key td:last-child{border-right:2px solid #111}.scope-table td p{font:inherit;margin:0}.scope-table code{font-size:inherit} +.scope-table-wrap{margin:3mm 0;overflow:hidden}.scope-table{width:100%;border-collapse:collapse;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:11px;line-height:1.22}.scope-table th,.scope-table td{border:0;border-bottom:1px solid #888;padding:4px 7px;text-align:left;vertical-align:top}.scope-table th{border-bottom:2px solid #555;background:#f3f3f3;font-weight:700}.scope-table th:first-child,.scope-table td:first-child{width:7%}.scope-table--task th:nth-child(2),.scope-table--task td:nth-child(2){width:11%}.scope-table--task th:nth-child(4),.scope-table--task td:nth-child(4){width:19%}.scope-table--task th:nth-child(5),.scope-table--task td:nth-child(5){width:14%}.scope-table--task th:nth-child(6),.scope-table--task td:nth-child(6){width:10%}.scope-table tr.scope-row--key td{font-weight:700;background:#f4f4f4;border-top:2px solid #111;border-bottom:2px solid #111}.scope-table tr.scope-row--key td:first-child{border-left:2px solid #111}.scope-table tr.scope-row--key td:last-child{border-right:2px solid #111}.scope-table td p{font:inherit;margin:0}.scope-table code{font-size:inherit}.scope-status{display:inline-block;white-space:nowrap;border:1px solid #aaa;border-radius:2px;padding:1px 4px;font-weight:600;background:#f5f5f5}.scope-status[data-status="in-progress"]{border-color:#307da1;background:#eaf5fa;color:#174f69}.scope-status[data-status="ready"]{border-color:#4c7d56;background:#edf6ee;color:#295b33}.scope-status[data-status="draft"],.scope-status[data-status="review"]{border-color:#9a7932;background:#faf4e5;color:#694f16}.scope-status[data-status="blocked"]{border-color:#9c4b4b;background:#faeded;color:#712f2f} .card-section h2{font-size:24px;line-height:1.15;margin:0 0 4mm}.section-heading{display:flex;align-items:flex-start;gap:0}.section-heading .section-index{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:23px;margin-right:12mm;font-weight:400}.section-heading .section-title{min-width:0}.task-identity{margin-left:auto;padding-left:22px;text-align:right;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;line-height:1.25;color:#555;white-space:nowrap}.task-identity strong,.task-identity-name,.task-identity code{display:block}.task-identity strong{font-size:12px;color:#111}.task-identity-name{max-width:240px;overflow:hidden;text-overflow:ellipsis}.task-identity code{font-size:9px;color:#777} p{font-size:16px;margin:0 0 3mm}.pdf-main ul{font-size:16px;margin:2mm 0 3mm 7mm;padding:0}.pdf-main li{margin:1.2mm 0} .pdf-margin{position:absolute;top:34mm;width:18mm;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:5px;line-height:1.12;color:#333;max-height:245mm;overflow:hidden}.pdf-margin.left{left:2mm;text-align:left}.pdf-margin.right{right:2mm;text-align:left} @@ -7182,6 +7252,11 @@ def react_card_model(data: dict) -> dict: ) scope = data.get("front_page_scope", {}) or {} scope_table = scope.get("scope_table", {}) or {} + scope_rows = scope_table.get("rows", []) or [] + scope_columns = scope_table_columns(scope_rows) + scope_headers = list(scope_table.get("headers", [])) + if len(scope_headers) != len(scope_columns): + scope_headers = scope_table_default_headers(scope_columns) front = { "header_html": render_page_header_html(data, 1, total_pages), "left_margin_html": front_left, @@ -7194,7 +7269,8 @@ def react_card_model(data: dict) -> dict: "scope_html": render_latex_fragment_html( str(scope.get("scope_content_tex", "")), equation_numbers, figure_numbers ), - "scope_headers": list(scope_table.get("headers", [])), + "scope_columns": scope_columns, + "scope_headers": scope_headers, "scope_rows": [ { "chapter": str(row.get("chapter", "")), @@ -7203,9 +7279,12 @@ def react_card_model(data: dict) -> dict: str(row.get("idea_tex", row.get("idea", ""))), equation_numbers, figure_numbers ), "priority": str(row.get("priority", "")), + "status": str(row.get("status", "")), + "status_label": scope_task_status_label(row.get("status", "")), + "version": scope_task_version(row.get("version", "")), "key": bool(row.get("key", str(row.get("task", "")).lower() == "task04")), } - for row in scope_table.get("rows", []) or [] + for row in scope_rows ], } section_models: list[dict] = []