---
# System prepended metadata

title: 'Cycle Overview Table (generator script) '
tags: [script]

---

## How to generate the table

- Make sure [`uv`](https://docs.astral.sh/uv/getting-started/installation/) is installed in your machine
- Download the python script below and save it as `hackmd_table_generator.py`
- Make it executable (`chmod a+x hackmd_table_generator.py`)
- Export an `HACKMD_API_TOKEN` environment variable with the HackMD API key (`export HACKMD_API_TOKEN=xxxxxxxxxxxxxxx`)
- Run the script with the proper arguments. For example:
    ```bash!
    ./hackmd_table_generator.py --team gridtools --tags "cycle 30 07/25" --columns "Appetite,Developers,Support" --exclude 'OVERVIEW.*,.*[Bb]rainstorming.*' -o cycle30.md
    ```

## `hackmd_table_generator.py` script

```python!
#!/usr/bin/env -S uv run -q --script
# /// script
# requires-python = ">=3.10"
# dependencies = [
#     "requests>=2.25.0",
#     "typer>=0.9.0",
# ]
# ///

import os
import sys
import requests
import typer
import re
from typing import Any


class HackMDAPI:
    def __init__(self, token: str):
        self.token = token
        self.base_url = "https://api.hackmd.io/v1"
        self.headers = {
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json"
        }

    def get_team_notes(self, team_path: str, tags: list[str]) -> list[dict[str, Any]]:
        """Get notes from a team that contain all specified tags."""
        url = f"{self.base_url}/teams/{team_path}/notes"

        try:
            response = requests.get(url, headers=self.headers)
            response.raise_for_status()
            notes = response.json()

            # Filter notes by tags
            filtered_notes = []
            for note in notes:
                note_tags = note.get('tags', [])
                if all(tag in note_tags for tag in tags):
                    filtered_notes.append(note)

            return filtered_notes
        except requests.exceptions.RequestException as e:
            typer.echo(f"Error fetching notes: {e}", err=True)
            raise typer.Exit(1)


def filter_notes_by_exclude_patterns(notes: list[dict[str, Any]], exclude_patterns: list[str]) -> list[dict[str, Any]]:
    """Filter out notes whose titles match any of the exclude patterns."""
    if not exclude_patterns:
        return notes
    
    compiled_patterns = []
    for pattern in exclude_patterns:
        try:
            compiled_patterns.append(re.compile(pattern, re.IGNORECASE))
        except re.error as e:
            typer.echo(f"Error compiling regex pattern '{pattern}': {e}", err=True)
            raise typer.Exit(1)
    
    filtered_notes = []
    for note in notes:
        title = note.get('title', 'Untitled')
        # Check if title matches any exclude pattern
        if not any(pattern.search(title) for pattern in compiled_patterns):
            filtered_notes.append(note)
    
    return filtered_notes


def create_reference_name(title: str, used_names: set) -> str:
    """Create a meaningful reference name from title, max 15 chars, ensuring uniqueness."""
    # Remove special characters and convert to lowercase
    clean_title = re.sub(r'[^a-zA-Z0-9\s-]', '', title.lower())
    # Replace spaces and multiple hyphens with single hyphen
    clean_title = re.sub(r'[\s-]+', '-', clean_title)
    # Remove leading/trailing hyphens
    clean_title = clean_title.strip('-')
    
    # If empty after cleaning, use fallback
    if not clean_title:
        clean_title = "untitled"
    
    # Truncate to 15 chars
    base_name = clean_title[:15].rstrip('-')
    
    # Ensure uniqueness
    ref_name = base_name
    counter = 1
    while ref_name in used_names:
        # Calculate space needed for counter suffix
        suffix = f"-{counter}"
        max_base_len = 15 - len(suffix)
        ref_name = base_name[:max_base_len].rstrip('-') + suffix
        counter += 1
    
    used_names.add(ref_name)
    return ref_name


def generate_markdown_table(notes: list[dict[str, Any]], additional_columns: list[str]) -> str:
    """Generate a markdown table from the notes data."""
    if not notes:
        return "No documents found with the specified tags.\n"

    # Sort notes alphabetically by title
    sorted_notes = sorted(notes, key=lambda note: note.get('title', 'Untitled').lower())

    # Define columns
    columns = ["Title"] + additional_columns

    # Create header
    header = "| " + " | ".join(columns) + " |"
    separator = "| " + " | ".join(["---"] * len(columns)) + " |"

    # Create rows and collect references
    rows = []
    references = []
    used_ref_names = set()
    
    for note in sorted_notes:
        title = note.get('title', 'Untitled')
        note_id = note.get('id', '')

        # Create reference-style link for title
        if note_id:
            ref_name = create_reference_name(title, used_ref_names)
            title_link = f"[{title}][{ref_name}]"
            references.append(f"[{ref_name}]: https://hackmd.io/{note_id}")
        else:
            title_link = title

        # Create row with title and empty cells for additional columns
        row_data = [title_link] + [""] * len(additional_columns)
        row = "| " + " | ".join(row_data) + " |"
        rows.append(row)

    # Combine all parts
    table_parts = [header, separator] + rows
    
    # Add references at the bottom if any exist
    if references:
        table_parts.extend(["", ""] + references)
    
    return "\n".join(table_parts) + "\n"


def main(
    team: str = typer.Option(..., "--team", "-t", help="HackMD team path/name"),
    tags: str = typer.Option(..., "--tags", "-g", help="Comma-separated list of tags to filter by"),
    columns: str = typer.Option("", "--columns", "-c", help="Comma-separated list of additional column names"),
    exclude: str = typer.Option("", "--exclude", "-e", help="Comma-separated list of regex patterns to exclude notes by title"),
    output: str | None = typer.Option(None, "--output", "-o", help="Output file path (default: stdout)")
):
    """
    Generate a markdown table of HackMD documents with specified tags.

    Requires HACKMD_API_TOKEN environment variable to be set.
    """
    # Get API token from environment
    api_token = os.getenv('HACKMD_API_TOKEN')
    if not api_token:
        typer.echo("Error: HACKMD_API_TOKEN environment variable not set", err=True)
        raise typer.Exit(1)

    # Parse tags, columns, and exclude patterns
    tag_list = [tag.strip() for tag in tags.split(',') if tag.strip()]
    column_list = [col.strip() for col in columns.split(',') if col.strip()] if columns else []
    exclude_patterns = [pattern.strip() for pattern in exclude.split(',') if pattern.strip()] if exclude else []

    if not tag_list:
        typer.echo("Error: At least one tag must be specified", err=True)
        raise typer.Exit(1)

    # Initialize API client
    api = HackMDAPI(api_token)

    # Fetch notes
    typer.echo(f"Fetching notes from team '{team}' with tags: {', '.join(tag_list)}", err=True)
    notes = api.get_team_notes(team, tag_list)

    # Apply exclude patterns
    if exclude_patterns:
        typer.echo(f"Applying exclude patterns: {', '.join(exclude_patterns)}", err=True)
        notes = filter_notes_by_exclude_patterns(notes, exclude_patterns)

    # Generate table
    table = generate_markdown_table(notes, column_list)

    # Output table
    if output:
        try:
            with open(output, 'w', encoding='utf-8') as f:
                f.write(table)
            typer.echo(f"Table written to {output}", err=True)
        except IOError as e:
            typer.echo(f"Error writing to file: {e}", err=True)
            raise typer.Exit(1)
    else:
        typer.echo(table)

    typer.echo(f"Found {len(notes)} documents", err=True)


if __name__ == "__main__":
    typer.run(main)
```

