github module

This commit is contained in:
2025-11-30 22:18:20 +01:00
parent 29af1f19ce
commit d72199db3b
3 changed files with 407 additions and 0 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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