""" GitHub Contributions Heatmap Module for Inkycal Displays GitHub contribution activity as a heatmap """ import logging from datetime import datetime, timedelta import requests from PIL import Image, ImageDraw from inkycal.custom import write, internet_available from inkycal.modules.template import inkycal_module logger = logging.getLogger(__name__) class GitHub(inkycal_module): """GitHub Contributions Heatmap Module Displays a heatmap showing GitHub contribution activity for a user. """ name = "GitHub - Display contribution heatmap" requires = { "username": { "label": "GitHub username to display contributions for" } } optional = { "weeks": { "label": "Number of weeks to show (default: 12)", "default": 12 }, "show_legend": { "label": "Show contribution count legend (default: True)", "options": [True, False], "default": True }, "token": { "label": "GitHub Personal Access Token (optional, for higher rate limits)" } } def __init__(self, config): """Initialize GitHub module""" super().__init__(config) config = config['config'] self.username = config['username'] self.weeks = config.get('weeks', 12) self.show_legend = config.get('show_legend', True) self.token = config.get('token', None) logger.debug(f'{__name__} loaded for user: {self.username}') def _get_contributions_via_scraping(self): """Fetch contribution data via GitHub's contribution graph (fallback method)""" import re url = f"https://github.com/users/{self.username}/contributions" try: response = requests.get(url, timeout=10) response.raise_for_status() html = response.text # Parse contribution data from SVG # Look for rect elements with data-count attribute pattern = r'data-date="([^"]+)"[^>]*data-level="(\d)"[^>]*data-count="(\d+)"' matches = re.findall(pattern, html) if not matches: # Try alternative pattern pattern = r'data-count="(\d+)"[^>]*data-date="([^"]+)"[^>]*data-level="(\d)"' matches = re.findall(pattern, html) if matches: # Reorder to match expected format (date, level, count) matches = [(m[1], m[2], m[0]) for m in matches] # Group by weeks from collections import defaultdict weeks_dict = defaultdict(list) total = 0 for date_str, level, count_str in matches: count = int(count_str) total += count date_obj = datetime.fromisoformat(date_str) # Calculate week number from start week_num = date_obj.isocalendar()[1] weeks_dict[week_num].append({ 'contributionCount': count, 'date': date_str }) # Convert to expected format weeks = [] for week_num in sorted(weeks_dict.keys())[-self.weeks:]: weeks.append({ 'contributionDays': weeks_dict[week_num] }) return { 'totalContributions': total, 'weeks': weeks } except Exception as e: logger.error(f"Failed to scrape GitHub contributions: {e}") raise def _get_contributions(self): """Fetch contribution data from GitHub GraphQL API""" if not internet_available(): raise Exception('Network could not be reached') # If no token provided, use scraping method if not self.token: logger.info("No token provided, using scraping method") return self._get_contributions_via_scraping() # Calculate date range today = datetime.now() from_date = today - timedelta(weeks=self.weeks) # GitHub GraphQL query query = """ query($username: String!, $from: DateTime!, $to: DateTime!) { user(login: $username) { contributionsCollection(from: $from, to: $to) { contributionCalendar { totalContributions weeks { contributionDays { contributionCount date } } } } } } """ variables = { "username": self.username, "from": from_date.isoformat(), "to": today.isoformat() } headers = { "Content-Type": "application/json", "Authorization": f"Bearer {self.token}" } try: response = requests.post( 'https://api.github.com/graphql', json={'query': query, 'variables': variables}, headers=headers, timeout=10 ) response.raise_for_status() data = response.json() if 'errors' in data: logger.error(f"GitHub API error: {data['errors']}") # Fallback to scraping if GraphQL fails logger.info("Falling back to scraping method") return self._get_contributions_via_scraping() return data['data']['user']['contributionsCollection']['contributionCalendar'] except Exception as e: logger.error(f"Failed to fetch GitHub contributions via API: {e}") # Fallback to scraping logger.info("Falling back to scraping method") return self._get_contributions_via_scraping() def _get_color_level(self, count, max_count): """Determine color intensity level based on contribution count""" if count == 0: return 0 elif max_count == 0: return 1 else: # 4 levels: 0 (none), 1-4 (low to high) percentage = count / max_count if percentage <= 0.25: return 1 elif percentage <= 0.5: return 2 elif percentage <= 0.75: return 3 else: return 4 def generate_image(self): """Generate heatmap image for GitHub contributions""" # Define image size with padding im_width = int(self.width - (2 * self.padding_left)) im_height = int(self.height - (2 * self.padding_top)) im_size = im_width, im_height logger.debug(f'Image size: {im_width} x {im_height} px') # Create images for black and color channels im_black = Image.new('RGB', size=im_size, color='white') im_colour = Image.new('RGB', size=im_size, color='white') draw_black = ImageDraw.Draw(im_black) draw_colour = ImageDraw.Draw(im_colour) try: # Fetch contribution data logger.info(f'Fetching GitHub contributions for {self.username}...') calendar_data = self._get_contributions() weeks_data = calendar_data['weeks'] total = calendar_data['totalContributions'] logger.info(f'Total contributions: {total}') # Calculate heatmap dimensions num_weeks = len(weeks_data) days_per_week = 7 # Reserve space for title and legend title_height = int(im_height * 0.15) legend_height = int(im_height * 0.15) if self.show_legend else 0 heatmap_height = im_height - title_height - legend_height # Calculate cell size cell_width = im_width // num_weeks cell_height = heatmap_height // days_per_week cell_size = min(cell_width, cell_height) # Add spacing between cells cell_spacing = max(1, cell_size // 10) actual_cell_size = cell_size - cell_spacing # Center the heatmap heatmap_start_x = (im_width - (num_weeks * cell_size)) // 2 heatmap_start_y = title_height + (heatmap_height - (days_per_week * cell_size)) // 2 logger.debug(f'Cell size: {actual_cell_size}x{actual_cell_size} px') logger.debug(f'Heatmap position: ({heatmap_start_x}, {heatmap_start_y})') # Find max contribution count for color scaling max_count = 0 for week in weeks_data: for day in week['contributionDays']: max_count = max(max_count, day['contributionCount']) logger.debug(f'Max daily contributions: {max_count}') # Draw title title_text = f"@{self.username} - {total} contributions" write( im_black, (0, 0), (im_width, title_height), title_text, font=self.font, alignment='center' ) # Draw heatmap for week_idx, week in enumerate(weeks_data): for day_idx, day in enumerate(week['contributionDays']): count = day['contributionCount'] level = self._get_color_level(count, max_count) x = heatmap_start_x + week_idx * cell_size y = heatmap_start_y + day_idx * cell_size # Draw cell border in black draw_black.rectangle( [x, y, x + actual_cell_size, y + actual_cell_size], outline='black', width=1 ) # Fill cell based on contribution level if level > 0: # All levels use black channel if level == 4: # Level 4: 100% fill (completely filled) draw_black.rectangle( [x + 1, y + 1, x + actual_cell_size - 1, y + actual_cell_size - 1], fill='black' ) else: # Level 1-3: Partial fill based on percentage # Level 1: 25%, Level 2: 50%, Level 3: 75% fill_percentage = level / 4 fill_size = int(actual_cell_size * fill_percentage) center_x = x + actual_cell_size // 2 center_y = y + actual_cell_size // 2 draw_black.rectangle( [center_x - fill_size // 2, center_y - fill_size // 2, center_x + fill_size // 2, center_y + fill_size // 2], fill='black' ) # Draw legend if enabled if self.show_legend: # Use same cell size as heatmap legend_cell_size = actual_cell_size legend_spacing = cell_spacing # Calculate text widths less_text = "Less" more_text = "More" less_width = int(self.font.getlength(less_text)) more_width = int(self.font.getlength(more_text)) # Calculate total legend width total_legend_width = ( less_width + 10 + # "Less" + spacing 5 * legend_cell_size + 4 * legend_spacing + # 5 cells with 4 gaps 10 + more_width # spacing + "More" ) # Center the legend horizontally legend_start_x = int((im_width - total_legend_width) // 2) legend_y = int(title_height + heatmap_height + (legend_height - legend_cell_size) // 2) # Draw "Less" text (centered vertically with cells) write( im_black, (legend_start_x, legend_y), (less_width + 10, legend_cell_size), less_text, font=self.font, alignment='center' ) # Draw legend cells cells_start_x = int(legend_start_x + less_width + 10) for level in range(5): x = int(cells_start_x + level * (legend_cell_size + legend_spacing)) y = int(legend_y) draw_black.rectangle( [x, y, x + legend_cell_size, y + legend_cell_size], outline='black', width=1 ) if level > 0: # All levels use black channel if level == 4: # Level 4: 100% fill (completely filled) draw_black.rectangle( [x + 1, y + 1, x + legend_cell_size - 1, y + legend_cell_size - 1], fill='black' ) else: # Level 1-3: Partial fill based on percentage # Level 1: 25%, Level 2: 50%, Level 3: 75% fill_percentage = level / 4 fill_size = int(legend_cell_size * fill_percentage) center_x = int(x + legend_cell_size // 2) center_y = int(y + legend_cell_size // 2) draw_black.rectangle( [center_x - fill_size // 2, center_y - fill_size // 2, center_x + fill_size // 2, center_y + fill_size // 2], fill='black' ) # Draw "More" text (centered vertically with cells) more_x = int(cells_start_x + 5 * legend_cell_size + 4 * legend_spacing + 10) write( im_black, (more_x, legend_y), (more_width + 10, legend_cell_size), more_text, font=self.font, alignment='center' ) logger.info('GitHub heatmap generated successfully') except Exception as e: logger.error(f'Failed to generate GitHub heatmap: {e}') # Show error message on display error_msg = f"Error: {str(e)}" write( im_black, (0, im_height // 2 - 20), (im_width, 40), error_msg, font=self.font, alignment='center' ) return im_black, im_colour