diff --git a/inkycal/__init__.py b/inkycal/__init__.py index edb3926..3e2eddb 100644 --- a/inkycal/__init__.py +++ b/inkycal/__init__.py @@ -3,6 +3,7 @@ import inkycal.modules.inkycal_agenda import inkycal.modules.inkycal_calendar import inkycal.modules.inkycal_feeds import inkycal.modules.inkycal_fullweather +import inkycal.modules.inkycal_github import inkycal.modules.inkycal_image import inkycal.modules.inkycal_jokes import inkycal.modules.inkycal_slideshow diff --git a/inkycal/modules/__init__.py b/inkycal/modules/__init__.py index 4d71e74..2617c7a 100644 --- a/inkycal/modules/__init__.py +++ b/inkycal/modules/__init__.py @@ -14,3 +14,4 @@ from .inkycal_fullweather import Fullweather from .inkycal_tindie import Tindie from .inkycal_vikunja import Vikunja from .inkycal_today import Today +from .inkycal_github import GitHub diff --git a/inkycal/modules/inkycal_github.py b/inkycal/modules/inkycal_github.py new file mode 100644 index 0000000..51ea851 --- /dev/null +++ b/inkycal/modules/inkycal_github.py @@ -0,0 +1,405 @@ +""" +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