"""c_project_list / c_project_get / c_project_update (2026-08-03, v_project_list): 02-V originally declared "不做完整多项目管理 UI(项目列表/切换)" as an MVP non-goal -- this is a deliberate, explicit scope expansion beyond that, requested directly by the user rather than found while implementing something else. Same read/update-query pattern as task_query.py. "Delete" maps to `project.status = 'archived'`, never a physical row delete -- 01-M's own status enum (`active`/`archived`) already models this as a soft-delete concept, and `project_id` is referenced via FK from task/artifact/bug_record/work_graph_run/etc.; a literal DELETE would either violate those constraints or need a destructive cascade that throws away real audit history this codebase otherwise goes out of its way to preserve (Git-tracked artifacts, bug records, ...). `c_project_create` (project_scaffold.py) already handles "point at a folder that already has real code in it" correctly -- it only ever creates `.graphlemon/` inside the target folder and never touches anything else there -- so importing an existing codebase needs no changes here, just this module's read/update side. """ _VALID_STATUSES = ("active", "archived") class ProjectNotFoundError(Exception): """NOT_FOUND.""" class InvalidParamError(Exception): """INVALID_PARAM.""" def list_projects(conn): rows = conn.execute( "SELECT project_id, name, target_folder_path, has_git_repo, status, created_at " "FROM project ORDER BY created_at DESC" ).fetchall() projects = [] for r in rows: goal_count = conn.execute("SELECT COUNT(*) FROM task WHERE project_id = ?", (r[0],)).fetchone()[0] projects.append({ "project_id": r[0], "name": r[1], "target_folder_path": r[2], "has_git_repo": bool(r[3]), "status": r[4], "created_at": r[5], "goal_count": goal_count, }) return projects def get_project(conn, project_id): row = conn.execute( "SELECT project_id, name, target_folder_path, has_git_repo, graphlemon_subdir_initialized, " "status, created_at FROM project WHERE project_id = ?", (project_id,), ).fetchone() if row is None: raise ProjectNotFoundError(project_id) goal_count = conn.execute("SELECT COUNT(*) FROM task WHERE project_id = ?", (project_id,)).fetchone()[0] return { "project_id": row[0], "name": row[1], "target_folder_path": row[2], "has_git_repo": bool(row[3]), "graphlemon_subdir_initialized": bool(row[4]), "status": row[5], "created_at": row[6], "goal_count": goal_count, } def update_project(conn, project_id, name=None, status=None): """Returns the updated project (same shape as get_project). Either field may be omitted; a call with neither is a harmless no-op read.""" if conn.execute("SELECT 1 FROM project WHERE project_id = ?", (project_id,)).fetchone() is None: raise ProjectNotFoundError(project_id) if status is not None and status not in _VALID_STATUSES: raise InvalidParamError("status must be one of {}, got {!r}".format(_VALID_STATUSES, status)) if name is not None: if not name.strip(): raise InvalidParamError("name cannot be blank") conn.execute("UPDATE project SET name = ? WHERE project_id = ?", (name, project_id)) if status is not None: conn.execute("UPDATE project SET status = ? WHERE project_id = ?", (status, project_id)) conn.commit() return get_project(conn, project_id)