Compare commits

...

21 Commits

Author SHA1 Message Date
Miguel Astor
afd11fba3a Separated the modern theme script into it's own file. 2026-03-03 03:56:39 -04:00
Miguel Astor
b56b7176a8 Extract CSS styles to separate files for better maintainability
- Create templates/brutalism.css, glassmorphism.css, neumorphism.css
- Update styles.py to read CSS from files instead of inline strings
- Support --template with .css files (auto-detects modern style)
- CSS files can now be edited with proper syntax highlighting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-03 03:29:25 -04:00
Miguel Astor
15a8072804 Unify modern templates into single template with style system
- Create templates/modern.html as unified base for brutalism, glassmorphism, neumorphism
- Add styles.py with CSS and chart config for each style
- Add --style argument to generate_report.py (overrides --template)
- Remove individual brutalism.html, glassmorphism.html, neumorphism.html
- Keep platinum.html separate due to unique Mac OS 9 structure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-03 03:23:43 -04:00
Miguel Astor
31e8d152ae Add By Runner tab to summaries section in all templates
Extract runner field from Lutris database and display playtime
grouped by runner (wine, linux, steam, dosbox, etc.) in a new
third tab alongside Top Games and By Category.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-03 03:09:29 -04:00
Miguel Astor
aa9719cbfe Update CLAUDE.md documentation
- Document all four available templates (platinum, brutalism, glassmorphism, neumorphism)
- Fix template path to use templates/ folder
- Update theme toggle description (auto/light/dark with persistent preference)
- Add responsive design mention
- Fix filtered categories list

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 17:29:28 -04:00
Miguel Astor
f4e5c33c87 Improve card header visibility in brutalism template dark mode
- Keep card-header background yellow in both light and dark modes using
  --accent-tertiary variable (#ffff00 light, #ffff33 dark)
- Set card-title text to black in dark mode for optimal contrast against
  yellow background
- Remove dark mode overrides that changed header background to gray

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 17:19:52 -04:00
Miguel Astor
db575f2bb7 Include Horny category in category chart and table
Remove Horny from category filter to display it in the categories chart
and By Category table. Now only .hidden and favorite remain filtered.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 17:12:52 -04:00
Miguel Astor
3394d66151 Include Horny category in game category badges
Remove Horny from the filter list to display it in category badges.
Only .hidden and favorite categories remain filtered.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 17:09:01 -04:00
Miguel Astor
3a700c4f48 Add category badges to game tables in all templates
Display game categories as colored badges after service badges in both
top games and others sections. Categories filtered to exclude .hidden,
favorite, and Horny.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 17:07:28 -04:00
Miguel Astor
f850e4d69c Move templates and assets to templates/ folder
- Move all HTML templates to templates/
- Move Platinum assets to templates/Platinum/
- Update default paths in generate_report.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 03:21:25 -04:00
Miguel Astor
49c7a2bba8 Add brutalism template with bold industrial style
Features high-contrast colors, thick borders, hard shadows,
monospace typography, and no rounded corners. Uses custom
background placeholder that shows theme color when no image
is specified.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 03:18:13 -04:00
Miguel Astor
6e7f3e5e16 Add scroll-to-top button to all templates
Floating button in bottom-right corner that appears when scrolling
down and smoothly scrolls to top when clicked. Each template has
matching visual style.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 03:09:44 -04:00
Miguel Astor
9e49262118 Add neumorphism template with soft UI style
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 03:02:58 -04:00
Miguel Astor
ec9c1c5205 Add glassmorphism template with modern glass UI style
New template featuring:
- Frosted glass effect with backdrop-filter blur
- Light/dark theme with automatic system detection
- Theme toggle button (sun/moon/auto) in top right corner
- Responsive design for mobile devices
- Same data structure as platinum template

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 02:02:48 -04:00
Miguel Astor
d5c5703523 Update CLAUDE.md to document external template file
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 01:39:32 -04:00
Miguel Astor
878eaf0c4e Extract HTML template to external file with --template option
Move embedded HTML template from generate_report.py to platinum.html for
easier editing with proper syntax highlighting. Add --template argument
to allow selecting different template files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 01:37:42 -04:00
Miguel Astor
3cc572a45b Add game source to tooltip in games chart
Show service name (Steam, Local, Gog, etc.) below the bold game title
when hovering over chart segments.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 18:53:41 -04:00
Miguel Astor
bc0a541034 Add categories chart and expandable Others row to categories table
- Add second doughnut chart showing playtime by category alongside games chart
- Charts display side-by-side on large screens, stacked on small screens (<700px)
- Group categories beyond top 10 into expandable "Others" row in table
- Add color boxes to category rows matching chart colors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 18:42:59 -04:00
Miguel Astor
c0953edf3a Added additional fake buttons to the window titles. 2026-02-10 22:40:24 -04:00
Miguel Astor
c38eda7ca8 Add Mac OS 9 Platinum style to report
- Restyle report with Mac OS 9.2.2 Platinum theme
- Add Charcoal font for UI elements, Monaco for numeric data
- Custom window chrome with titlebars and close buttons
- Mac-style checkboxes, scrollbars, and tabs
- Tabbed interface for game/category summaries using 9-patch tabs
- Filter games by service with styled checkboxes
- Support custom tiled background via --background flag
- Add --assets flag for Platinum assets directory

Platinum assets from grassmunk's Platinum9 GTK2 theme:
https://github.com/grassmunk/Platinum9/

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 18:10:37 -04:00
Miguel Astor
a3b88d9fe4 Add total library count to report stats
Show "Games in Library" stat alongside "Games Played" and "Total Playtime".
Games with zero or null playtime are excluded from tables and charts but
now counted in the library total.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 16:57:14 -04:00
250 changed files with 4062 additions and 567 deletions

View File

@@ -13,17 +13,46 @@ Generate report with defaults:
python generate_report.py
```
Generate report with custom options:
Generate report with modern style:
```bash
python generate_report.py --db pga.db --output report.html --top 10 --background background.png
python generate_report.py --style glassmorphism --output report.html
python generate_report.py --style brutalism --output report.html
python generate_report.py --style neumorphism --output report.html
```
Generate report with legacy Platinum template:
```bash
python generate_report.py --template templates/platinum.html --output report.html
```
All options:
```bash
python generate_report.py --db pga.db --output report.html --top 10 --background background.png --style glassmorphism
```
## Architecture
**Single-file generator (`generate_report.py`):**
**Report generator (`generate_report.py`):**
- Reads Lutris SQLite database (`pga.db`) containing games, categories, and playtime data
- Embeds all data (games JSON, background image as base64) directly into a self-contained HTML file
- HTML template with Chart.js doughnut chart and dynamic JavaScript filtering is embedded as a string constant (`HTML_TEMPLATE`)
- Loads HTML template from `templates/` folder (default: `templates/platinum.html`)
**HTML templates (`templates/`):**
- **modern.html**: Unified template for modern styles (brutalism, glassmorphism, neumorphism)
- **platinum.html**: Legacy Mac OS 9 Platinum visual style (separate template due to unique structure)
- All templates use Chart.js doughnut charts and dynamic JavaScript filtering
- Placeholder tokens like `__ALL_GAMES__`, `__BACKGROUND_IMAGE__` are replaced at generation time
- Modern templates support light/dark/auto theme toggle button
**Style system (`styles.py`):**
- CSS definitions for each modern style (brutalism, glassmorphism, neumorphism)
- Theme configurations (colors, fonts, chart options) injected via `__THEME_CSS__`
- Use `--style` argument to select a modern style instead of `--template`
**Javascript (`templates/script.js`):**
- A single common script is used for each modern style (brutalism, glassmorphism, neumorphism)
- Theme configurations (colors, fonts, chart options) injected via `__THEME_CONFIG__`
- Data inserted via `__ALL_GAMES__` and `__TOP_N__`
**Database schema (`schema.py`):**
- Reference file documenting Lutris database structure
@@ -33,11 +62,12 @@ python generate_report.py --db pga.db --output report.html --top 10 --background
- Fully static, can be hosted on any web server
- Client-side filtering by service (Steam, GOG, itch.io, local)
- Expandable "Others" row in games table
- Light/dark mode support via CSS `prefers-color-scheme`
- Light/dark/auto theme toggle button with persistent preference
- Responsive design for mobile and desktop
## Key Data Relationships
- Games have a `service` field (steam, gog, itchio, humblebundle, or NULL for local)
- Games link to categories via `games_categories` join table
- Categories like `.hidden`, `favorite`, `Horny` are filtered out in the report
- Categories like `.hidden` and `favorite` are filtered out in the report display
- `playtime` is cumulative hours (REAL), not per-session data

View File

@@ -1,3 +1,5 @@
# Lutris-Playtime-Report-Generator
Generates a little static HTML report from a Lutris database. Made with Claude Code.
Generates a little static HTML report from a Lutris database. Made with Claude Code.
Platinum assets are from the Platinum 9 theme for GTK 2 by grassmunk, found at https://github.com/grassmunk/Platinum9/

View File

@@ -21,564 +21,52 @@ import json
import sqlite3
from pathlib import Path
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lutris Playtime Report</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root {
--card-bg: rgba(33, 34, 44, 0.75);
--card-hover: rgba(40, 42, 54, 0.85);
--text-color: #eee;
--text-muted: #aaa;
--accent: #ff7f50;
--border-color: rgba(255, 255, 255, 0.1);
--badge-bg: rgba(255, 255, 255, 0.15);
--table-header: rgba(22, 33, 62, 0.8);
}
@media (prefers-color-scheme: light) {
:root {
--card-bg: rgba(255, 255, 255, 0.75);
--card-hover: rgba(245, 245, 245, 0.85);
--text-color: #111;
--text-muted: #555;
--border-color: rgba(0, 0, 0, 0.1);
--badge-bg: rgba(0, 0, 0, 0.1);
--table-header: rgba(240, 240, 240, 0.9);
}
}
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-image: url('__BACKGROUND_IMAGE__');
background-repeat: repeat;
min-height: 100vh;
color: var(--text-color);
}
h1 {
text-align: center;
color: var(--accent);
margin: 0;
}
.card {
background: var(--card-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid var(--border-color);
}
.filters {
display: flex;
justify-content: center;
gap: 15px;
flex-wrap: wrap;
}
.filter-label {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--badge-bg);
border-radius: 20px;
cursor: pointer;
transition: background 0.2s;
user-select: none;
}
.filter-label:hover {
background: var(--card-hover);
}
.filter-label input {
accent-color: var(--accent);
width: 16px;
height: 16px;
cursor: pointer;
}
.filter-label .service-name {
text-transform: capitalize;
}
.filter-label .service-count {
color: var(--text-muted);
font-size: 0.85em;
}
.stats {
display: flex;
justify-content: center;
gap: 40px;
flex-wrap: wrap;
}
.stat {
text-align: center;
}
.stat-value {
font-size: 2em;
font-weight: bold;
color: var(--accent);
}
.stat-label {
font-size: 0.9em;
color: var(--text-muted);
}
.chart-container {
position: relative;
max-width: 600px;
margin: 0 auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
background: var(--table-header);
color: var(--accent);
}
tr:hover {
background: var(--card-hover);
}
.time {
font-family: monospace;
}
.percent {
font-family: monospace;
text-align: right;
}
.color-box {
display: inline-block;
width: 16px;
height: 16px;
margin-right: 8px;
vertical-align: middle;
border-radius: 3px;
}
.service-badge {
display: inline-block;
font-size: 0.75em;
padding: 2px 8px;
border-radius: 10px;
background: var(--badge-bg);
color: var(--text-muted);
margin-left: 8px;
text-transform: capitalize;
}
.no-data {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
.others-row {
cursor: pointer;
}
.others-row:hover {
background: var(--card-hover);
}
.others-row td:first-child::before {
content: '';
display: inline-block;
width: 0;
height: 0;
border-left: 6px solid var(--accent);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
margin-right: 8px;
transition: transform 0.2s;
}
.others-row.expanded td:first-child::before {
transform: rotate(90deg);
}
.others-detail {
display: none;
background: var(--badge-bg);
}
.others-detail.visible {
display: table-row;
}
.others-detail td {
padding-left: 40px;
border-bottom: 1px solid var(--border-color);
}
.tables-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 900px) {
.tables-container {
grid-template-columns: 1fr;
}
.tables-container .card {
height: auto !important;
}
}
@media (min-width: 901px) {
.tables-container {
align-items: stretch;
}
.tables-container .card {
display: flex;
flex-direction: column;
}
.tables-container .card .table-wrapper {
flex: 1;
overflow-y: auto;
}
}
.table-section h2 {
color: var(--accent);
font-size: 1.2em;
margin: 0 0 15px 0;
}
</style>
</head>
<body>
<div class="card">
<h1>Lutris Playtime Report</h1>
</div>
from styles import get_theme_css, get_theme_config
<div class="card">
<div class="filters" id="filters"></div>
</div>
# Directory where this script is located (for finding template.html)
SCRIPT_DIR = Path(__file__).parent
<div class="card">
<div class="stats">
<div class="stat">
<div class="stat-value" id="total-games">0</div>
<div class="stat-label">Games Played</div>
</div>
<div class="stat">
<div class="stat-value" id="total-time">0h</div>
<div class="stat-label">Total Playtime</div>
</div>
</div>
</div>
<div class="card">
<div class="chart-container">
<canvas id="playtime-chart"></canvas>
</div>
</div>
<div class="tables-container">
<div class="card">
<h2 class="table-section">Top Games</h2>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>Game</th>
<th>Playtime</th>
<th style="text-align: right">%</th>
</tr>
</thead>
<tbody id="games-table"></tbody>
</table>
</div>
</div>
<div class="card">
<h2 class="table-section">By Category</h2>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>Category</th>
<th>Playtime</th>
<th style="text-align: right">%</th>
</tr>
</thead>
<tbody id="categories-table"></tbody>
</table>
</div>
</div>
</div>
<script>
const allGames = __ALL_GAMES__;
const topN = __TOP_N__;
// Colors for the chart
const colors = [
'#ff6384', '#36a2eb', '#ffce56', '#4bc0c0', '#9966ff',
'#ff9f40', '#ff6384', '#c9cbcf', '#7bc043', '#ee4035',
'#808080'
];
// Format hours to "Xh Ym" format
function formatTime(hours) {
const h = Math.floor(hours);
const m = Math.round((hours - h) * 60);
if (h === 0) return m + 'm';
if (m === 0) return h + 'h';
return h + 'h ' + m + 'm';
}
// Get unique services and their counts
function getServices() {
const services = {};
allGames.forEach(g => {
const s = g.service;
if (!services[s]) services[s] = { count: 0, playtime: 0 };
services[s].count++;
services[s].playtime += g.playtime;
});
return Object.entries(services)
.sort((a, b) => b[1].playtime - a[1].playtime)
.map(([name, data]) => ({ name, ...data }));
}
// Build filter checkboxes
const services = getServices();
const filtersDiv = document.getElementById('filters');
services.forEach(service => {
const label = document.createElement('label');
label.className = 'filter-label';
label.innerHTML = `
<input type="checkbox" value="${service.name}" checked>
<span class="service-name">${service.name}</span>
<span class="service-count">(${service.count})</span>
`;
filtersDiv.appendChild(label);
});
// Chart instance
let chart = null;
const ctx = document.getElementById('playtime-chart').getContext('2d');
// Get selected services
function getSelectedServices() {
const checkboxes = filtersDiv.querySelectorAll('input[type="checkbox"]');
return Array.from(checkboxes)
.filter(cb => cb.checked)
.map(cb => cb.value);
}
// Filter and aggregate data
function getFilteredData(selectedServices) {
const filtered = allGames
.filter(g => selectedServices.includes(g.service))
.sort((a, b) => b.playtime - a.playtime);
if (filtered.length === 0) {
return { chartData: [], othersGames: [], categoriesData: [], totalPlaytime: 0, totalGames: 0 };
}
const totalPlaytime = filtered.reduce((sum, g) => sum + g.playtime, 0);
const totalGames = filtered.length;
// Get top N games
const topGames = filtered.slice(0, topN).map(g => ({
name: g.name,
playtime: g.playtime,
service: g.service
}));
// Games in "Others" category
let othersGames = [];
// Add "Others" if needed
if (filtered.length > topN) {
othersGames = filtered.slice(topN).map(g => ({
name: g.name,
playtime: g.playtime,
service: g.service
}));
const othersPlaytime = othersGames.reduce((sum, g) => sum + g.playtime, 0);
const othersCount = othersGames.length;
topGames.push({
name: `Others (${othersCount} games)`,
playtime: othersPlaytime,
service: 'others'
});
}
// Aggregate by category
const categoryMap = {};
filtered.forEach(g => {
if (g.categories && g.categories.length > 0) {
g.categories.forEach(cat => {
if (cat === '.hidden' || cat === 'favorite' || cat === 'Horny') return;
if (!categoryMap[cat]) {
categoryMap[cat] = { name: cat, playtime: 0, gameCount: 0 };
}
categoryMap[cat].playtime += g.playtime;
categoryMap[cat].gameCount++;
});
}
});
const categoriesData = Object.values(categoryMap)
.sort((a, b) => b.playtime - a.playtime);
return { chartData: topGames, othersGames, categoriesData, totalPlaytime, totalGames };
}
// Update the display
function updateDisplay() {
const selectedServices = getSelectedServices();
const { chartData, othersGames, categoriesData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
// Update stats
document.getElementById('total-games').textContent = totalGames;
document.getElementById('total-time').textContent = formatTime(totalPlaytime);
// Update chart
if (chart) {
chart.destroy();
}
if (chartData.length === 0) {
document.getElementById('games-table').innerHTML =
'<tr><td colspan="4" class="no-data">No games match the selected filters</td></tr>';
document.getElementById('categories-table').innerHTML =
'<tr><td colspan="4" class="no-data">No categories found</td></tr>';
return;
}
chart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: chartData.map(g => g.name),
datasets: [{
data: chartData.map(g => g.playtime),
backgroundColor: colors.slice(0, chartData.length),
borderColor: '#1a1a2e',
borderWidth: 2
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: '#eee',
padding: 15
}
},
tooltip: {
callbacks: {
label: function(context) {
const value = context.raw;
const percent = ((value / totalPlaytime) * 100).toFixed(1);
return ' ' + formatTime(value) + ' (' + percent + '%)';
}
}
}
}
}
});
// Update table
const tbody = document.getElementById('games-table');
tbody.innerHTML = '';
chartData.forEach((game, index) => {
const percent = ((game.playtime / totalPlaytime) * 100).toFixed(1);
const isOthers = game.service === 'others';
const serviceBadge = !isOthers
? `<span class="service-badge">${game.service}</span>`
: '';
const row = document.createElement('tr');
if (isOthers) {
row.className = 'others-row';
}
row.innerHTML = `
<td>${index + 1}</td>
<td>
<span class="color-box" style="background: ${colors[index]}"></span>
${game.name}${serviceBadge}
</td>
<td class="time">${formatTime(game.playtime)}</td>
<td class="percent">${percent}%</td>
`;
tbody.appendChild(row);
// Add expandable rows for "Others"
if (isOthers && othersGames.length > 0) {
const detailRows = [];
othersGames.forEach((otherGame, otherIndex) => {
const otherPercent = ((otherGame.playtime / totalPlaytime) * 100).toFixed(1);
const detailRow = document.createElement('tr');
detailRow.className = 'others-detail';
detailRow.innerHTML = `
<td>${index + 1}.${otherIndex + 1}</td>
<td>
${otherGame.name}
<span class="service-badge">${otherGame.service}</span>
</td>
<td class="time">${formatTime(otherGame.playtime)}</td>
<td class="percent">${otherPercent}%</td>
`;
tbody.appendChild(detailRow);
detailRows.push(detailRow);
});
// Toggle expand/collapse on click
row.addEventListener('click', () => {
row.classList.toggle('expanded');
detailRows.forEach(dr => dr.classList.toggle('visible'));
});
}
});
// Update categories table
const catTbody = document.getElementById('categories-table');
catTbody.innerHTML = '';
if (categoriesData.length === 0) {
catTbody.innerHTML = '<tr><td colspan="4" class="no-data">No categories found</td></tr>';
} else {
categoriesData.forEach((cat, index) => {
const percent = ((cat.playtime / totalPlaytime) * 100).toFixed(1);
const row = document.createElement('tr');
row.innerHTML = `
<td>${index + 1}</td>
<td>${cat.name} <span class="service-badge">${cat.gameCount} games</span></td>
<td class="time">${formatTime(cat.playtime)}</td>
<td class="percent">${percent}%</td>
`;
catTbody.appendChild(row);
});
}
}
// Listen for filter changes
filtersDiv.addEventListener('change', updateDisplay);
// Initial render
updateDisplay();
</script>
</body>
</html>
"""
# Modern styles that use the unified template
MODERN_STYLES = ["brutalism", "glassmorphism", "neumorphism"]
def get_all_games(db_path: str) -> list[dict]:
"""Query the database and return all games with playtime and categories."""
def load_template(template_file: str) -> str:
"""Load the HTML template from the specified file."""
template_path = SCRIPT_DIR / template_file
return template_path.read_text(encoding="utf-8")
def load_script(script_file: str) -> str:
"""Load the JS script from the specified file."""
script_path = SCRIPT_DIR / script_file
return script_path.read_text(encoding="utf-8")
def load_asset_as_base64(path: Path, mime_type: str) -> str:
"""Load a file and return it as a base64 data URL."""
if path.exists():
with open(path, "rb") as f:
data = base64.b64encode(f.read()).decode("utf-8")
return f"data:{mime_type};base64,{data}"
return ""
def get_all_games(db_path: str) -> tuple[list[dict], int]:
"""Query the database and return all games with playtime and categories, plus total library count."""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Get games with playtime
cursor.execute("SELECT COUNT(*) FROM games")
total_library = cursor.fetchone()[0]
cursor.execute("""
SELECT id, name, playtime, COALESCE(service, 'local') as service
SELECT id, name, playtime, COALESCE(service, 'local') as service, COALESCE(runner, 'unknown') as runner
FROM games
WHERE playtime > 0
ORDER BY playtime DESC
""")
games_rows = cursor.fetchall()
# Get categories for each game
cursor.execute("""
SELECT gc.game_id, c.name
FROM games_categories gc
@@ -587,27 +75,28 @@ def get_all_games(db_path: str) -> list[dict]:
categories_rows = cursor.fetchall()
conn.close()
# Build game_id -> categories mapping
game_categories = {}
for game_id, category in categories_rows:
if game_id not in game_categories:
game_categories[game_id] = []
game_categories[game_id].append(category)
return [
games = [
{
"name": row[1],
"playtime": row[2],
"service": row[3],
"runner": row[4],
"categories": game_categories.get(row[0], [])
}
for row in games_rows
]
return games, total_library
def generate_report(db_path: str, output_path: str, top_n: int, bg_image_path: str = None) -> None:
def generate_report(db_path: str, output_path: str, top_n: int, assets_dir: str, template_file: str, bg_image_path: str = None, style: str = None) -> None:
"""Generate the HTML report."""
all_games = get_all_games(db_path)
all_games, total_library = get_all_games(db_path)
if not all_games:
print("No games with playtime found in the database.")
@@ -616,19 +105,85 @@ def generate_report(db_path: str, output_path: str, top_n: int, bg_image_path: s
total_playtime = sum(g["playtime"] for g in all_games)
total_games = len(all_games)
# Load background image as base64
bg_data_url = ""
if bg_image_path and Path(bg_image_path).exists():
with open(bg_image_path, "rb") as f:
bg_base64 = base64.b64encode(f.read()).decode("utf-8")
bg_data_url = f"data:image/png;base64,{bg_base64}"
assets_path = Path(assets_dir)
html = HTML_TEMPLATE.replace("__ALL_GAMES__", json.dumps(all_games))
html = html.replace("__TOP_N__", str(top_n))
html = html.replace("__BACKGROUND_IMAGE__", bg_data_url)
# Load background image (custom or default stripes)
if bg_image_path and Path(bg_image_path).exists():
background_image = load_asset_as_base64(Path(bg_image_path), "image/png")
background_image_custom = f"url('{background_image}')"
else:
background_image = load_asset_as_base64(assets_path / "Others" / "stripes.png", "image/png")
background_image_custom = "none" # For templates that prefer no default background
# Check if using modern unified template
if style and style in MODERN_STYLES:
html = load_template("templates/modern.html")
# Inject theme CSS and config
theme_css = get_theme_css(style)
theme_config = get_theme_config(style)
# Inject javascript
javascript = load_script('templates/script.js')
javascript = javascript.replace("__ALL_GAMES__", json.dumps(all_games))
javascript = javascript.replace("__TOP_N__", str(top_n))
javascript = javascript.replace("__THEME_CONFIG__", theme_config)
html = html.replace("__THEME_CSS__", theme_css)
html = html.replace("__SCRIPT__", javascript)
html = html.replace("__TOTAL_LIBRARY__", str(total_library))
html = html.replace("__BACKGROUND_IMAGE__", background_image)
html = html.replace("__BACKGROUND_IMAGE_CUSTOM__", background_image_custom)
else:
# Legacy template handling (platinum and others)
# Load fonts
font_charcoal = load_asset_as_base64(assets_path / "Charcoal.ttf", "font/truetype")
font_monaco = load_asset_as_base64(assets_path / "MONACO.TTF", "font/truetype")
# Load images
titlebar_bg = load_asset_as_base64(assets_path / "Windows" / "title-1-active.png", "image/png")
title_stripes = load_asset_as_base64(assets_path / "Windows" / "title-1-active.png", "image/png")
close_btn = load_asset_as_base64(assets_path / "Windows" / "close-active.png", "image/png")
hide_btn = load_asset_as_base64(assets_path / "Windows" / "maximize-active.png", "image/png")
shade_btn = load_asset_as_base64(assets_path / "Windows" / "shade-active.png", "image/png")
check_off = load_asset_as_base64(assets_path / "Check-Radio" / "check-normal.png", "image/png")
check_on = load_asset_as_base64(assets_path / "Check-Radio" / "check-active.png", "image/png")
# Load scrollbar images
scrollbar_trough_v = load_asset_as_base64(assets_path / "Scrollbars" / "trough-scrollbar-vert.png", "image/png")
scrollbar_thumb_v = load_asset_as_base64(assets_path / "Scrollbars" / "slider-vertical.png", "image/png")
scrollbar_up = load_asset_as_base64(assets_path / "Scrollbars" / "stepper-up.png", "image/png")
scrollbar_down = load_asset_as_base64(assets_path / "Scrollbars" / "stepper-down.png", "image/png")
# Load tab images
tab_active = load_asset_as_base64(assets_path / "Tabs" / "tab-top-active.png", "image/png")
tab_inactive = load_asset_as_base64(assets_path / "Tabs" / "tab-top.png", "image/png")
html = load_template(template_file)
html = html.replace("__ALL_GAMES__", json.dumps(all_games))
html = html.replace("__TOP_N__", str(top_n))
html = html.replace("__TOTAL_LIBRARY__", str(total_library))
html = html.replace("__FONT_CHARCOAL__", font_charcoal)
html = html.replace("__FONT_MONACO__", font_monaco)
html = html.replace("__BACKGROUND_IMAGE__", background_image)
html = html.replace("__BACKGROUND_IMAGE_CUSTOM__", background_image_custom)
html = html.replace("__TITLEBAR_BG__", titlebar_bg)
html = html.replace("__TITLE_STRIPES__", title_stripes)
html = html.replace("__CLOSE_BTN__", close_btn)
html = html.replace("__HIDE_BTN__", hide_btn)
html = html.replace("__SHADE_BTN__", shade_btn)
html = html.replace("__CHECK_OFF__", check_off)
html = html.replace("__CHECK_ON__", check_on)
html = html.replace("__SCROLLBAR_TROUGH_V__", scrollbar_trough_v)
html = html.replace("__SCROLLBAR_THUMB_V__", scrollbar_thumb_v)
html = html.replace("__SCROLLBAR_UP__", scrollbar_up)
html = html.replace("__SCROLLBAR_DOWN__", scrollbar_down)
html = html.replace("__TAB_ACTIVE__", tab_active)
html = html.replace("__TAB_INACTIVE__", tab_inactive)
Path(output_path).write_text(html, encoding="utf-8")
print(f"Report generated: {output_path}")
print(f"Total games in library: {total_library}")
print(f"Total games with playtime: {total_games}")
print(f"Total playtime: {total_playtime:.1f} hours")
@@ -653,10 +208,26 @@ def main():
default=10,
help="Number of top games to show individually (default: 10)"
)
parser.add_argument(
"--assets",
default="templates/Platinum",
help="Path to Platinum assets directory (default: templates/Platinum)"
)
parser.add_argument(
"--background",
default="background.png",
help="Path to background image (default: background.png)"
default=None,
help="Path to background image for tiling (default: Platinum stripes pattern)"
)
parser.add_argument(
"--template",
default="templates/platinum.html",
help="HTML template file to use (default: templates/platinum.html). Ignored if --style is used."
)
parser.add_argument(
"--style",
choices=["brutalism", "glassmorphism", "neumorphism"],
default=None,
help="Modern style to use (brutalism, glassmorphism, neumorphism). Overrides --template."
)
args = parser.parse_args()
@@ -665,7 +236,35 @@ def main():
print(f"Error: Database file not found: {args.db}")
return 1
generate_report(args.db, args.output, args.top, args.background)
if not Path(args.assets).exists():
print(f"Error: Assets directory not found: {args.assets}")
return 1
# Determine style from --style or --template
style = args.style
# If no --style provided, check if --template points to a modern CSS file
if not style and args.template.endswith('.css'):
template_path = Path(args.template)
style_name = template_path.stem # e.g., "brutalism" from "brutalism.css"
if style_name in MODERN_STYLES:
style = style_name
# Validate template/style
if style and style in MODERN_STYLES:
template_path = SCRIPT_DIR / "templates" / "modern.html"
css_path = SCRIPT_DIR / "templates" / f"{style}.css"
if not css_path.exists():
print(f"Error: CSS file not found: {css_path}")
return 1
else:
template_path = SCRIPT_DIR / args.template
if not template_path.exists():
print(f"Error: Template file not found: {template_path}")
return 1
generate_report(args.db, args.output, args.top, args.assets, args.template, args.background, style)
return 0

107
styles.py Normal file
View File

@@ -0,0 +1,107 @@
"""CSS styles and theme configurations for modern templates."""
####################################################################################################
# Copyright (C) 2026 by WallyHackenslacker wallyhackenslacker@noreply.git.hackenslacker.space #
# #
# Permission to use, copy, modify, and/or distribute this software for any purpose with or without #
# fee is hereby granted. #
# #
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS #
# SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE #
# AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES #
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, #
# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE #
# OF THIS SOFTWARE. #
####################################################################################################
import json
from pathlib import Path
# Directory where templates are located
TEMPLATES_DIR = Path(__file__).parent / "templates"
# Theme configurations for Chart.js
THEME_CONFIGS = {
"brutalism": {
"colors": [
"#ff0000", "#0000ff", "#ffff00", "#00ff00", "#ff00ff",
"#00ffff", "#ff8800", "#8800ff", "#0088ff", "#88ff00",
"#888888"
],
"fontFamily": "'Courier New', monospace",
"fontWeight": "bold",
"pointStyle": "rect",
"textColorLight": "#000000",
"textColorDark": "#ffffff",
"borderColorLight": "#000000",
"borderColorDark": "#ffffff",
"borderWidth": 3,
"tooltipBg": "#000000",
"tooltipTitleColor": "#ffffff",
"tooltipBodyColor": "#ffffff",
"tooltipBorderColor": "#ffffff",
"tooltipBorderWidth": 2,
"tooltipCornerRadius": 0,
"uppercaseTooltip": True
},
"glassmorphism": {
"colors": [
"#6366f1", "#8b5cf6", "#ec4899", "#f43f5e", "#f97316",
"#eab308", "#22c55e", "#14b8a6", "#06b6d4", "#3b82f6",
"#64748b"
],
"fontFamily": "'Inter', sans-serif",
"fontWeight": "normal",
"pointStyle": "circle",
"textColorLight": "#1a1a2e",
"textColorDark": "#f0f0f5",
"borderColorLight": "rgba(255, 255, 255, 0.2)",
"borderColorDark": "rgba(255, 255, 255, 0.2)",
"borderWidth": 2,
"tooltipBg": "rgba(0, 0, 0, 0.8)",
"tooltipTitleColor": "#ffffff",
"tooltipBodyColor": "#ffffff",
"tooltipBorderColor": "transparent",
"tooltipBorderWidth": 0,
"tooltipCornerRadius": 8,
"uppercaseTooltip": False
},
"neumorphism": {
"colors": [
"#6366f1", "#8b5cf6", "#ec4899", "#f43f5e", "#f97316",
"#eab308", "#22c55e", "#14b8a6", "#06b6d4", "#3b82f6",
"#64748b"
],
"fontFamily": "'Inter', sans-serif",
"fontWeight": "normal",
"pointStyle": "circle",
"textColorLight": "#2d3436",
"textColorDark": "#f0f0f5",
"borderColorLight": "rgba(255, 255, 255, 0.3)",
"borderColorDark": "rgba(255, 255, 255, 0.3)",
"borderWidth": 3,
"tooltipBg": "rgba(0, 0, 0, 0.8)",
"tooltipTitleColor": "#ffffff",
"tooltipBodyColor": "#ffffff",
"tooltipBorderColor": "transparent",
"tooltipBorderWidth": 0,
"tooltipCornerRadius": 8,
"uppercaseTooltip": False
}
}
def get_theme_css(style: str) -> str:
"""Get the CSS for a given style by reading from the corresponding .css file."""
css_file = TEMPLATES_DIR / f"{style}.css"
if css_file.exists():
return css_file.read_text(encoding="utf-8")
# Fallback to glassmorphism if style not found
fallback = TEMPLATES_DIR / "glassmorphism.css"
return fallback.read_text(encoding="utf-8")
def get_theme_config(style: str) -> str:
"""Get the theme config as JSON string for a given style."""
config = THEME_CONFIGS.get(style, THEME_CONFIGS["glassmorphism"])
return json.dumps(config)

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 B

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 B

View File

@@ -0,0 +1,15 @@
/* XPM */
static char * bg_tiled_xpm[] = {
"8 8 4 1",
" c None",
". c #DDDDDD",
"+ c #CCCCCC",
"@ c #EEEEEE",
".+++++++",
"+.@+++@+",
"++.+++++",
"@+++@+++",
"++++++.+",
"++@++.@+",
"++++.+++",
"@+++@+++"};

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Some files were not shown because too many files have changed in this diff Show More