602 lines
19 KiB
Python
602 lines
19 KiB
Python
#!/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()
|
|
|