## 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)
```