diff --git a/src/blackboard/operations.py b/src/blackboard/operations.py index ac1c755..7c7c966 100644 --- a/src/blackboard/operations.py +++ b/src/blackboard/operations.py @@ -593,3 +593,268 @@ class Blackboard: conn.commit() finally: conn.close() + + +# ====================================================================== +# v2.7 Card 操作 +# ====================================================================== + +class CardOps: + """Card CRUD 操作""" + + def __init__(self, db_path: Path): + self.db_path = db_path + # 不调用 init_db,由 Blackboard 或 Ticker 负责初始化 + + def _conn(self) -> sqlite3.Connection: + return get_connection(self.db_path) + + def create_card(self, card: Card) -> Card: + """创建 Card""" + conn = self._conn() + try: + conn.execute("BEGIN IMMEDIATE") + conn.execute( + """INSERT INTO cards (id, name, description, card_type, stages_json, labels_json, status, created_at) + VALUES (?,?,?,?,?,?,?,?)""", + (card.id, card.name, card.description, card.card_type, + card.stages_json, card.labels_json, card.status, + card.created_at or datetime.utcnow().isoformat()), + ) + conn.commit() + return card + finally: + conn.close() + + def get_card(self, card_id: str) -> Optional[Card]: + """获取单个 Card""" + conn = self._conn() + try: + row = conn.execute( + "SELECT * FROM cards WHERE id=?", (card_id,) + ).fetchone() + return Card.from_row(row) if row else None + finally: + conn.close() + + def list_cards(self, status: Optional[str] = None) -> List[Card]: + """列出所有 Card""" + conn = self._conn() + try: + if status: + rows = conn.execute( + "SELECT * FROM cards WHERE status=? ORDER BY created_at ASC", + (status,), + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM cards ORDER BY created_at ASC" + ).fetchall() + return [Card.from_row(r) for r in rows] + finally: + conn.close() + + def update_card(self, card_id: str, **kwargs) -> bool: + """更新 Card 元数据""" + conn = self._conn() + try: + conn.execute("BEGIN IMMEDIATE") + existing = conn.execute( + "SELECT id FROM cards WHERE id=?", (card_id,) + ).fetchone() + if not existing: + return False + + allowed = {"name", "description", "card_type", "stages_json", + "labels_json", "status", "updated_at", "archived_at"} + updates = {k: v for k, v in kwargs.items() if k in allowed} + if not updates: + return True + + updates["updated_at"] = datetime.utcnow().isoformat() + set_clause = ", ".join(f"{k}= ?" for k in updates) + conn.execute( + f"UPDATE cards SET {set_clause} WHERE id=?", + (*updates.values(), card_id), + ) + conn.commit() + return True + finally: + conn.close() + + def delete_card(self, card_id: str) -> bool: + """删除 Card(只有 archived/deleted 状态可删)""" + conn = self._conn() + try: + conn.execute("BEGIN IMMEDIATE") + row = conn.execute( + "SELECT status FROM cards WHERE id=?", (card_id,) + ).fetchone() + if not row: + return False + if row["status"] not in ("archived", "deleted"): + return False + conn.execute("DELETE FROM cards WHERE id=?", (card_id,)) + conn.commit() + return True + finally: + conn.close() + + def fork_card(self, source_card_id: str, new_card_id: str, + new_name: str) -> Optional[Card]: + """Fork Card:复制 stages + 元数据,不复制 Task""" + source = self.get_card(source_card_id) + if not source: + return None + + new_card = Card( + id=new_card_id, + name=new_name, + description=f"Forked from {source.name} ({source_card_id})", + card_type=source.card_type, + stages_json=source.stages_json, + labels_json=source.labels_json, + status="active", + ) + return self.create_card(new_card) + + def compute_card_status(self, card_id: str) -> str: + """从 Task 聚合推导 Card 状态""" + conn = self._conn() + try: + # 检查 Card 是否有手动状态 + card = conn.execute( + "SELECT status FROM cards WHERE id=?", (card_id,) + ).fetchone() + if not card: + return "active" + + # 人工操作状态不参与推导 + if card["status"] in CARD_MANUAL_STATUSES: + return card["status"] + + # 聚合 Task 状态 + rows = conn.execute( + "SELECT status, COUNT(*) as cnt FROM tasks WHERE card_id=? " + "AND status != 'cancelled' GROUP BY status", + (card_id,), + ).fetchall() + + if not rows: + # 空 Card 或所有 Task 都 cancelled + return "active" + + status_counts = {r["status"]: r["cnt"] for r in rows} + total = sum(status_counts.values()) + done_count = status_counts.get("done", 0) + + # 所有 done → done + if done_count == total: + return "done" + + # 有 working/review/claimed → working + active_count = sum(status_counts.get(s, 0) + for s in ("working", "review", "claimed")) + if active_count > 0: + if "review" in status_counts: + return "review" + return "working" + + # 有 pending → pending + if status_counts.get("pending", 0) > 0: + return "pending" + + # 有 blocked → blocked + if status_counts.get("blocked", 0) > 0: + return "blocked" + + # 有 failed → failed + if status_counts.get("failed", 0) > 0: + return "failed" + + return "active" + finally: + conn.close() + + def refresh_card_status(self, card_id: str) -> str: + """刷新 Card 状态(从 Task 聚合推导并写入)""" + computed = self.compute_card_status(card_id) + self.update_card(card_id, status=computed) + return computed + + def card_progress(self, card_id: str) -> Dict[str, Any]: + """Card 进度信息(按 Stage 分组)""" + import json as _json + conn = self._conn() + try: + card = self.get_card(card_id) + if not card: + return {} + + stages = _json.loads(card.stages_json) + + # 总体统计 + total_row = conn.execute( + "SELECT COUNT(*) as cnt FROM tasks WHERE card_id=? AND status != 'cancelled'", + (card_id,), + ).fetchone() + done_row = conn.execute( + "SELECT COUNT(*) as cnt FROM tasks WHERE card_id=? AND status='done'", + (card_id,), + ).fetchone() + total = total_row["cnt"] if total_row else 0 + done = done_row["cnt"] if done_row else 0 + + # 按 stage 分组 + stage_progress = [] + for stage in stages: + stage_id = stage.get("id", stage.get("name", "")) + s_total = conn.execute( + "SELECT COUNT(*) as cnt FROM tasks WHERE card_id=? AND stage=? AND status != 'cancelled'", + (card_id, stage_id), + ).fetchone()["cnt"] + s_done = conn.execute( + "SELECT COUNT(*) as cnt FROM tasks WHERE card_id=? AND stage=? AND status='done'", + (card_id, stage_id), + ).fetchone()["cnt"] + s_active = conn.execute( + "SELECT COUNT(*) as cnt FROM tasks WHERE card_id=? AND stage=? AND status IN ('working','review','claimed')", + (card_id, stage_id), + ).fetchone()["cnt"] + stage_progress.append({ + "id": stage_id, + "name": stage.get("name", stage_id), + "total": s_total, + "done": s_done, + "active": s_active, + }) + + # 未分类 stage + uncategorized_total = conn.execute( + "SELECT COUNT(*) as cnt FROM tasks WHERE card_id=? AND stage IS NULL AND status != 'cancelled'", + (card_id,), + ).fetchone()["cnt"] + if uncategorized_total > 0: + uncategorized_done = conn.execute( + "SELECT COUNT(*) as cnt FROM tasks WHERE card_id=? AND stage IS NULL AND status='done'", + (card_id,), + ).fetchone()["cnt"] + stage_progress.append({ + "id": "_uncategorized", + "name": "未分类", + "total": uncategorized_total, + "done": uncategorized_done, + "active": 0, + }) + + return { + "card_id": card_id, + "card_name": card.name, + "card_status": card.status, + "total_tasks": total, + "done_tasks": done, + "progress_pct": round(done / total * 100) if total > 0 else 0, + "stages": stage_progress, + } + finally: + conn.close()