feat(scripts): סעיף "חד פעמי" נפרד עם תתי-נושאים בדף-הסקריפטים
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s

SCRIPTS.md: הסקריפטים החד-פעמיים (25, לפי עמודת Scheduled) הוצאו מסעיף
"## סקריפטים פעילים" אל סעיף חדש "## חד פעמי" בתחתית (לפני .archive),
מקובצים באותם תתי-כותרות ### לפי תת-נושא. כל 79 השורות (54 חוזרות + 25
חד-פעמיות) נשמרו מילה-במילה. הערת-הקונבנציה עודכנה.

/scripts: ה-parser מזהה את סעיף "## חד פעמי" כסקשן נפרד; הרינדור דו-רמתי —
כותרת-סעיף "פעילים" + קבוצות, ואז כותרת-סעיף "חד פעמי" + קבוצות-משנה
מוזחות (ms-5), מקופלות כברירת-מחדל. סטטוס-השורה נגזר מהסעיף.

מאושר דרך שער-העיצוב (מוקאפ 16-scripts עודכן; חיים אישר מראש).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 04:49:03 +00:00
parent e7124e94a3
commit 9d66ad4bf7
2 changed files with 147 additions and 73 deletions

View File

@@ -45,7 +45,8 @@ type ScriptRow = {
name: string;
role: string;
status: ScriptStatus;
group: string;
section: ScriptStatus; // which `## ` section the row lives in (= its status)
group: string; // the `### ` sub-topic within the section
};
const STATUS_LABEL: Record<ScriptStatus, string> = {
@@ -62,19 +63,29 @@ const STATUS_TONE: Record<ScriptStatus, { wrap: string; dot: string }> = {
deleted: { wrap: "bg-danger-bg text-danger", dot: "bg-danger" },
};
// "חד-פעמי" / "one-shot" markers inside the Scheduled column of an active row.
const ONCE_RE = /חד-?פעמי|one-?shot|בוצע/;
// Archived/deleted rows get their own collapsible groups; active rows are
// grouped by the `### <sub-topic>` headers maintained in SCRIPTS.md (#11).
// Archived/deleted rows get their own collapsible groups; active + one-time rows
// are grouped by the `### <sub-topic>` headers maintained in SCRIPTS.md, under
// their respective `## ` section ("סקריפטים פעילים" / "חד פעמי", #11 + one-time split).
const ARCHIVE_GROUP = "ארכיון";
const DELETED_GROUP = "נמחקו";
// Top-level sections in render order; "active"/"once" carry a section heading.
const SECTION_ORDER: ScriptStatus[] = ["active", "once", "archive", "deleted"];
const SECTION_META: Record<ScriptStatus, { label: string; note: string } | null> = {
active: { label: "פעילים", note: "סקריפטים חוזרים / קבועי-תפעול, מקובצים לפי תת-נושא." },
once: {
label: "חד פעמי",
note: "סקריפטים שהורצו פעם-אחת (מיגרציות / backfills / POCs), נשמרים לרפרנס.",
},
archive: null,
deleted: null,
};
/**
* Parse SCRIPTS.md markdown tables into typed rows. The file has three
* sections; the active section is further split into `### <sub-topic>` blocks.
* We read the first two columns (name + role), derive status from the section +
* scheduling note, and carry the sub-topic header as the row's display group.
* Parse SCRIPTS.md markdown tables into typed rows. The file has four `## `
* sections (פעילים / חד פעמי / .archive / נמחקו); the first two are split into
* `### <sub-topic>` blocks. The row's `section` is its status (and which heading
* it renders under); `group` is the sub-topic.
*/
function parseScripts(md: string): ScriptRow[] {
const lines = md.split("\n");
@@ -87,6 +98,7 @@ function parseScripts(md: string): ScriptRow[] {
if (line.startsWith("## ")) {
if (line.includes(".archive") || line.includes("הושלמו")) section = "archive";
else if (line.includes("נמחק")) section = "deleted";
else if (line.includes("חד פעמי")) section = "once";
else section = "active";
category = "";
continue;
@@ -109,38 +121,52 @@ function parseScripts(md: string): ScriptRow[] {
const name = cells[0].replace(/`/g, "");
if (!name) continue;
let status: ScriptStatus = section;
if (section === "active") {
const scheduled = cells[3] ?? "";
status = ONCE_RE.test(scheduled) ? "once" : "active";
}
// role: active = Purpose (col 2), archive = Original Purpose (col 1),
// role: active/once = Purpose (col 2), archive = Original Purpose (col 1),
// deleted = Reason (col 1).
const role =
section === "active" ? cells[2] ?? cells[1] ?? "" : cells[1] ?? "";
section === "active" || section === "once"
? cells[2] ?? cells[1] ?? ""
: cells[1] ?? "";
const group =
section === "archive" ? ARCHIVE_GROUP
: section === "deleted" ? DELETED_GROUP
: category || "כללי";
rows.push({ name, role: stripMd(role), status, group });
rows.push({ name, role: stripMd(role), status: section, section, group });
}
return rows;
}
/** Group rows by their display group, preserving first-seen order. */
function groupScripts(rows: ScriptRow[]): { title: string; rows: ScriptRow[] }[] {
const order: string[] = [];
const byGroup = new Map<string, ScriptRow[]>();
type SectionBlock = {
key: ScriptStatus;
groups: { title: string; rows: ScriptRow[] }[];
};
/** Group rows by section, then by sub-topic within each section, preserving
* first-seen order; sections themselves come back in SECTION_ORDER. */
function sectionize(rows: ScriptRow[]): SectionBlock[] {
const bySection = new Map<ScriptStatus, Map<string, ScriptRow[]>>();
for (const r of rows) {
if (!byGroup.has(r.group)) {
byGroup.set(r.group, []);
order.push(r.group);
let cats = bySection.get(r.section);
if (!cats) {
cats = new Map();
bySection.set(r.section, cats);
}
byGroup.get(r.group)!.push(r);
let g = cats.get(r.group);
if (!g) {
g = [];
cats.set(r.group, g);
}
g.push(r);
}
return order.map((title) => ({ title, rows: byGroup.get(title)! }));
return SECTION_ORDER.filter((k) => bySection.has(k)).map((key) => ({
key,
groups: [...bySection.get(key)!.entries()].map(([title, rs]) => ({
title,
rows: rs,
})),
}));
}
// Strip bold/inline-code markdown so the role reads as plain text in a cell.
@@ -261,7 +287,7 @@ function ScriptTable({
/** Collapsible sub-topic block (#11, mockup 16): parchment header with a
* chevron + title + count, and the group's table beneath. */
function ScriptGroup({
title, rows, giteaBase, defaultOpen, runnable, runningName, onRun,
title, rows, giteaBase, defaultOpen, runnable, runningName, onRun, indented,
}: {
title: string;
rows: ScriptRow[];
@@ -270,10 +296,15 @@ function ScriptGroup({
runnable: Set<string>;
runningName: string | null;
onRun: (name: string) => void;
indented?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<Card className="bg-surface border-rule shadow-sm overflow-hidden p-0">
<Card
className={`bg-surface border-rule shadow-sm overflow-hidden p-0 ${
indented ? "ms-5" : ""
}`}
>
<button
type="button"
onClick={() => setOpen((o) => !o)}
@@ -315,7 +346,7 @@ export default function ScriptsPage() {
() => (data?.content ? parseScripts(data.content) : []),
[data],
);
const groups = useMemo(() => groupScripts(rows), [rows]);
const sections = useMemo(() => sectionize(rows), [rows]);
const lastModified =
data?.last_modified != null
@@ -389,18 +420,32 @@ export default function ScriptsPage() {
</Card>
) : (
<div className="space-y-3.5">
{groups.map((g) => (
<ScriptGroup
key={g.title}
title={g.title}
rows={g.rows}
giteaBase={giteaBase}
defaultOpen={g.title !== ARCHIVE_GROUP && g.title !== DELETED_GROUP}
runnable={runnable}
runningName={runningName}
onRun={handleRun}
/>
))}
{sections.map((sec) => {
const meta = SECTION_META[sec.key];
return (
<div key={sec.key} className="space-y-3.5">
{meta ? (
<div className="flex items-baseline gap-3 flex-wrap border-t-2 border-rule pt-4 mt-2 first:border-t-0 first:pt-0 first:mt-0">
<h2 className="text-navy text-lg font-bold mb-0">{meta.label}</h2>
<span className="text-[0.78rem] text-ink-muted">{meta.note}</span>
</div>
) : null}
{sec.groups.map((g) => (
<ScriptGroup
key={`${sec.key}:${g.title}`}
title={g.title}
rows={g.rows}
giteaBase={giteaBase}
defaultOpen={sec.key === "active"}
indented={sec.key === "once"}
runnable={runnable}
runningName={runningName}
onRun={handleRun}
/>
))}
</div>
);
})}
{lastModified ? (
<p className="px-1 pt-1 text-xs text-ink-muted">
עודכן לאחרונה: {lastModified}