#!/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"", line) card_id = id_match.group(1) if id_match else str(uuid.uuid4()) clean_line = re.sub(r"", "", 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" " 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"\1", text) text = re.sub(r"\*([^*]+)\*", r"\1", text) text = re.sub(r"\[([^\]]+)]\(([^)]+)\)", r'\1', 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("/") 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") 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//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//cards/", 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//cards//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//cards/", 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//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()