"""Project management API routes""" import os import uuid import shutil from flask import Blueprint, request from backend import db from backend.models import Project, User from backend.utils.helpers import ok, err from backend.utils.workspace import ( create_project_directory, delete_project_directory, get_project_path, save_uploaded_files, validate_path_in_project, ) bp = Blueprint("projects", __name__) @bp.route("/api/projects", methods=["GET"]) def list_projects(): """List all projects for a user""" user_id = request.args.get("user_id", type=int) if not user_id: return err(400, "Missing user_id parameter") projects = Project.query.filter_by(user_id=user_id).order_by(Project.updated_at.desc()).all() return ok({ "projects": [ { "id": p.id, "name": p.name, "path": p.path, "description": p.description, "created_at": p.created_at.isoformat() if p.created_at else None, "updated_at": p.updated_at.isoformat() if p.updated_at else None, "conversation_count": p.conversations.count() } for p in projects ], "total": len(projects) }) @bp.route("/api/projects", methods=["POST"]) def create_project(): """Create a new project""" data = request.get_json() if not data: return err(400, "No data provided") user_id = data.get("user_id") name = data.get("name", "").strip() description = data.get("description", "") if not user_id: return err(400, "Missing user_id") if not name: return err(400, "Project name is required") # Check if user exists user = User.query.get(user_id) if not user: return err(404, "User not found") # Check if project name already exists for this user existing = Project.query.filter_by(user_id=user_id, name=name).first() if existing: return err(400, f"Project '{name}' already exists") # Create project directory try: relative_path, absolute_path = create_project_directory(name, user_id) except Exception as e: return err(500, f"Failed to create project directory: {str(e)}") # Create project record project = Project( id=str(uuid.uuid4()), user_id=user_id, name=name, path=relative_path, description=description ) db.session.add(project) db.session.commit() return ok({ "id": project.id, "name": project.name, "path": project.path, "description": project.description, "created_at": project.created_at.isoformat() }) @bp.route("/api/projects/", methods=["GET"]) def get_project(project_id): """Get project details""" project = Project.query.get(project_id) if not project: return err(404, "Project not found") # Get absolute path absolute_path = get_project_path(project.id, project.path) # Get directory statistics file_count = sum(1 for _ in absolute_path.rglob("*") if _.is_file()) dir_count = sum(1 for _ in absolute_path.rglob("*") if _.is_dir()) total_size = sum(f.stat().st_size for f in absolute_path.rglob("*") if f.is_file()) return ok({ "id": project.id, "name": project.name, "path": project.path, "absolute_path": str(absolute_path), "description": project.description, "created_at": project.created_at.isoformat() if project.created_at else None, "updated_at": project.updated_at.isoformat() if project.updated_at else None, "conversation_count": project.conversations.count(), "stats": { "files": file_count, "directories": dir_count, "total_size": total_size } }) @bp.route("/api/projects/", methods=["PUT"]) def update_project(project_id): """Update project details""" project = Project.query.get(project_id) if not project: return err(404, "Project not found") data = request.get_json() if not data: return err(400, "No data provided") # Update name if provided if "name" in data: name = data["name"].strip() if not name: return err(400, "Project name cannot be empty") # Check if new name conflicts with existing project existing = Project.query.filter( Project.user_id == project.user_id, Project.name == name, Project.id != project_id ).first() if existing: return err(400, f"Project '{name}' already exists") project.name = name # Update description if provided if "description" in data: project.description = data["description"] db.session.commit() return ok({ "id": project.id, "name": project.name, "description": project.description, "updated_at": project.updated_at.isoformat() }) @bp.route("/api/projects/", methods=["DELETE"]) def delete_project(project_id): """Delete a project""" project = Project.query.get(project_id) if not project: return err(404, "Project not found") # Delete project directory try: delete_project_directory(project.path) except Exception as e: return err(500, f"Failed to delete project directory: {str(e)}") # Delete project record (cascades to conversations and messages) db.session.delete(project) db.session.commit() return ok({"message": "Project deleted successfully"}) @bp.route("/api/projects/upload", methods=["POST"]) def upload_project_folder(): """Upload a folder as a new project via file upload""" user_id = request.form.get("user_id", type=int) project_name = request.form.get("name", "").strip() description = request.form.get("description", "") files = request.files.getlist("files") if not user_id: return err(400, "Missing user_id") if not files: return err(400, "No files uploaded") if not project_name: # Use first file's top-level folder name project_name = files[0].filename.split("/")[0] if files[0].filename else "untitled" # Check if user exists user = User.query.get(user_id) if not user: return err(404, "User not found") # Check if project name already exists existing = Project.query.filter_by(user_id=user_id, name=project_name).first() if existing: return err(400, f"Project '{project_name}' already exists") # Create project directory first try: relative_path, absolute_path = create_project_directory(project_name, user_id) except Exception as e: return err(500, f"Failed to create project directory: {str(e)}") # Write uploaded files to project directory try: stats = save_uploaded_files(files, absolute_path) except Exception as e: shutil.rmtree(absolute_path, ignore_errors=True) return err(500, f"Failed to save uploaded files: {str(e)}") # Create project record project = Project( id=str(uuid.uuid4()), user_id=user_id, name=project_name, path=relative_path, description=description ) db.session.add(project) db.session.commit() return ok({ "id": project.id, "name": project.name, "path": project.path, "description": project.description, "created_at": project.created_at.isoformat(), "stats": stats }) @bp.route("/api/projects//files", methods=["GET"]) def list_project_files(project_id): """List files in a project directory""" project = Project.query.get(project_id) if not project: return err(404, "Project not found") project_dir = get_project_path(project.id, project.path) # Get subdirectory parameter subdir = request.args.get("path", "") try: target_dir = project_dir / subdir if subdir else project_dir target_dir = target_dir.resolve() # Validate path is within project target_dir.relative_to(project_dir.resolve()) except ValueError: return err(403, "Invalid path: outside project directory") if not target_dir.exists(): return err(404, "Directory not found") if not target_dir.is_dir(): return err(400, "Path is not a directory") # List files files = [] directories = [] try: for item in target_dir.iterdir(): # Skip hidden files if item.name.startswith("."): continue relative_path = item.relative_to(project_dir) if item.is_file(): files.append({ "name": item.name, "path": str(relative_path), "size": item.stat().st_size, "extension": item.suffix }) elif item.is_dir(): directories.append({ "name": item.name, "path": str(relative_path) }) except Exception as e: return err(500, f"Failed to list directory: {str(e)}") return ok({ "project_id": project_id, "current_path": str(subdir) if subdir else "/", "files": files, "directories": directories, "total_files": len(files), "total_dirs": len(directories) }) # --- REST file operation endpoints --- MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB read limit TEXT_EXTENSIONS = { ".py", ".js", ".ts", ".jsx", ".tsx", ".vue", ".html", ".css", ".scss", ".less", ".json", ".yaml", ".yml", ".toml", ".xml", ".csv", ".md", ".txt", ".log", ".sh", ".bash", ".zsh", ".bat", ".ps1", ".cmd", ".c", ".h", ".cpp", ".hpp", ".java", ".go", ".rs", ".rb", ".php", ".sql", ".r", ".swift", ".kt", ".dart", ".lua", ".pl", ".m", ".ini", ".cfg", ".conf", ".env", ".gitignore", ".dockerignore", ".dockerfile", ".makefile", ".cmake", ".gradle", ".properties", ".proto", ".graphql", ".tf", ".hcl", } def _resolve_file_path(project_id, filepath): """Resolve and validate a file path within a project directory.""" project = Project.query.get(project_id) if not project: return None, None, err(404, "Project not found") project_dir = get_project_path(project.id, project.path) try: target = validate_path_in_project(filepath, project_dir) except ValueError: return None, None, err(403, "Invalid path: outside project directory") return project_dir, target, None @bp.route("/api/projects//files/", methods=["GET"]) def read_project_file(project_id, filepath): """Read a single file's content (text only).""" project_dir, target, error = _resolve_file_path(project_id, filepath) if error: return error if not target.exists(): return err(404, "File not found") if not target.is_file(): return err(400, "Path is not a file") if target.stat().st_size > MAX_FILE_SIZE: return err(400, "File too large (max 5 MB)") try: content = target.read_text(encoding="utf-8") except UnicodeDecodeError: return err(400, "Binary file, cannot preview as text") return ok({ "name": target.name, "path": str(target.relative_to(project_dir)), "size": target.stat().st_size, "extension": target.suffix, "content": content, }) @bp.route("/api/projects//files/", methods=["PUT"]) def write_project_file(project_id, filepath): """Create or overwrite a file.""" data = request.get_json() if not data or "content" not in data: return err(400, "Missing 'content' in request body") project_dir, target, error = _resolve_file_path(project_id, filepath) if error: return error try: target.parent.mkdir(parents=True, exist_ok=True) target.write_text(data["content"], encoding="utf-8") except Exception as e: return err(500, f"Failed to write file: {str(e)}") return ok({ "name": target.name, "path": str(target.relative_to(project_dir)), "size": target.stat().st_size, }) @bp.route("/api/projects//files/", methods=["DELETE"]) def delete_project_file(project_id, filepath): """Delete a file or empty directory.""" project_dir, target, error = _resolve_file_path(project_id, filepath) if error: return error if not target.exists(): return err(404, "File not found") try: if target.is_dir(): shutil.rmtree(target) else: target.unlink() except Exception as e: return err(500, f"Failed to delete: {str(e)}") return ok({"message": f"Deleted '{filepath}'"}) @bp.route("/api/projects//files/mkdir", methods=["POST"]) def create_project_directory_endpoint(project_id): """Create a directory in the project.""" data = request.get_json() if not data or "path" not in data: return err(400, "Missing 'path' in request body") project_dir, target, error = _resolve_file_path(project_id, data["path"]) if error: return error try: target.mkdir(parents=True, exist_ok=True) except FileExistsError: return err(400, "Directory already exists") except Exception as e: return err(500, f"Failed to create directory: {str(e)}") return ok({ "path": str(target.relative_to(project_dir)), }) @bp.route("/api/projects//search", methods=["POST"]) def search_project_files(project_id): """Search file contents (grep-like).""" data = request.get_json() if not data or "query" not in data: return err(400, "Missing 'query' in request body") query = data["query"] subdir = data.get("path", "") max_results = min(data.get("max_results", 50), 200) case_sensitive = data.get("case_sensitive", False) project = Project.query.get(project_id) if not project: return err(404, "Project not found") project_dir = get_project_path(project.id, project.path) target_dir = project_dir / subdir if subdir else project_dir try: target_dir = target_dir.resolve() target_dir.relative_to(project_dir.resolve()) except ValueError: return err(403, "Invalid path: outside project directory") if not target_dir.exists(): return err(404, "Directory not found") import re flags = 0 if case_sensitive else re.IGNORECASE try: pattern = re.compile(re.escape(query), flags) except re.error: return err(400, "Invalid search pattern") results = [] try: for file_path in target_dir.rglob("*"): if len(results) >= max_results: break if not file_path.is_file(): continue if file_path.name.startswith("."): continue # Skip binary files by extension if file_path.suffix.lower() not in TEXT_EXTENSIONS and file_path.suffix != "": continue try: text = file_path.read_text(encoding="utf-8", errors="ignore") except Exception: continue matches = [] for i, line in enumerate(text.splitlines(), 1): if pattern.search(line): matches.append({"line": i, "content": line}) if sum(len(m.get("content", "")) for m in matches) > 10000: break if matches: results.append({ "path": str(file_path.relative_to(project_dir)), "matches": matches, }) except Exception as e: return err(500, f"Search failed: {str(e)}") return ok({ "query": query, "results": results, "total_matches": len(results), })