import os import re import requests from dotenv import load_dotenv class BasecampClient: """ Client for interacting with Basecamp 3 API using Basic Authentication or OAuth 2.0. """ def __init__(self, username=None, password=None, account_id=None, user_agent=None, access_token=None, auth_mode="basic"): """ Initialize the Basecamp client with credentials. Args: username (str, optional): Basecamp username (email) for Basic Auth password (str, optional): Basecamp password for Basic Auth account_id (str, optional): Basecamp account ID user_agent (str, optional): User agent for API requests access_token (str, optional): OAuth access token for OAuth Auth auth_mode (str, optional): Authentication mode ('basic' or 'oauth') """ # Load environment variables if not provided directly load_dotenv() self.auth_mode = auth_mode.lower() self.account_id = account_id or os.getenv('BASECAMP_ACCOUNT_ID') self.user_agent = user_agent or os.getenv('USER_AGENT') # Set up authentication based on mode if self.auth_mode == 'basic': self.username = username or os.getenv('BASECAMP_USERNAME') self.password = password or os.getenv('BASECAMP_PASSWORD') if not all([self.username, self.password, self.account_id, self.user_agent]): raise ValueError("Missing required credentials for Basic Auth. Set them in .env file or pass them to the constructor.") self.auth = (self.username, self.password) self.headers = { "User-Agent": self.user_agent, "Content-Type": "application/json" } elif self.auth_mode == 'oauth': self.access_token = access_token or os.getenv('BASECAMP_ACCESS_TOKEN') if not all([self.access_token, self.account_id, self.user_agent]): raise ValueError("Missing required credentials for OAuth. Set them in .env file or pass them to the constructor.") self.auth = None # No basic auth needed for OAuth self.headers = { "User-Agent": self.user_agent, "Content-Type": "application/json", "Authorization": f"Bearer {self.access_token}" } else: raise ValueError("Invalid auth_mode. Must be 'basic' or 'oauth'") # Basecamp 3 uses a different URL structure self.base_url = f"https://3.basecampapi.com/{self.account_id}" def test_connection(self): """Test the connection to Basecamp API.""" response = self.get('projects.json') if response.status_code == 200: return True, "Connection successful" else: return False, f"Connection failed: {response.status_code} - {response.text}" def get(self, endpoint, params=None): """Make a GET request to the Basecamp API.""" url = f"{self.base_url}/{endpoint}" return requests.get(url, auth=self.auth, headers=self.headers, params=params) def post(self, endpoint, data=None): """Make a POST request to the Basecamp API.""" url = f"{self.base_url}/{endpoint}" return requests.post(url, auth=self.auth, headers=self.headers, json=data) def put(self, endpoint, data=None): """Make a PUT request to the Basecamp API.""" url = f"{self.base_url}/{endpoint}" return requests.put(url, auth=self.auth, headers=self.headers, json=data) def delete(self, endpoint): """Make a DELETE request to the Basecamp API.""" url = f"{self.base_url}/{endpoint}" return requests.delete(url, auth=self.auth, headers=self.headers) def patch(self, endpoint, data=None): """Make a PATCH request to the Basecamp API.""" url = f"{self.base_url}/{endpoint}" return requests.patch(url, auth=self.auth, headers=self.headers, json=data) # Project methods def get_projects(self): """Get all projects.""" response = self.get('projects.json') if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get projects: {response.status_code} - {response.text}") def get_project(self, project_id): """Get a specific project by ID.""" response = self.get(f'projects/{project_id}.json') if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get project: {response.status_code} - {response.text}") # To-do list methods def get_todoset(self, project_id): """Get the todoset for a project (Basecamp 3 has one todoset per project).""" project = self.get_project(project_id) try: return next(_ for _ in project["dock"] if _["name"] == "todoset") except (IndexError, TypeError, StopIteration): raise Exception(f"Failed to get todoset for project: {project.id}. Project response: {project}") def get_todolists(self, project_id): """Get all todolists for a project.""" # First get the todoset ID for this project todoset = self.get_todoset(project_id) todoset_id = todoset['id'] # Then get all todolists in this todoset response = self.get(f'buckets/{project_id}/todosets/{todoset_id}/todolists.json') if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get todolists: {response.status_code} - {response.text}") def get_todolist(self, todolist_id): """Get a specific todolist.""" response = self.get(f'todolists/{todolist_id}.json') if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get todolist: {response.status_code} - {response.text}") # To-do methods def get_todos(self, project_id, todolist_id): """Get all todos in a todolist, handling pagination. Basecamp paginates list endpoints (commonly 15 items per page). This implementation follows pagination via the `page` query parameter and the HTTP `Link` header if present, aggregating all pages before returning the combined list. """ endpoint = f'buckets/{project_id}/todolists/{todolist_id}/todos.json' all_todos = [] page = 1 while True: response = self.get(endpoint, params={"page": page}) if response.status_code != 200: raise Exception(f"Failed to get todos: {response.status_code} - {response.text}") page_items = response.json() or [] all_todos.extend(page_items) # Check for next page using Link header or by empty result link_header = response.headers.get("Link", "") has_next = 'rel="next"' in link_header if link_header else False if not page_items or not has_next: break page += 1 return all_todos def get_todo(self, todo_id): """Get a specific todo.""" response = self.get(f'todos/{todo_id}.json') if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get todo: {response.status_code} - {response.text}") def create_todo(self, project_id, todolist_id, content, description=None, assignee_ids=None, completion_subscriber_ids=None, notify=False, due_on=None, starts_on=None): """ Create a new todo item in a todolist. Args: project_id (str): Project ID todolist_id (str): Todolist ID content (str): The todo item's text (required) description (str, optional): HTML description assignee_ids (list, optional): List of person IDs to assign completion_subscriber_ids (list, optional): List of person IDs to notify on completion notify (bool, optional): Whether to notify assignees due_on (str, optional): Due date in YYYY-MM-DD format starts_on (str, optional): Start date in YYYY-MM-DD format Returns: dict: The created todo """ endpoint = f'buckets/{project_id}/todolists/{todolist_id}/todos.json' data = {'content': content} if description is not None: data['description'] = description if assignee_ids is not None: data['assignee_ids'] = assignee_ids if completion_subscriber_ids is not None: data['completion_subscriber_ids'] = completion_subscriber_ids if notify is not None: data['notify'] = notify if due_on is not None: data['due_on'] = due_on if starts_on is not None: data['starts_on'] = starts_on response = self.post(endpoint, data) if response.status_code == 201: return response.json() else: raise Exception(f"Failed to create todo: {response.status_code} - {response.text}") def update_todo(self, project_id, todo_id, content=None, description=None, assignee_ids=None, completion_subscriber_ids=None, notify=None, due_on=None, starts_on=None): """ Update an existing todo item. Args: project_id (str): Project ID todo_id (str): Todo ID content (str, optional): The todo item's text description (str, optional): HTML description assignee_ids (list, optional): List of person IDs to assign completion_subscriber_ids (list, optional): List of person IDs to notify on completion notify (bool, optional): Whether to notify assignees due_on (str, optional): Due date in YYYY-MM-DD format starts_on (str, optional): Start date in YYYY-MM-DD format Returns: dict: The updated todo """ endpoint = f'buckets/{project_id}/todos/{todo_id}.json' data = {} if content is not None: data['content'] = content if description is not None: data['description'] = description if assignee_ids is not None: data['assignee_ids'] = assignee_ids if completion_subscriber_ids is not None: data['completion_subscriber_ids'] = completion_subscriber_ids if notify is not None: data['notify'] = notify if due_on is not None: data['due_on'] = due_on if starts_on is not None: data['starts_on'] = starts_on if not data: raise ValueError("No fields provided to update") response = self.put(endpoint, data) if response.status_code == 200: return response.json() else: raise Exception(f"Failed to update todo: {response.status_code} - {response.text}") def delete_todo(self, project_id, todo_id): """ Delete a todo item. Args: project_id (str): Project ID todo_id (str): Todo ID Returns: bool: True if successful """ endpoint = f'buckets/{project_id}/todos/{todo_id}.json' response = self.delete(endpoint) if response.status_code == 204: return True else: raise Exception(f"Failed to delete todo: {response.status_code} - {response.text}") def complete_todo(self, project_id, todo_id): """ Mark a todo as complete. Args: project_id (str): Project ID todo_id (str): Todo ID Returns: dict: Completion details """ endpoint = f'buckets/{project_id}/todos/{todo_id}/completion.json' response = self.post(endpoint) if response.status_code == 201: return response.json() else: raise Exception(f"Failed to complete todo: {response.status_code} - {response.text}") def uncomplete_todo(self, project_id, todo_id): """ Mark a todo as incomplete. Args: project_id (str): Project ID todo_id (str): Todo ID Returns: bool: True if successful """ endpoint = f'buckets/{project_id}/todos/{todo_id}/completion.json' response = self.delete(endpoint) if response.status_code == 204: return True else: raise Exception(f"Failed to uncomplete todo: {response.status_code} - {response.text}") # People methods def get_people(self): """Get all people in the account.""" response = self.get('people.json') if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get people: {response.status_code} - {response.text}") # Campfire (chat) methods def get_campfires(self, project_id): """Get the campfire for a project.""" response = self.get(f'buckets/{project_id}/chats.json') if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get campfire: {response.status_code} - {response.text}") def get_campfire_lines(self, project_id, campfire_id): """Get chat lines from a campfire.""" response = self.get(f'buckets/{project_id}/chats/{campfire_id}/lines.json') if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get campfire lines: {response.status_code} - {response.text}") # Message board methods def get_message_board(self, project_id): """Get the message board for a project.""" response = self.get(f'projects/{project_id}/message_board.json') if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get message board: {response.status_code} - {response.text}") def get_messages(self, project_id): """Get all messages for a project.""" # First get the message board ID message_board = self.get_message_board(project_id) message_board_id = message_board['id'] # Then get all messages response = self.get('messages.json', {'message_board_id': message_board_id}) if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get messages: {response.status_code} - {response.text}") # Schedule methods def get_schedule(self, project_id): """Get the schedule for a project.""" response = self.get(f'projects/{project_id}/schedule.json') if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get schedule: {response.status_code} - {response.text}") def get_schedule_entries(self, project_id): """ Get schedule entries for a project. Args: project_id (int): Project ID Returns: list: Schedule entries """ try: endpoint = f"buckets/{project_id}/schedules.json" schedule = self.get(endpoint) if isinstance(schedule, list) and len(schedule) > 0: schedule_id = schedule[0]['id'] entries_endpoint = f"buckets/{project_id}/schedules/{schedule_id}/entries.json" return self.get(entries_endpoint) else: return [] except Exception as e: raise Exception(f"Failed to get schedule: {str(e)}") # Comments methods def get_comments(self, project_id, recording_id, page=1): """ Get comments for a recording (todos, message, etc.). Args: project_id (int): Project/bucket ID. recording_id (int): ID of the recording (todos, message, etc.) page (int): Page number for pagination (default: 1). Basecamp uses geared pagination: page 1 has 15 results, page 2 has 30, page 3 has 50, page 4+ has 100. Returns: dict: Contains 'comments' list and pagination metadata: - comments: list of comments - total_count: total number of comments (from X-Total-Count header) - next_page: next page number if available, None otherwise """ if page < 1: raise ValueError("page must be >= 1") endpoint = f"buckets/{project_id}/recordings/{recording_id}/comments.json" response = self.get(endpoint, params={"page": page}) if response.status_code == 200: # Parse pagination headers total_count = response.headers.get('X-Total-Count') total_count = int(total_count) if total_count else None # Parse Link header for next page next_page = None link_header = response.headers.get('Link', '') # Split by comma to handle multiple links (e.g., rel="prev", rel="next") for link in link_header.split(','): if 'rel="next"' in link: match = re.search(r'page=(\d+)', link) if match: next_page = int(match.group(1)) break return { "comments": response.json(), "total_count": total_count, "next_page": next_page } else: raise Exception(f"Failed to get comments: {response.status_code} - {response.text}") def create_comment(self, recording_id, bucket_id, content): """ Create a comment on a recording. Args: recording_id (int): ID of the recording to comment on bucket_id (int): Project/bucket ID content (str): Content of the comment in HTML format Returns: dict: The created comment """ endpoint = f"buckets/{bucket_id}/recordings/{recording_id}/comments.json" data = {"content": content} response = self.post(endpoint, data) if response.status_code == 201: return response.json() else: raise Exception(f"Failed to create comment: {response.status_code} - {response.text}") def get_comment(self, comment_id, bucket_id): """ Get a specific comment. Args: comment_id (int): Comment ID bucket_id (int): Project/bucket ID Returns: dict: Comment details """ endpoint = f"buckets/{bucket_id}/comments/{comment_id}.json" response = self.get(endpoint) if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get comment: {response.status_code} - {response.text}") def update_comment(self, comment_id, bucket_id, content): """ Update a comment. Args: comment_id (int): Comment ID bucket_id (int): Project/bucket ID content (str): New content for the comment in HTML format Returns: dict: Updated comment """ endpoint = f"buckets/{bucket_id}/comments/{comment_id}.json" data = {"content": content} response = self.put(endpoint, data) if response.status_code == 200: return response.json() else: raise Exception(f"Failed to update comment: {response.status_code} - {response.text}") def delete_comment(self, comment_id, bucket_id): """ Delete a comment. Args: comment_id (int): Comment ID bucket_id (int): Project/bucket ID Returns: bool: True if successful """ endpoint = f"buckets/{bucket_id}/comments/{comment_id}.json" response = self.delete(endpoint) if response.status_code == 204: return True else: raise Exception(f"Failed to delete comment: {response.status_code} - {response.text}") def get_daily_check_ins(self, project_id, page=1): project = self.get_project(project_id) questionnaire = next(_ for _ in project["dock"] if _["name"] == "questionnaire") endpoint = f"buckets/{project_id}/questionnaires/{questionnaire['id']}/questions.json" response = self.get(endpoint, params={"page": page}) if response.status_code != 200: raise Exception("Failed to read questions") return response.json() def get_question_answers(self, project_id, question_id, page=1): endpoint = f"buckets/{project_id}/questions/{question_id}/answers.json" response = self.get(endpoint, params={"page": page}) if response.status_code != 200: raise Exception("Failed to read question answers") return response.json() # Card Table methods def get_card_tables(self, project_id): """Get all card tables for a project.""" project = self.get_project(project_id) try: return [item for item in project["dock"] if item.get("name") in ("kanban_board", "card_table")] except (IndexError, TypeError): return [] def get_card_table(self, project_id): """Get the first card table for a project (Basecamp 3 can have multiple card tables per project).""" card_tables = self.get_card_tables(project_id) if not card_tables: raise Exception(f"No card tables found for project: {project_id}") return card_tables[0] # Return the first card table def get_card_table_details(self, project_id, card_table_id): """Get details for a specific card table.""" response = self.get(f'buckets/{project_id}/card_tables/{card_table_id}.json') if response.status_code == 200: return response.json() elif response.status_code == 204: # 204 means "No Content" - return an empty structure return {"lists": [], "id": card_table_id, "status": "empty"} else: raise Exception(f"Failed to get card table: {response.status_code} - {response.text}") # Card Table Column methods def get_columns(self, project_id, card_table_id): """Get all columns in a card table.""" # Get the card table details which includes the lists (columns) card_table_details = self.get_card_table_details(project_id, card_table_id) return card_table_details.get('lists', []) def get_column(self, project_id, column_id): """Get a specific column.""" response = self.get(f'buckets/{project_id}/card_tables/columns/{column_id}.json') if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get column: {response.status_code} - {response.text}") def create_column(self, project_id, card_table_id, title): """Create a new column in a card table.""" data = {"title": title} response = self.post(f'buckets/{project_id}/card_tables/{card_table_id}/columns.json', data) if response.status_code == 201: return response.json() else: raise Exception(f"Failed to create column: {response.status_code} - {response.text}") def update_column(self, project_id, column_id, title): """Update a column title.""" data = {"title": title} response = self.put(f'buckets/{project_id}/card_tables/columns/{column_id}.json', data) if response.status_code == 200: return response.json() else: raise Exception(f"Failed to update column: {response.status_code} - {response.text}") def move_column(self, project_id, column_id, position, card_table_id): """Move a column to a new position.""" data = { "source_id": column_id, "target_id": card_table_id, "position": position } response = self.post(f'buckets/{project_id}/card_tables/{card_table_id}/moves.json', data) if response.status_code == 204: return True else: raise Exception(f"Failed to move column: {response.status_code} - {response.text}") def update_column_color(self, project_id, column_id, color): """Update a column color.""" data = {"color": color} response = self.patch(f'buckets/{project_id}/card_tables/columns/{column_id}/color.json', data) if response.status_code == 200: return response.json() else: raise Exception(f"Failed to update column color: {response.status_code} - {response.text}") def put_column_on_hold(self, project_id, column_id): """Put a column on hold.""" response = self.post(f'buckets/{project_id}/card_tables/columns/{column_id}/on_hold.json') if response.status_code == 204: return True else: raise Exception(f"Failed to put column on hold: {response.status_code} - {response.text}") def remove_column_hold(self, project_id, column_id): """Remove hold from a column.""" response = self.delete(f'buckets/{project_id}/card_tables/columns/{column_id}/on_hold.json') if response.status_code == 204: return True else: raise Exception(f"Failed to remove column hold: {response.status_code} - {response.text}") def watch_column(self, project_id, column_id): """Subscribe to column notifications.""" response = self.post(f'buckets/{project_id}/card_tables/lists/{column_id}/subscription.json') if response.status_code == 204: return True else: raise Exception(f"Failed to watch column: {response.status_code} - {response.text}") def unwatch_column(self, project_id, column_id): """Unsubscribe from column notifications.""" response = self.delete(f'buckets/{project_id}/card_tables/lists/{column_id}/subscription.json') if response.status_code == 204: return True else: raise Exception(f"Failed to unwatch column: {response.status_code} - {response.text}") # Card Table Card methods def get_cards(self, project_id, column_id): """Get all cards in a column.""" response = self.get(f'buckets/{project_id}/card_tables/lists/{column_id}/cards.json') if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get cards: {response.status_code} - {response.text}") def get_card(self, project_id, card_id): """Get a specific card.""" response = self.get(f'buckets/{project_id}/card_tables/cards/{card_id}.json') if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get card: {response.status_code} - {response.text}") def create_card(self, project_id, column_id, title, content=None, due_on=None, notify=False): """Create a new card in a column.""" data = {"title": title} if content: data["content"] = content if due_on: data["due_on"] = due_on if notify: data["notify"] = notify response = self.post(f'buckets/{project_id}/card_tables/lists/{column_id}/cards.json', data) if response.status_code == 201: return response.json() else: raise Exception(f"Failed to create card: {response.status_code} - {response.text}") def update_card(self, project_id, card_id, title=None, content=None, due_on=None, assignee_ids=None): """Update a card.""" data = {} if title: data["title"] = title if content: data["content"] = content if due_on: data["due_on"] = due_on if assignee_ids: data["assignee_ids"] = assignee_ids response = self.put(f'buckets/{project_id}/card_tables/cards/{card_id}.json', data) if response.status_code == 200: return response.json() else: raise Exception(f"Failed to update card: {response.status_code} - {response.text}") def move_card(self, project_id, card_id, column_id): """Move a card to a new column.""" data = {"column_id": column_id} response = self.post(f'buckets/{project_id}/card_tables/cards/{card_id}/moves.json', data) if response.status_code == 204: return True else: raise Exception(f"Failed to move card: {response.status_code} - {response.text}") def complete_card(self, project_id, card_id): """Mark a card as complete.""" response = self.post(f'buckets/{project_id}/todos/{card_id}/completion.json') if response.status_code == 201: return response.json() else: raise Exception(f"Failed to complete card: {response.status_code} - {response.text}") def uncomplete_card(self, project_id, card_id): """Mark a card as incomplete.""" response = self.delete(f'buckets/{project_id}/todos/{card_id}/completion.json') if response.status_code == 204: return True else: raise Exception(f"Failed to uncomplete card: {response.status_code} - {response.text}") # Card Steps methods def get_card_steps(self, project_id, card_id): """Get all steps (sub-tasks) for a card.""" card = self.get_card(project_id, card_id) return card.get('steps', []) def create_card_step(self, project_id, card_id, title, due_on=None, assignee_ids=None): """Create a new step (sub-task) for a card.""" data = {"title": title} if due_on: data["due_on"] = due_on if assignee_ids: data["assignee_ids"] = assignee_ids response = self.post(f'buckets/{project_id}/card_tables/cards/{card_id}/steps.json', data) if response.status_code == 201: return response.json() else: raise Exception(f"Failed to create card step: {response.status_code} - {response.text}") def get_card_step(self, project_id, step_id): """Get a specific card step.""" response = self.get(f'buckets/{project_id}/card_tables/steps/{step_id}.json') if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get card step: {response.status_code} - {response.text}") def update_card_step(self, project_id, step_id, title=None, due_on=None, assignee_ids=None): """Update a card step.""" data = {} if title: data["title"] = title if due_on: data["due_on"] = due_on if assignee_ids: data["assignee_ids"] = assignee_ids response = self.put(f'buckets/{project_id}/card_tables/steps/{step_id}.json', data) if response.status_code == 200: return response.json() else: raise Exception(f"Failed to update card step: {response.status_code} - {response.text}") def delete_card_step(self, project_id, step_id): """Delete a card step.""" response = self.delete(f'buckets/{project_id}/card_tables/steps/{step_id}.json') if response.status_code == 204: return True else: raise Exception(f"Failed to delete card step: {response.status_code} - {response.text}") def complete_card_step(self, project_id, step_id): """Mark a card step as complete.""" response = self.post(f'buckets/{project_id}/todos/{step_id}/completion.json') if response.status_code == 201: return response.json() else: raise Exception(f"Failed to complete card step: {response.status_code} - {response.text}") def uncomplete_card_step(self, project_id, step_id): """Mark a card step as incomplete.""" response = self.delete(f'buckets/{project_id}/todos/{step_id}/completion.json') if response.status_code == 204: return True else: raise Exception(f"Failed to uncomplete card step: {response.status_code} - {response.text}") # New methods for additional Basecamp API functionality def create_attachment(self, file_path, name, content_type="application/octet-stream"): """Upload an attachment and return the attachable sgid.""" with open(file_path, "rb") as f: data = f.read() headers = self.headers.copy() headers["Content-Type"] = content_type headers["Content-Length"] = str(len(data)) endpoint = f"attachments.json?name={name}" response = requests.post(f"{self.base_url}/{endpoint}", auth=self.auth, headers=headers, data=data) if response.status_code == 201: return response.json() else: raise Exception(f"Failed to create attachment: {response.status_code} - {response.text}") def get_events(self, project_id, recording_id): """Get events for a recording.""" endpoint = f"buckets/{project_id}/recordings/{recording_id}/events.json" response = self.get(endpoint) if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get events: {response.status_code} - {response.text}") def get_webhooks(self, project_id): """List webhooks for a project.""" endpoint = f"buckets/{project_id}/webhooks.json" response = self.get(endpoint) if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get webhooks: {response.status_code} - {response.text}") def create_webhook(self, project_id, payload_url, types=None): """Create a webhook for a project.""" data = {"payload_url": payload_url} if types: data["types"] = types endpoint = f"buckets/{project_id}/webhooks.json" response = self.post(endpoint, data) if response.status_code == 201: return response.json() else: raise Exception(f"Failed to create webhook: {response.status_code} - {response.text}") def delete_webhook(self, project_id, webhook_id): """Delete a webhook.""" endpoint = f"buckets/{project_id}/webhooks/{webhook_id}.json" response = self.delete(endpoint) if response.status_code == 204: return True else: raise Exception(f"Failed to delete webhook: {response.status_code} - {response.text}") def get_documents(self, project_id, vault_id): """List documents in a vault.""" endpoint = f"buckets/{project_id}/vaults/{vault_id}/documents.json" response = self.get(endpoint) if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get documents: {response.status_code} - {response.text}") def get_document(self, project_id, document_id): """Get a single document.""" endpoint = f"buckets/{project_id}/documents/{document_id}.json" response = self.get(endpoint) if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get document: {response.status_code} - {response.text}") def create_document(self, project_id, vault_id, title, content, status="active"): """Create a document in a vault.""" data = {"title": title, "content": content, "status": status} endpoint = f"buckets/{project_id}/vaults/{vault_id}/documents.json" response = self.post(endpoint, data) if response.status_code == 201: return response.json() else: raise Exception(f"Failed to create document: {response.status_code} - {response.text}") def update_document(self, project_id, document_id, title=None, content=None): """Update a document's title or content.""" data = {} if title: data["title"] = title if content: data["content"] = content endpoint = f"buckets/{project_id}/documents/{document_id}.json" response = self.put(endpoint, data) if response.status_code == 200: return response.json() else: raise Exception(f"Failed to update document: {response.status_code} - {response.text}") def trash_document(self, project_id, document_id): """Trash a document.""" endpoint = f"buckets/{project_id}/recordings/{document_id}/status/trashed.json" response = self.put(endpoint) if response.status_code == 204: return True else: raise Exception(f"Failed to trash document: {response.status_code} - {response.text}") # Upload methods def get_uploads(self, project_id, vault_id=None): """List uploads in a project or vault.""" if vault_id: endpoint = f"buckets/{project_id}/vaults/{vault_id}/uploads.json" else: endpoint = f"buckets/{project_id}/uploads.json" response = self.get(endpoint) if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get uploads: {response.status_code} - {response.text}") def get_upload(self, project_id, upload_id): """Get a single upload.""" endpoint = f"buckets/{project_id}/uploads/{upload_id}.json" response = self.get(endpoint) if response.status_code == 200: return response.json() else: raise Exception(f"Failed to get upload: {response.status_code} - {response.text}")