"""Labels service — shared business logic for source label management.""" from ..core.client import NotebookLMClient from ._compat import TypedDict from .errors import ServiceError, ValidationError class LabelInfo(TypedDict): """Details of a single label.""" id: str name: str emoji: str source_ids: list[str] class LabelListResult(TypedDict): """Result of listing or auto-labeling.""" notebook_id: str labels: list[LabelInfo] count: int class LabelMutateResult(TypedDict): """Result of a label mutation (rename, emoji, move-source, create).""" label_id: str message: str class LabelDeleteResult(TypedDict): """Result of deleting labels.""" deleted_label_ids: list[str] count: int message: str def _make_list_result(notebook_id: str, labels: list[dict]) -> LabelListResult: return { "notebook_id": notebook_id, "labels": labels, "count": len(labels), } def _require_notebook_id(notebook_id: str) -> None: if not notebook_id or not notebook_id.strip(): raise ValidationError( "notebook_id is required.", user_message="Notebook ID cannot be empty." ) def auto_label(client: NotebookLMClient, notebook_id: str) -> LabelListResult: """Auto-label all sources using AI-generated thematic categories. Requires 5+ sources. If labels already exist, returns the current state without re-running AI categorization. Args: client: Authenticated NotebookLM client notebook_id: Notebook UUID Returns: LabelListResult with auto-generated labels Raises: ServiceError: If the API call fails """ _require_notebook_id(notebook_id) try: labels = client.auto_label(notebook_id) except Exception as e: raise ServiceError(f"Failed to auto-label: {e}") from e return _make_list_result(notebook_id, labels) def list_labels(client: NotebookLMClient, notebook_id: str) -> LabelListResult: """List current labels. Triggers AI auto-labeling if none exist.""" return auto_label(client, notebook_id) def reorganize_labels( client: NotebookLMClient, notebook_id: str, unlabeled_only: bool = False, ) -> LabelListResult: """Force AI re-categorization of sources into new labels. Args: client: Authenticated NotebookLM client notebook_id: Notebook UUID unlabeled_only: If True, only label sources not yet in any label. If False (default), replace all existing labels from scratch. Returns: LabelListResult with the resulting label set Raises: ValidationError: If notebook_id is empty ServiceError: If the API call fails """ _require_notebook_id(notebook_id) try: labels = client.reorganize_labels(notebook_id, unlabeled_only=unlabeled_only) except Exception as e: raise ServiceError(f"Failed to reorganize labels: {e}") from e return _make_list_result(notebook_id, labels) def create_label( client: NotebookLMClient, notebook_id: str, name: str, emoji: str = "", ) -> LabelMutateResult: """Create a new empty label manually. Args: client: Authenticated NotebookLM client notebook_id: Notebook UUID name: Display name for the new label emoji: Optional emoji marker (e.g. "📊") Returns: LabelMutateResult with new label ID Raises: ValidationError: If name is empty ServiceError: If creation fails """ stripped = name.strip() if not stripped: raise ValidationError("Label name is required.", user_message="Label name cannot be empty.") try: labels = client.create_label(notebook_id, stripped, emoji) except Exception as e: raise ServiceError(f"Failed to create label: {e}") from e new_label = next((lbl for lbl in labels if lbl["name"] == stripped), None) label_id = new_label["id"] if new_label else "" return { "label_id": label_id, "message": f"Label '{stripped}' created." + (f" emoji: {emoji}" if emoji else ""), } def rename_label( client: NotebookLMClient, notebook_id: str, label_id: str, new_name: str, ) -> LabelMutateResult: """Rename an existing label. Args: client: Authenticated NotebookLM client notebook_id: Notebook UUID label_id: Label UUID to rename new_name: New display name Returns: LabelMutateResult Raises: ValidationError: If label_id or new_name is empty ServiceError: If rename fails """ if not label_id or not label_id.strip(): raise ValidationError("label_id is required.", user_message="Label ID cannot be empty.") if not new_name or not new_name.strip(): raise ValidationError( "new_name is required.", user_message="New label name cannot be empty." ) try: result = client.rename_label(notebook_id, label_id, new_name.strip()) except Exception as e: raise ServiceError(f"Failed to rename label: {e}") from e if not result: raise ServiceError("Rename returned falsy result", user_message="Failed to rename label.") return {"label_id": label_id, "message": f"Label renamed to '{new_name.strip()}'."} def set_label_emoji( client: NotebookLMClient, notebook_id: str, label_id: str, emoji: str, ) -> LabelMutateResult: """Set or clear the emoji marker on a label. Args: client: Authenticated NotebookLM client notebook_id: Notebook UUID label_id: Label UUID emoji: Emoji character or "" to clear Returns: LabelMutateResult Raises: ValidationError: If label_id is empty ServiceError: If the API call fails """ if not label_id or not label_id.strip(): raise ValidationError("label_id is required.", user_message="Label ID cannot be empty.") try: result = client.set_label_emoji(notebook_id, label_id, emoji) except Exception as e: raise ServiceError(f"Failed to set label emoji: {e}") from e if not result: raise ServiceError( "Set emoji returned falsy result", user_message="Failed to set label emoji." ) msg = f"Emoji set to '{emoji}'." if emoji else "Emoji cleared." return {"label_id": label_id, "message": msg} def move_source_to_label( client: NotebookLMClient, notebook_id: str, label_id: str, source_id: str, ) -> LabelMutateResult: """Assign a source to a label. Sources support multi-label assignment — this adds the source to the target label without removing it from any existing labels. Args: client: Authenticated NotebookLM client notebook_id: Notebook UUID label_id: Target label UUID source_id: Source UUID to assign Returns: LabelMutateResult Raises: ValidationError: If any required ID is empty ServiceError: If the API call fails """ if not label_id or not label_id.strip(): raise ValidationError("label_id is required.", user_message="Label ID cannot be empty.") if not source_id or not source_id.strip(): raise ValidationError("source_id is required.", user_message="Source ID cannot be empty.") try: result = client.move_source_to_label(notebook_id, label_id, source_id) except Exception as e: raise ServiceError(f"Failed to move source to label: {e}") from e if not result: raise ServiceError( "Move source returned falsy result", user_message="Failed to move source." ) return {"label_id": label_id, "message": f"Source {source_id[:8]}... assigned to label."} def delete_labels( client: NotebookLMClient, notebook_id: str, label_ids: list[str], ) -> LabelDeleteResult: """Delete one or more labels. Sources are NOT deleted. Args: client: Authenticated NotebookLM client notebook_id: Notebook UUID label_ids: List of label UUIDs to delete Returns: LabelDeleteResult Raises: ValidationError: If label_ids is empty ServiceError: If deletion fails """ if not label_ids: raise ValidationError( "label_ids is required.", user_message="Provide at least one label ID to delete." ) clean_ids = [lid.strip() for lid in label_ids if lid and lid.strip()] if not clean_ids: raise ValidationError( "label_ids must contain valid IDs.", user_message="No valid label IDs provided." ) try: result = client.delete_labels(notebook_id, clean_ids) except Exception as e: raise ServiceError(f"Failed to delete labels: {e}") from e if not result: raise ServiceError("Delete returned falsy result", user_message="Failed to delete labels.") count = len(clean_ids) noun = "label" if count == 1 else "labels" return { "deleted_label_ids": clean_ids, "count": count, "message": f"Deleted {count} {noun}. Sources are preserved.", }