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_calendar
|
||||||
import inkycal.modules.inkycal_feeds
|
import inkycal.modules.inkycal_feeds
|
||||||
import inkycal.modules.inkycal_fullweather
|
import inkycal.modules.inkycal_fullweather
|
||||||
|
import inkycal.modules.inkycal_github
|
||||||
import inkycal.modules.inkycal_image
|
import inkycal.modules.inkycal_image
|
||||||
import inkycal.modules.inkycal_jokes
|
import inkycal.modules.inkycal_jokes
|
||||||
import inkycal.modules.inkycal_slideshow
|
import inkycal.modules.inkycal_slideshow
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ from .inkycal_fullweather import Fullweather
|
|||||||
from .inkycal_tindie import Tindie
|
from .inkycal_tindie import Tindie
|
||||||
from .inkycal_vikunja import Vikunja
|
from .inkycal_vikunja import Vikunja
|
||||||
from .inkycal_today import Today
|
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