TRUSS-1: init code commit of truss
This commit is contained in:
601
build.py
Normal file
601
build.py
Normal file
@@ -0,0 +1,601 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Arcline Kanban — multi-board Kanban with CRUD over markdown files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, jsonify, redirect, render_template_string, request, url_for
|
||||
from markupsafe import Markup
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
TEMPLATES_DIR = ROOT / "templates"
|
||||
OUTPUT_DIR = ROOT / "output"
|
||||
|
||||
# Board registry — name → file path
|
||||
BOARDS: dict[str, Path] = {
|
||||
"msp": ROOT / "TODO-msp.md",
|
||||
"os": ROOT / "TODO-os.md",
|
||||
}
|
||||
|
||||
app = Flask(__name__, template_folder=str(TEMPLATES_DIR))
|
||||
|
||||
# Per-board in-memory state — loaded on first access, synced on write
|
||||
_boards_data: dict[str, dict] = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Board helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_board(name: str) -> dict | None:
|
||||
"""Return in-memory data for *name*, loading from its file if needed."""
|
||||
if name not in BOARDS:
|
||||
return None
|
||||
if name not in _boards_data:
|
||||
_boards_data[name] = parse_todo(BOARDS[name])
|
||||
return _boards_data[name]
|
||||
|
||||
|
||||
def _find_card(board: str, card_id: str) -> tuple[dict, list[dict]] | None:
|
||||
"""Return (card, parent_column_cards_list) or None within *board*."""
|
||||
data = _get_board(board)
|
||||
if data is None:
|
||||
return None
|
||||
for col in data["columns"]:
|
||||
for card in col["cards"]:
|
||||
if card["id"] == card_id:
|
||||
return card, col["cards"]
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_board_files() -> None:
|
||||
"""Create any missing board markdown files from TODO.md."""
|
||||
original = ROOT / "TODO.md"
|
||||
if not original.exists():
|
||||
return
|
||||
|
||||
for name, path in BOARDS.items():
|
||||
if path.exists():
|
||||
continue
|
||||
data = parse_todo(original)
|
||||
|
||||
# Customize title per board
|
||||
if name == "msp":
|
||||
data["_header_raw"] = data["_header_raw"].replace(
|
||||
"# Arcline — TODO", "# Arcline MSP — TODO"
|
||||
).replace(
|
||||
"In-house Kanban board for **Arcline IT** ",
|
||||
"Kanban board for **Arcline MSP** — IT / managed-services track. ",
|
||||
)
|
||||
# Filter cards: keep IT and IT/OS
|
||||
for col in data["columns"]:
|
||||
col["cards"] = [
|
||||
c for c in col["cards"]
|
||||
if "IT" in c.get("track", "")
|
||||
]
|
||||
# Filter legend: keep IT, IT/OS
|
||||
data["legend"] = [
|
||||
r for r in data["legend"]
|
||||
if "IT" in r.get("track", "") or "OS" not in r.get("track", "")
|
||||
]
|
||||
elif name == "os":
|
||||
data["_header_raw"] = data["_header_raw"].replace(
|
||||
"# Arcline — TODO", "# Arcline OS — TODO"
|
||||
).replace(
|
||||
"In-house Kanban board for **Arcline IT** ",
|
||||
"Kanban board for **Arcline OS** — open-source / GPL track. ",
|
||||
)
|
||||
# Filter cards: keep OS and IT/OS
|
||||
for col in data["columns"]:
|
||||
col["cards"] = [
|
||||
c for c in col["cards"]
|
||||
if "OS" in c.get("track", "")
|
||||
]
|
||||
# Filter legend: keep OS, IT/OS
|
||||
data["legend"] = [
|
||||
r for r in data["legend"]
|
||||
if "OS" in r.get("track", "") or "IT" not in r.get("track", "")
|
||||
]
|
||||
|
||||
# Regenerate legend raw from filtered legend list
|
||||
if data["legend"]:
|
||||
legend_header = "| Codename | Initiative | Track | Status |"
|
||||
legend_sep = "|-------------|-----------------------------------------------|-------|-------------|"
|
||||
legend_rows = [
|
||||
f"| {r['codename']:<12} | {r['initiative']:<45} | {r['track']:<5} | {r['status']:<11} |"
|
||||
for r in data["legend"]
|
||||
]
|
||||
data["_legend_raw"] = "\n".join([legend_header, legend_sep] + legend_rows)
|
||||
|
||||
# Update notes
|
||||
data["_notes_raw"] = "## Notes\n\n- Board auto-generated from the master TODO.md\n"
|
||||
|
||||
_save_board(data, path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parser (parses a TODO.md file into structured data)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_todo(path: Path) -> dict:
|
||||
"""Parse a TODO.md file into a structured dictionary."""
|
||||
text = path.read_text()
|
||||
return {
|
||||
"header": _parse_header(text),
|
||||
"legend": _parse_legend(text),
|
||||
"columns": _parse_columns(text),
|
||||
"notes": _parse_notes(text),
|
||||
"_header_raw": _extract_header_raw(text),
|
||||
"_pre_legend_raw": _extract_pre_legend_raw(text),
|
||||
"_legend_raw": _extract_legend_raw(text),
|
||||
"_post_legend_raw": _extract_post_legend_raw(text),
|
||||
"_notes_raw": _extract_notes_raw(text),
|
||||
}
|
||||
|
||||
|
||||
def _parse_header(text: str) -> dict:
|
||||
lines = text.splitlines()
|
||||
title = ""
|
||||
subtitle = ""
|
||||
flow_line = ""
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("# ") and not title:
|
||||
title = stripped[2:].strip()
|
||||
for j in range(i + 1, min(i + 8, len(lines))):
|
||||
nxt = lines[j].strip()
|
||||
if nxt.startswith("#") or nxt.startswith("---"):
|
||||
break
|
||||
if not nxt:
|
||||
if subtitle:
|
||||
break
|
||||
continue
|
||||
if not subtitle:
|
||||
subtitle = nxt
|
||||
else:
|
||||
subtitle += " " + nxt
|
||||
elif "Backlog" in stripped and "In Progress" in stripped:
|
||||
flow_line = stripped
|
||||
break
|
||||
|
||||
return {"title": title, "subtitle": subtitle, "flow_line": flow_line}
|
||||
|
||||
|
||||
def _parse_legend(text: str) -> list[dict]:
|
||||
legends: list[dict] = []
|
||||
in_table = False
|
||||
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("| Codename"):
|
||||
in_table = True
|
||||
continue
|
||||
if in_table:
|
||||
if not stripped.startswith("|"):
|
||||
break
|
||||
if "---" in stripped:
|
||||
continue
|
||||
cells = [c.strip() for c in stripped.split("|")[1:-1]]
|
||||
if len(cells) >= 4:
|
||||
legends.append({
|
||||
"codename": cells[0],
|
||||
"initiative": cells[1],
|
||||
"track": cells[2],
|
||||
"status": cells[3],
|
||||
})
|
||||
return legends
|
||||
|
||||
|
||||
def _parse_columns(text: str) -> list[dict]:
|
||||
columns: list[dict] = []
|
||||
current_col: dict | None = None
|
||||
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
|
||||
if stripped.startswith("## Backlog"):
|
||||
current_col = {"name": "Backlog", "cards": []}
|
||||
columns.append(current_col)
|
||||
elif stripped.startswith("## In Progress"):
|
||||
current_col = {"name": "In Progress", "cards": []}
|
||||
columns.append(current_col)
|
||||
elif stripped.startswith("## In Review"):
|
||||
current_col = {"name": "In Review", "cards": []}
|
||||
columns.append(current_col)
|
||||
elif stripped.startswith("## Done"):
|
||||
current_col = {"name": "Done", "cards": []}
|
||||
columns.append(current_col)
|
||||
elif stripped.startswith("## ") and current_col and current_col["name"] != "Done":
|
||||
break
|
||||
|
||||
if current_col and _is_card_line(stripped):
|
||||
card = _parse_card(stripped)
|
||||
if card:
|
||||
current_col["cards"].append(card)
|
||||
|
||||
return columns
|
||||
|
||||
|
||||
def _is_card_line(line: str) -> bool:
|
||||
return line.startswith("- [ ]") or line.startswith("- [x]")
|
||||
|
||||
|
||||
def _parse_card(line: str) -> dict | None:
|
||||
id_match = re.search(r"<!--\s*id:(\S+)\s*-->", line)
|
||||
card_id = id_match.group(1) if id_match else str(uuid.uuid4())
|
||||
clean_line = re.sub(r"<!--\s*id:\S+\s*-->", "", line).strip()
|
||||
|
||||
checked = clean_line.startswith("- [x]")
|
||||
rest = re.sub(r"^- \[[ x]\]\s*", "", clean_line)
|
||||
|
||||
track_match = re.match(r"`\[([^\]]+)\]`", rest)
|
||||
track = track_match.group(1) if track_match else ""
|
||||
if track_match:
|
||||
rest = rest[track_match.end():].strip()
|
||||
|
||||
codename_match = re.match(r"\*\*([^*]+)\*\*", rest)
|
||||
codename = codename_match.group(1) if codename_match else ""
|
||||
if codename_match:
|
||||
rest = rest[codename_match.end():].strip()
|
||||
|
||||
rest = re.sub(r"^—\s*", "", rest)
|
||||
description = rest.strip()
|
||||
|
||||
return {
|
||||
"id": card_id,
|
||||
"track": track,
|
||||
"codename": codename,
|
||||
"description": description,
|
||||
"checked": checked,
|
||||
}
|
||||
|
||||
|
||||
def _parse_notes(text: str) -> list[str]:
|
||||
notes: list[str] = []
|
||||
in_notes = False
|
||||
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("## Notes"):
|
||||
in_notes = True
|
||||
continue
|
||||
if in_notes:
|
||||
if stripped.startswith("## "):
|
||||
break
|
||||
if stripped.startswith("- "):
|
||||
notes.append(stripped[2:].strip())
|
||||
|
||||
return notes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Raw-section extractors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract_header_raw(text: str) -> str:
|
||||
lines = text.splitlines()
|
||||
out: list[str] = []
|
||||
for line in lines:
|
||||
if line.strip().startswith("## Codename Legend"):
|
||||
break
|
||||
out.append(line)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _extract_pre_legend_raw(text: str) -> str:
|
||||
lines = text.splitlines()
|
||||
out: list[str] = []
|
||||
in_section = False
|
||||
for line in lines:
|
||||
if line.strip().startswith("## Codename Legend"):
|
||||
in_section = True
|
||||
if in_section:
|
||||
out.append(line)
|
||||
if line.strip().startswith("|"):
|
||||
break
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _extract_legend_raw(text: str) -> str:
|
||||
lines = text.splitlines()
|
||||
out: list[str] = []
|
||||
in_table = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("| Codename"):
|
||||
in_table = True
|
||||
out.append(line)
|
||||
continue
|
||||
if in_table:
|
||||
if stripped.startswith("|"):
|
||||
out.append(line)
|
||||
else:
|
||||
if stripped == "":
|
||||
out.append(line)
|
||||
else:
|
||||
break
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _extract_post_legend_raw(text: str) -> str:
|
||||
lines = text.splitlines()
|
||||
out: list[str] = []
|
||||
found_legend_end = False
|
||||
found_sep = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if found_legend_end and not found_sep:
|
||||
if stripped == "---":
|
||||
found_sep = True
|
||||
out.append(line)
|
||||
elif stripped == "":
|
||||
out.append(line)
|
||||
continue
|
||||
if found_sep:
|
||||
if stripped.startswith("## "):
|
||||
break
|
||||
out.append(line)
|
||||
continue
|
||||
if stripped.startswith("## Codename Legend"):
|
||||
found_legend_end = False
|
||||
if stripped.startswith("|"):
|
||||
found_legend_end = True
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _extract_notes_raw(text: str) -> str:
|
||||
lines = text.splitlines()
|
||||
out: list[str] = []
|
||||
in_notes = False
|
||||
for line in lines:
|
||||
if line.strip().startswith("## Notes"):
|
||||
in_notes = True
|
||||
if in_notes:
|
||||
out.append(line)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serializer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _card_to_md(card: dict) -> str:
|
||||
checked = "x" if card["checked"] else " "
|
||||
parts = [f"- [{checked}]"]
|
||||
if card.get("track"):
|
||||
parts.append(f"`[{card['track']}]`")
|
||||
if card.get("codename"):
|
||||
parts.append(f"**{card['codename']}**")
|
||||
if card.get("description"):
|
||||
parts.append(f"— {card['description']}")
|
||||
line = " ".join(parts)
|
||||
if card.get("id"):
|
||||
line += f" <!-- id:{card['id']} -->"
|
||||
return line
|
||||
|
||||
|
||||
def _save_board(data: dict, path: Path | None = None) -> None:
|
||||
"""Persist board data to its markdown file."""
|
||||
if path is None:
|
||||
return
|
||||
|
||||
sections: list[str] = []
|
||||
|
||||
sections.append(data["_header_raw"].rstrip())
|
||||
sections.append("")
|
||||
sections.append(data["_pre_legend_raw"].rstrip())
|
||||
sections.append("")
|
||||
sections.append(data["_legend_raw"].rstrip())
|
||||
sections.append("")
|
||||
sections.append(data["_post_legend_raw"].rstrip())
|
||||
|
||||
for col in data["columns"]:
|
||||
sections.append("")
|
||||
sections.append(f"## {col['name']}")
|
||||
sections.append("")
|
||||
for card in col["cards"]:
|
||||
sections.append(_card_to_md(card))
|
||||
|
||||
sections.append("")
|
||||
sections.append(data["_notes_raw"].rstrip())
|
||||
sections.append("")
|
||||
|
||||
path.write_text("\n".join(sections) + "\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jinja2 filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.template_filter("inline_md")
|
||||
def _inline_markdown(text: str) -> Markup:
|
||||
text = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", text)
|
||||
text = re.sub(r"\*([^*]+)\*", r"<em>\1</em>", text)
|
||||
text = re.sub(r"\[([^\]]+)]\(([^)]+)\)", r'<a href="\2">\1</a>', text)
|
||||
return Markup(text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes — pages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.route("/")
|
||||
def home():
|
||||
"""Board selector."""
|
||||
board_list = [
|
||||
{"slug": slug, "title": _get_board(slug)["header"]["title"] if _get_board(slug) else slug.upper()}
|
||||
for slug in BOARDS
|
||||
]
|
||||
return render_template_string(
|
||||
(TEMPLATES_DIR / "home.html").read_text(),
|
||||
boards=board_list,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/<board>")
|
||||
def board_page(board: str):
|
||||
"""Render a specific Kanban board."""
|
||||
data = _get_board(board)
|
||||
if data is None:
|
||||
return redirect(url_for("home"))
|
||||
return render_template_string(
|
||||
(TEMPLATES_DIR / "board.html").read_text(),
|
||||
board_slug=board,
|
||||
**{k: v for k, v in data.items() if not k.startswith("_")},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes — REST API (per-board)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.route("/api/<board>/board")
|
||||
def api_board(board: str):
|
||||
data = _get_board(board)
|
||||
if data is None:
|
||||
return jsonify({"ok": False, "error": "Unknown board"}), 404
|
||||
return jsonify({
|
||||
"header": data["header"],
|
||||
"legend": data["legend"],
|
||||
"columns": data["columns"],
|
||||
"notes": data["notes"],
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/<board>/cards", methods=["POST"])
|
||||
def create_card(board: str):
|
||||
data = _get_board(board)
|
||||
if data is None:
|
||||
return jsonify({"ok": False, "error": "Unknown board"}), 404
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
column_name = payload.get("column", "Backlog")
|
||||
card = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"track": payload.get("track", ""),
|
||||
"codename": payload.get("codename", ""),
|
||||
"description": payload.get("description", ""),
|
||||
"checked": bool(payload.get("checked", False)),
|
||||
}
|
||||
|
||||
for col in data["columns"]:
|
||||
if col["name"] == column_name:
|
||||
col["cards"].append(card)
|
||||
_save_board(data, BOARDS[board])
|
||||
return jsonify({"ok": True, "card": card}), 201
|
||||
|
||||
return jsonify({"ok": False, "error": f"Unknown column: {column_name}"}), 400
|
||||
|
||||
|
||||
@app.route("/api/<board>/cards/<card_id>", methods=["PUT"])
|
||||
def update_card(board: str, card_id: str):
|
||||
data = _get_board(board)
|
||||
if data is None:
|
||||
return jsonify({"ok": False, "error": "Unknown board"}), 404
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
result = _find_card(board, card_id)
|
||||
if result is None:
|
||||
return jsonify({"ok": False, "error": "Card not found"}), 404
|
||||
|
||||
card, _cards_list = result
|
||||
for field in ("track", "codename", "description", "checked"):
|
||||
if field in payload:
|
||||
card[field] = payload[field]
|
||||
|
||||
_save_board(data, BOARDS[board])
|
||||
return jsonify({"ok": True, "card": card})
|
||||
|
||||
|
||||
@app.route("/api/<board>/cards/<card_id>/move", methods=["POST"])
|
||||
def move_card(board: str, card_id: str):
|
||||
data = _get_board(board)
|
||||
if data is None:
|
||||
return jsonify({"ok": False, "error": "Unknown board"}), 404
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
target_column = payload.get("column")
|
||||
if not target_column:
|
||||
return jsonify({"ok": False, "error": "Missing 'column' field"}), 400
|
||||
|
||||
result = _find_card(board, card_id)
|
||||
if result is None:
|
||||
return jsonify({"ok": False, "error": "Card not found"}), 404
|
||||
|
||||
card, source_list = result
|
||||
target_list = None
|
||||
for col in data["columns"]:
|
||||
if col["name"] == target_column:
|
||||
target_list = col["cards"]
|
||||
break
|
||||
|
||||
if target_list is None:
|
||||
return jsonify({"ok": False, "error": f"Unknown column: {target_column}"}), 400
|
||||
|
||||
source_list.remove(card)
|
||||
target_list.append(card)
|
||||
_save_board(data, BOARDS[board])
|
||||
return jsonify({"ok": True, "card": card})
|
||||
|
||||
|
||||
@app.route("/api/<board>/cards/<card_id>", methods=["DELETE"])
|
||||
def delete_card(board: str, card_id: str):
|
||||
data = _get_board(board)
|
||||
if data is None:
|
||||
return jsonify({"ok": False, "error": "Unknown board"}), 404
|
||||
|
||||
result = _find_card(board, card_id)
|
||||
if result is None:
|
||||
return jsonify({"ok": False, "error": "Card not found"}), 404
|
||||
|
||||
card, cards_list = result
|
||||
cards_list.remove(card)
|
||||
_save_board(data, BOARDS[board])
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/api/<board>/reload", methods=["POST"])
|
||||
def reload_board(board: str):
|
||||
if board not in BOARDS:
|
||||
return jsonify({"ok": False, "error": "Unknown board"}), 404
|
||||
_boards_data.pop(board, None)
|
||||
_get_board(board)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
_ensure_board_files()
|
||||
|
||||
if "--build" in sys.argv:
|
||||
env = app.jinja_env
|
||||
template = env.from_string((TEMPLATES_DIR / "board.html").read_text())
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for slug in BOARDS:
|
||||
data = _get_board(slug)
|
||||
if data is None:
|
||||
continue
|
||||
html = template.render(
|
||||
board_slug=slug,
|
||||
**{k: v for k, v in data.items() if not k.startswith("_")},
|
||||
)
|
||||
(OUTPUT_DIR / f"{slug}.html").write_text(html)
|
||||
print(f"✅ Rendered {slug} → {OUTPUT_DIR / f'{slug}.html'}")
|
||||
else:
|
||||
print("🚀 Serving Kanban boards at http://127.0.0.1:5000")
|
||||
for slug in BOARDS:
|
||||
print(f" /{slug}")
|
||||
app.run(debug=True, host="127.0.0.1", port=5000)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user