github module
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
405
inkycal/modules/inkycal_github.py
Normal file
405
inkycal/modules/inkycal_github.py
Normal 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
|
||||
Reference in New Issue
Block a user