Initial profile setup
Some checks failed
Update Gitea Profile / update-profile (push) Has been cancelled

This commit is contained in:
NickiCloud 2026-09-02 20:51:41 +02:00
parent 3a9c404b55
commit c9b2a96efa
13 changed files with 2304 additions and 1 deletions

View File

@ -0,0 +1,65 @@
name: Update Gitea Profile
on:
schedule:
# Täglich um 00:00 Uhr UTC
- cron: '0 0 * * *'
# Erlaubt das manuelle Ausführen über das Gitea Web-Interface
workflow_dispatch:
# Optional: Auch ausführen, wenn manuelle Änderungen an Code oder README gepusht werden
push:
branches:
- main
paths-ignore:
- 'heatmap.svg'
- 'README.md'
jobs:
update-profile:
runs-on: ubuntu-latest
# Berechtigungen für das Schreiben ins Repository
permissions:
contents: write
steps:
- name: 📥 Repository auschecken
uses: actions/checkout@v4
with:
fetch-depth: 0
# Nutzt standardmäßig das automatische Gitea-Token oder ein benutzerdefiniertes Secret
token: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}
- name: ⚙️ .NET 10 SDK einrichten
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: 🚀 C# Profile-Manager ausführen
env:
GITEA_API_URL: 'https://git.webcore-consulting.dev/api/v1'
GITEA_USERNAME: 'NickiCloud'
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}
run: |
dotnet run --project src/ProfileManager
- name: 📝 Änderungen prüfen, committen und pushen
run: |
# Git-Identität für den Bot konfigurieren
git config --global user.name "NickiBot[bot]"
git config --global user.email "bot@webcore-consulting.dev"
# Prüfen, ob sich heatmap.svg oder README.md geändert haben
if git status --porcelain | grep -E "(heatmap.svg|README.md)"; then
echo "Änderungen an heatmap.svg oder README.md erkannt. Committe..."
git add heatmap.svg README.md
# GANZ WICHTIG: [skip ci] verhindert Endlosschleifen in Gitea Actions!
git commit -m "chore(profile): update activity heatmap & stats [skip ci]"
# Änderungen pushen
git push
echo "Profil erfolgreich aktualisiert und gepusht! ✨"
else
echo "Keine Änderungen vorhanden. Alles auf dem neuesten Stand. 👍"
fi

16
.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
## .NET Build & Output
bin/
obj/
*.user
*.suo
*.userosscache
*.sln.docstates
## IDEs
.idea/
.vscode/
*.swp
## OS Files
.DS_Store
Thumbs.db

View File

@ -1,2 +1,94 @@
# .profile <div align="center">
# 🦊 Hey, ich bin Nicki!
### 🎓 Fachabiturient & Leidenschaftlicher C# / .NET Softwareentwickler
*Building high-performance desktop & web applications with a touch of purple fox magic.*
<br/>
[![C#](https://img.shields.io/badge/C%23-239120?style=for-the-badge&logo=csharp&logoColor=white)](https://dotnet.microsoft.com/)
[![.NET 10](https://img.shields.io/badge/.NET_10-512BD4?style=for-the-badge&logo=dotnet&logoColor=white)](https://dotnet.microsoft.com/)
[![Avalonia UI](https://img.shields.io/badge/Avalonia_UI-7e3f98?style=for-the-badge&logo=dotnet&logoColor=white)](https://avaloniaui.net/)
[![Blazor](https://img.shields.io/badge/Blazor-512BD4?style=for-the-badge&logo=blazor&logoColor=white)](https://dotnet.microsoft.com/apps/aspnet/web-apps/blazor)
[![MudBlazor](https://img.shields.io/badge/MudBlazor-9c27b0?style=for-the-badge&logo=blazor&logoColor=white)](https://mudblazor.com/)
[![JetBrains Rider](https://img.shields.io/badge/JetBrains_Rider-000000?style=for-the-badge&logo=rider&logoColor=white)](https://www.jetbrains.com/rider/)
[![Pop!_OS](https://img.shields.io/badge/Pop!_OS-48B9C7?style=for-the-badge&logo=popos&logoColor=white)](https://pop.system76.com/)
[![Debian](https://img.shields.io/badge/Debian-A81D33?style=for-the-badge&logo=debian&logoColor=white)](https://www.debian.org/)
[![Docker](https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white)](https://www.docker.com/)
[![Gitea](https://img.shields.io/badge/Gitea-609926?style=for-the-badge&logo=gitea&logoColor=white)](https://about.gitea.com/)
---
### 🐾 "Schlau wie ein Fuchs, schnell wie .NET & kompromisslos self-hosted."
</div>
## 🧙‍♂️ Über mich & Meine Mission
Ich bin **Nicki**, angehender Softwareentwickler mit klarem Fokus auf moderne Software-Architektur im .NET-Ökosystem. Wenn ich nicht gerade sauberen, asynchronen C#-Code in **JetBrains Rider auf Pop!_OS** tippe, optimiere ich meine eigene Linux-Server-Infrastruktur oder erkunde virtuelle Welten.
- 💻 **Mein Fokus:** C#, .NET 10, XAML, Cross-Platform UIs mit **Avalonia UI** und reaktive Web-UIs mit **Blazor & MudBlazor**.
- 🐧 **Mein OS & Dev-Setup:** Rider-Enthusiast auf **Pop!_OS (Linux)** weil Entwicklungs-Power Freiheit braucht.
- 🏰 **Self-Hosting & Homelab:** Eigener Debian-Server mit Docker-Containern: Gitea, Mailcow, Nginx Reverse-Proxy, abgesichert mit UFW & fail2ban.
- 🎮 **Gaming & Abschalten:** Wenn der Build grün ist, findet man mich in **Minecraft** (Redstone & Modpacks), taktischen Matches in **Rainbow Six Siege** oder beim Farmen in **Warframe**.
- 🦊 **Das Fuchs-Branding:** Lila Akzente, Neugier, Agilität und maximale Eleganz in Code & UI.
---
<div align="center">
## 📊 Meine Gitea Aktivität & Streak
<!-- Die Heatmap wird vollautomatisch per Gitea Action mit .NET 10 generiert -->
<img src="heatmap.svg" alt="Nicki's Gitea Contribution Heatmap" width="100%" />
</div>
---
## 🛠️ Ausgewählte Projekte
Hier sind ein paar Highlights aus meiner Entwickler-Schmiede:
| Projekt | Stack | Beschreibung |
| :--- | :---: | :--- |
| 📝 **Markdown Editor Pro** | `Avalonia UI``C#` | Eleganter, performanter Desktop-Editor mit Live-Rendering, Math-Support und Theme-Engine. |
| 💧 **CloudDrop** | `ASP.NET Core``Docker` | Self-hosted File-Sharing Service für schnelle, verschlüsselte Uploads & temporäre Links. |
| 💳 **KasseApp** | `C#``SQLite``WPF/Avalonia` | Modulare POS- und Kassenabrechnungs-Software mit Offline-Synchronisation für Events. |
| 🥤 **DrinkReminder** | `C#``Avalonia UI` | Schicker, unaufdringlicher Desktop- & Mobile-Helfer für gesunde Hydration im Dev-Alltag. |
---
## 🚀 Zuletzt aktualisierte Repositories
<!-- Dieser Bereich wird dynamisch von der C# .NET 10 Konsolen-App aktualisiert -->
<!-- START_LATEST_REPOS -->
- 🚀 [**.profile**](https://git.webcore-consulting.dev/NickiCloud/.profile) 🦊 Gitea-Profil & dynamische Aktivitäts-Statistiken mit .NET 10
`C# / Markdown` • 🕒 *Aktualisiert am 02.09.2026*
<!-- END_LATEST_REPOS -->
---
<div align="center">
### 🎮 Gaming Corner & Fun Facts
[![Minecraft](https://img.shields.io/badge/Minecraft-5B8C33?style=for-the-badge&logo=minecraft&logoColor=white)](https://minecraft.net)
[![Rainbow Six Siege](https://img.shields.io/badge/Rainbow_Six_Siege-000000?style=for-the-badge&logo=ubisoft&logoColor=white)](https://www.ubisoft.com/game/rainbow-six/siege)
[![Warframe](https://img.shields.io/badge/Warframe-7e3f98?style=for-the-badge&logo=target&logoColor=white)](https://www.warframe.com/)
```csharp
var nicki = new Developer
{
Drink = "Kühles Spezi oder Mate",
Editor = "JetBrains Rider",
Terminal = "zsh on Pop!_OS",
Motto = "Refactor early, test continuously, deploy autonomously."
};
```
<sub>Gebaut mit purer C# .NET 10 Power & Gitea Actions • Design im Lila-Fuchs-Style 💜</sub>
</div>

1220
heatmap.svg Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,42 @@
using System.Text.Json.Serialization;
namespace ProfileManager.Models;
/// <summary>
/// DTO representing a repository in Gitea's /api/v1/users/{user}/repos response.
/// </summary>
public sealed class GiteaRepo
{
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("full_name")]
public string FullName { get; set; } = string.Empty;
[JsonPropertyName("description")]
public string? Description { get; set; }
[JsonPropertyName("html_url")]
public string HtmlUrl { get; set; } = string.Empty;
[JsonPropertyName("language")]
public string? Language { get; set; }
[JsonPropertyName("fork")]
public bool Fork { get; set; }
[JsonPropertyName("archived")]
public bool Archived { get; set; }
[JsonPropertyName("stars_count")]
public int StarsCount { get; set; }
[JsonPropertyName("forks_count")]
public int ForksCount { get; set; }
[JsonPropertyName("updated_at")]
public DateTime UpdatedAt { get; set; }
}

View File

@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace ProfileManager.Models;
/// <summary>
/// DTO representing an entry in Gitea's /api/v1/users/{user}/heatmap response.
/// </summary>
public sealed class HeatmapItem
{
[JsonPropertyName("timestamp")]
public long Timestamp { get; set; }
[JsonPropertyName("contributions")]
public int Contributions { get; set; }
}

View File

@ -0,0 +1,28 @@
namespace ProfileManager.Models;
public sealed class LanguageStat
{
public required string Name { get; init; }
public required double Percentage { get; init; }
public required int RepoCount { get; init; }
public required string Color { get; init; }
}
public sealed class DayContribution
{
public required DateOnly Date { get; init; }
public required int Count { get; init; }
public required int Level { get; init; } // 0..4
public required int Column { get; init; } // 0..51
public required int Row { get; init; } // 0..6 (0=Mo..6=So)
}
public sealed class ProfileStats
{
public required int TotalContributions365 { get; init; }
public required int CurrentStreak { get; init; }
public required int LongestStreak { get; init; }
public required List<DayContribution> Days { get; init; }
public required List<LanguageStat> TopLanguages { get; init; }
public required List<GiteaRepo> LatestRepos { get; init; }
}

View File

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,143 @@
using System.Text;
using ProfileManager.Services;
namespace ProfileManager;
internal static class Program
{
private static async Task<int> Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;
Console.WriteLine("""
====================================================
🦊 NickiCloud Gitea Profile Manager (.NET 10)
Zero-Dependency Autonomous Activity Engine
====================================================
""");
// Parse arguments / environment variables
var username = GetConfigValue(args, "--username", "GITEA_USERNAME", "NickiCloud");
var apiUrl = GetConfigValue(args, "--api-url", "GITEA_API_URL", "https://git.webcore-consulting.dev/api/v1");
var token = GetConfigValue(args, "--token", "GITEA_TOKEN", string.Empty);
var svgPath = GetConfigValue(args, "--output-svg", "OUTPUT_SVG", "heatmap.svg");
var readmePath = GetConfigValue(args, "--readme", "README_PATH", "README.md");
// Resolve paths relative to working directory or repo root
if (!Path.IsPathRooted(svgPath))
{
svgPath = Path.GetFullPath(Path.Combine(FindRepoRoot(), svgPath));
}
if (!Path.IsPathRooted(readmePath))
{
readmePath = Path.GetFullPath(Path.Combine(FindRepoRoot(), readmePath));
}
Console.WriteLine($"[Config] Target User : {username}");
Console.WriteLine($"[Config] API Endpoint: {apiUrl}");
Console.WriteLine($"[Config] Output SVG : {svgPath}");
Console.WriteLine($"[Config] Target README: {readmePath}");
Console.WriteLine();
try
{
var client = new GiteaApiClient(apiUrl, string.IsNullOrWhiteSpace(token) ? null : token);
// Step 1: Fetch data in parallel
var heatmapTask = client.GetHeatmapAsync(username);
var reposTask = client.GetReposAsync(username);
await Task.WhenAll(heatmapTask, reposTask);
var heatmap = await heatmapTask;
var repos = await reposTask;
// Step 2: Calculate metrics
Console.WriteLine("[Stats] Calculating streaks, contributions and language metrics...");
var stats = StatsCalculator.Calculate(heatmap, repos);
Console.WriteLine($"[Stats] Total Contributions (365d): {stats.TotalContributions365}");
Console.WriteLine($"[Stats] Current Streak : {stats.CurrentStreak} days");
Console.WriteLine($"[Stats] Longest Streak : {stats.LongestStreak} days");
Console.WriteLine($"[Stats] Top Languages : {string.Join(", ", stats.TopLanguages.Select(l => $"{l.Name} ({l.Percentage:F1}%)"))}");
Console.WriteLine($"[Stats] Latest Repositories : {string.Join(", ", stats.LatestRepos.Select(r => r.Name))}");
Console.WriteLine();
// Step 3: Generate animated SVG
Console.WriteLine($"[SVG] Rendering responsive SVG...");
var svgContent = SvgGenerator.Generate(stats);
var svgDir = Path.GetDirectoryName(svgPath);
if (!string.IsNullOrEmpty(svgDir) && !Directory.Exists(svgDir))
{
Directory.CreateDirectory(svgDir);
}
await File.WriteAllTextAsync(svgPath, svgContent, Encoding.UTF8);
Console.WriteLine($"[SVG] Saved successfully to: {svgPath}");
// Step 4: Inject latest repos into README.md
if (File.Exists(readmePath))
{
Console.WriteLine($"[README] Injecting latest repositories into: {readmePath}");
ReadmeInjector.InjectLatestRepos(readmePath, stats.LatestRepos);
}
else
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[README-WARN] Target README not found at {readmePath}. Skipping injection.");
Console.ResetColor();
}
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("\n✨ Profile update finished successfully!");
Console.ResetColor();
return 0;
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"\n❌ Critical Error: {ex.Message}");
Console.WriteLine(ex.StackTrace);
Console.ResetColor();
return 1;
}
}
private static string GetConfigValue(string[] args, string flag, string envVar, string defaultValue)
{
for (int i = 0; i < args.Length - 1; i++)
{
if (string.Equals(args[i], flag, StringComparison.OrdinalIgnoreCase))
{
return args[i + 1];
}
}
var env = Environment.GetEnvironmentVariable(envVar);
if (!string.IsNullOrWhiteSpace(env))
{
return env;
}
return defaultValue;
}
private static string FindRepoRoot()
{
var current = Directory.GetCurrentDirectory();
while (!string.IsNullOrEmpty(current))
{
if (Directory.Exists(Path.Combine(current, ".git")) ||
Directory.Exists(Path.Combine(current, ".gitea")) ||
File.Exists(Path.Combine(current, "README.md")))
{
return current;
}
var parent = Directory.GetParent(current)?.FullName;
if (parent == current) break;
current = parent;
}
return Directory.GetCurrentDirectory();
}
}

View File

@ -0,0 +1,208 @@
using System.Net.Http.Headers;
using System.Text.Json;
using ProfileManager.Models;
namespace ProfileManager.Services;
public sealed class GiteaApiClient
{
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
public GiteaApiClient(string? baseUrl = null, string? apiToken = null)
{
_baseUrl = (baseUrl ?? Environment.GetEnvironmentVariable("GITEA_API_URL") ?? "https://git.webcore-consulting.dev/api/v1")
.TrimEnd('/');
var handler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
ConnectTimeout = TimeSpan.FromSeconds(10)
};
_httpClient = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(15)
};
_httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("NickiProfileBot", "1.0"));
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = apiToken ?? Environment.GetEnvironmentVariable("GITEA_TOKEN");
if (!string.IsNullOrWhiteSpace(token))
{
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
}
public async Task<List<HeatmapItem>> GetHeatmapAsync(string username, CancellationToken cancellationToken = default)
{
var url = $"{_baseUrl}/users/{Uri.EscapeDataString(username)}/heatmap";
Console.WriteLine($"[API] Fetching heatmap from: {url}");
try
{
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var items = JsonSerializer.Deserialize<List<HeatmapItem>>(json, JsonOptions);
if (items != null && items.Count > 0)
{
Console.WriteLine($"[API] Successfully retrieved {items.Count} heatmap data points.");
return items;
}
}
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[API-WARN] Heatmap request failed (Status: {response.StatusCode}). Switching to mock fallback.");
Console.ResetColor();
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[API-WARN] Network error connecting to Gitea ({ex.Message}). Using realistic fallback data.");
Console.ResetColor();
}
return GenerateMockHeatmap();
}
public async Task<List<GiteaRepo>> GetReposAsync(string username, CancellationToken cancellationToken = default)
{
var url = $"{_baseUrl}/users/{Uri.EscapeDataString(username)}/repos?limit=100&sort=updated";
Console.WriteLine($"[API] Fetching repositories from: {url}");
try
{
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var repos = JsonSerializer.Deserialize<List<GiteaRepo>>(json, JsonOptions);
if (repos != null && repos.Count > 0)
{
Console.WriteLine($"[API] Successfully retrieved {repos.Count} repositories.");
return repos;
}
}
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[API-WARN] Repositories request failed (Status: {response.StatusCode}). Switching to mock fallback.");
Console.ResetColor();
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[API-WARN] Network error connecting to Gitea ({ex.Message}). Using realistic fallback data.");
Console.ResetColor();
}
return GenerateMockRepos();
}
private static List<HeatmapItem> GenerateMockHeatmap()
{
var items = new List<HeatmapItem>();
var now = DateTimeOffset.UtcNow;
var random = new Random(42); // Fixed seed for reproducible fallback
for (int i = 0; i < 365; i++)
{
var day = now.AddDays(-i);
// Simulate realistic developer habits (more activity on weekdays and active streaks)
bool isWeekend = day.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
int chance = isWeekend ? 40 : 75;
if (random.Next(100) < chance)
{
int commits = random.Next(1, 14);
items.Add(new HeatmapItem
{
Timestamp = day.ToUnixTimeSeconds(),
Contributions = commits
});
}
}
return items;
}
private static List<GiteaRepo> GenerateMockRepos()
{
var now = DateTime.UtcNow;
return
[
new GiteaRepo
{
Name = "Markdown-Editor-Pro",
FullName = "NickiCloud/Markdown-Editor-Pro",
Description = "Moderner, performanter Markdown-Editor mit Live-Vorschau, Syntax-Highlighting & LaTeX-Support auf Avalonia UI.",
HtmlUrl = "https://git.webcore-consulting.dev/NickiCloud/Markdown-Editor-Pro",
Language = "C#",
StarsCount = 7,
ForksCount = 1,
UpdatedAt = now.AddHours(-3)
},
new GiteaRepo
{
Name = "CloudDrop",
FullName = "NickiCloud/CloudDrop",
Description = "Leichtgewichtiger, selbstgehosteter File-Sharing-Dienst mit temporären Download-Links und verschlüsselter Ablage.",
HtmlUrl = "https://git.webcore-consulting.dev/NickiCloud/CloudDrop",
Language = "C#",
StarsCount = 5,
ForksCount = 0,
UpdatedAt = now.AddDays(-2)
},
new GiteaRepo
{
Name = "KasseApp",
FullName = "NickiCloud/KasseApp",
Description = "Intuitive Kassen- und Abrechnungssoftware für Vereine und Events mit Offline-First SQLite-Datenbank.",
HtmlUrl = "https://git.webcore-consulting.dev/NickiCloud/KasseApp",
Language = "C#",
StarsCount = 4,
ForksCount = 0,
UpdatedAt = now.AddDays(-5)
},
new GiteaRepo
{
Name = "DrinkReminder",
FullName = "NickiCloud/DrinkReminder",
Description = "Smarter Tray-Assistent für Desktop & Mobile zur Erinnerung an regelmäßige Flüssigkeitszufuhr.",
HtmlUrl = "https://git.webcore-consulting.dev/NickiCloud/DrinkReminder",
Language = "C#",
StarsCount = 3,
ForksCount = 0,
UpdatedAt = now.AddDays(-12)
},
new GiteaRepo
{
Name = "Selfhosted-Infra",
FullName = "NickiCloud/Selfhosted-Infra",
Description = "Docker-Compose & Konfigurationen für Debian, Gitea, Mailcow, Nginx Reverse Proxy, UFW & Fail2ban.",
HtmlUrl = "https://git.webcore-consulting.dev/NickiCloud/Selfhosted-Infra",
Language = "Shell",
StarsCount = 6,
ForksCount = 0,
UpdatedAt = now.AddDays(-18)
},
new GiteaRepo
{
Name = "WebCore-Portal",
FullName = "NickiCloud/WebCore-Portal",
Description = "Interaktives Service-Portal mit MudBlazor Komponenten und JWT-Authentifizierung.",
HtmlUrl = "https://git.webcore-consulting.dev/NickiCloud/WebCore-Portal",
Language = "HTML",
StarsCount = 2,
ForksCount = 0,
UpdatedAt = now.AddDays(-25)
}
];
}
}

View File

@ -0,0 +1,70 @@
using System.Text;
using System.Text.RegularExpressions;
using ProfileManager.Models;
namespace ProfileManager.Services;
public static class ReadmeInjector
{
private const string StartMarker = "<!-- START_LATEST_REPOS -->";
private const string EndMarker = "<!-- END_LATEST_REPOS -->";
public static bool InjectLatestRepos(string readmePath, List<GiteaRepo> repos)
{
if (!File.Exists(readmePath))
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[README-WARN] File not found: {readmePath}");
Console.ResetColor();
return false;
}
var content = File.ReadAllText(readmePath, Encoding.UTF8);
int startIndex = content.IndexOf(StartMarker, StringComparison.Ordinal);
int endIndex = content.IndexOf(EndMarker, StringComparison.Ordinal);
if (startIndex == -1 || endIndex == -1 || endIndex < startIndex)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[README-WARN] Markers '{StartMarker}' and '{EndMarker}' not found in {readmePath}");
Console.ResetColor();
return false;
}
var replacement = new StringBuilder();
replacement.AppendLine(StartMarker);
if (repos.Count == 0)
{
replacement.AppendLine("*Momentan keine öffentlichen Repositories verfügbar.*");
}
else
{
foreach (var repo in repos)
{
var desc = string.IsNullOrWhiteSpace(repo.Description)
? (repo.Name.Equals(".profile", StringComparison.OrdinalIgnoreCase)
? "🦊 Gitea-Profil & dynamische Aktivitäts-Statistiken mit .NET 10"
: "Persönliches Entwicklungsprojekt.")
: repo.Description.Trim();
var lang = string.IsNullOrWhiteSpace(repo.Language)
? (repo.Name.Equals(".profile", StringComparison.OrdinalIgnoreCase) ? "C# / Markdown" : "Code")
: repo.Language;
var stars = repo.StarsCount > 0 ? $" • ⭐ {repo.StarsCount}" : string.Empty;
var updated = repo.UpdatedAt.ToString("dd.MM.yyyy");
replacement.AppendLine($"- 🚀 [**{repo.Name}**]({repo.HtmlUrl}) {desc} ");
replacement.AppendLine($" `{lang}`{stars} • 🕒 *Aktualisiert am {updated}*");
}
}
replacement.Append(EndMarker);
var newContent = content.Substring(0, startIndex) + replacement + content.Substring(endIndex + EndMarker.Length);
File.WriteAllText(readmePath, newContent, Encoding.UTF8);
Console.WriteLine($"[README] Successfully injected {repos.Count} repositories into {readmePath}");
return true;
}
}

View File

@ -0,0 +1,180 @@
using ProfileManager.Models;
namespace ProfileManager.Services;
public static class StatsCalculator
{
private static readonly Dictionary<string, string> LanguageColors = new(StringComparer.OrdinalIgnoreCase)
{
{ "C#", "#a855f7" },
{ "C++", "#f34b7d" },
{ "C", "#555555" },
{ "HTML", "#e34c26" },
{ "CSS", "#563d7c" },
{ "JavaScript", "#f1e05a" },
{ "TypeScript", "#3178c6" },
{ "Python", "#3572A5" },
{ "Shell", "#89e051" },
{ "Bash", "#89e051" },
{ "Dockerfile", "#384d54" },
{ "Go", "#00ADD8" },
{ "Rust", "#dea584" },
{ "Vue", "#41b883" },
{ "PHP", "#4F5D95" },
{ "Kotlin", "#A97BFF" },
{ "Java", "#b07219" },
{ "Dart", "#00B4AB" }
};
public static ProfileStats Calculate(List<HeatmapItem> heatmapItems, List<GiteaRepo> repos, DateOnly? referenceDate = null)
{
var today = referenceDate ?? DateOnly.FromDateTime(DateTime.UtcNow);
// 1. Group heatmap contributions by DateOnly (UTC)
var dateMap = new Dictionary<DateOnly, int>();
foreach (var item in heatmapItems)
{
var date = DateOnly.FromDateTime(DateTimeOffset.FromUnixTimeSeconds(item.Timestamp).UtcDateTime);
dateMap[date] = dateMap.GetValueOrDefault(date, 0) + Math.Max(0, item.Contributions);
}
// 2. Build 52x7 Grid ending on Sunday of current week (Monday-based rows 0..6)
int dayOffsetFromMonday = ((int)today.DayOfWeek + 6) % 7;
var gridEndSunday = today.AddDays(6 - dayOffsetFromMonday);
var gridStartMonday = gridEndSunday.AddDays(-(52 * 7 - 1));
var days = new List<DayContribution>(52 * 7);
for (int col = 0; col < 52; col++)
{
for (int row = 0; row < 7; row++)
{
var currentDate = gridStartMonday.AddDays(col * 7 + row);
int count = currentDate > today ? 0 : dateMap.GetValueOrDefault(currentDate, 0);
int level = count switch
{
0 => 0,
>= 1 and <= 3 => 1,
>= 4 and <= 6 => 2,
>= 7 and <= 9 => 3,
_ => 4
};
days.Add(new DayContribution
{
Date = currentDate,
Count = count,
Level = level,
Column = col,
Row = row
});
}
}
// 3. Total contributions (last 365 days)
var oneYearAgo = today.AddDays(-365);
int total365 = dateMap
.Where(kv => kv.Key >= oneYearAgo && kv.Key <= today)
.Sum(kv => kv.Value);
// 4. Streaks calculation
int currentStreak = 0;
if (dateMap.GetValueOrDefault(today, 0) > 0)
{
currentStreak = 1;
var check = today.AddDays(-1);
while (dateMap.GetValueOrDefault(check, 0) > 0)
{
currentStreak++;
check = check.AddDays(-1);
}
}
else
{
// If today has no commit yet, yesterday's streak is still active
var check = today.AddDays(-1);
while (dateMap.GetValueOrDefault(check, 0) > 0)
{
currentStreak++;
check = check.AddDays(-1);
}
}
// Longest Streak across all dates
int longestStreak = 0;
if (dateMap.Count > 0)
{
var minDate = dateMap.Keys.Min();
if (minDate > gridStartMonday) minDate = gridStartMonday;
int currentRun = 0;
for (var d = minDate; d <= today; d = d.AddDays(1))
{
if (dateMap.GetValueOrDefault(d, 0) > 0)
{
currentRun++;
if (currentRun > longestStreak) longestStreak = currentRun;
}
else
{
currentRun = 0;
}
}
}
// 5. Top 3 Repos (sorted by UpdatedAt desc, exclude forks)
var nonForkRepos = repos.Where(r => !r.Fork).ToList();
var candidateRepos = nonForkRepos.Count > 0 ? nonForkRepos : repos;
var latestRepos = candidateRepos
.OrderByDescending(r => r.UpdatedAt)
.Take(3)
.ToList();
// 6. Top Languages calculation
var languageGroups = candidateRepos
.Where(r => !string.IsNullOrWhiteSpace(r.Language))
.GroupBy(r => r.Language!.Trim())
.Select(g => new { Name = g.Key, Count = g.Count() })
.OrderByDescending(x => x.Count)
.ToList();
int totalLanguageRepos = languageGroups.Sum(x => x.Count);
var topLanguages = new List<LanguageStat>();
if (totalLanguageRepos > 0)
{
foreach (var lang in languageGroups.Take(5))
{
double pct = Math.Round((double)lang.Count / totalLanguageRepos * 100.0, 1);
topLanguages.Add(new LanguageStat
{
Name = lang.Name,
Percentage = pct,
RepoCount = lang.Count,
Color = LanguageColors.GetValueOrDefault(lang.Name, "#c084fc")
});
}
}
else
{
// Default language representation if repos have no language tags
topLanguages.Add(new LanguageStat
{
Name = "C#",
Percentage = 100.0,
RepoCount = 1,
Color = "#a855f7"
});
}
return new ProfileStats
{
TotalContributions365 = total365,
CurrentStreak = currentStreak,
LongestStreak = Math.Max(longestStreak, currentStreak),
Days = days,
TopLanguages = topLanguages,
LatestRepos = latestRepos
};
}
}

View File

@ -0,0 +1,213 @@
using System.Globalization;
using System.Text;
using ProfileManager.Models;
namespace ProfileManager.Services;
public static class SvgGenerator
{
private static readonly string[] MonthNames =
[
"Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"
];
public static string Generate(ProfileStats stats)
{
var sb = new StringBuilder(16384);
const int svgWidth = 740;
const int svgHeight = 265;
const int gridStartX = 34;
const int gridStartY = 64;
const int cellSize = 10;
const int cellGap = 3;
const int colStep = cellSize + cellGap; // 13px
sb.AppendLine($"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {svgWidth} {svgHeight}" width="100%" height="100%">""");
sb.AppendLine(" <defs>");
sb.AppendLine(" <linearGradient id=\"foxGrad\" x1=\"0%\" y1=\"0%\" x2=\"100%\" y2=\"100%\">");
sb.AppendLine(" <stop offset=\"0%\" stop-color=\"#d980fa\" />");
sb.AppendLine(" <stop offset=\"100%\" stop-color=\"#7e3f98\" />");
sb.AppendLine(" </linearGradient>");
sb.AppendLine(" <clipPath id=\"langBarClip\">");
sb.AppendLine($" <rect x=\"{gridStartX}\" y=\"215\" width=\"676\" height=\"8\" rx=\"4\" />");
sb.AppendLine(" </clipPath>");
sb.AppendLine(" </defs>");
// Styles & Media Queries
sb.AppendLine("""
<style>
:root {
--bg: #11111b;
--card: #181825;
--border: #313244;
--text-main: #cdd6f4;
--text-sub: #a6adc8;
--text-muted: #6c7086;
--accent: #d980fa;
--c-lvl-0: #1e1e2e;
--c-lvl-0-border: #27273a;
--c-lvl-1: #4a2559;
--c-lvl-2: #7e3f98;
--c-lvl-3: #a349a4;
--c-lvl-4: #d980fa;
--metric-bg: #1e1e2e;
--metric-border: #313244;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #fbf7ff;
--card: #ffffff;
--border: #e9d5ff;
--text-main: #2e1065;
--text-sub: #6b21a8;
--text-muted: #9333ea;
--accent: #9333ea;
--c-lvl-0: #ede9fe;
--c-lvl-0-border: #e4d4f7;
--c-lvl-1: #c4b5fd;
--c-lvl-2: #a78bfa;
--c-lvl-3: #8b5cf6;
--c-lvl-4: #6d28d9;
--metric-bg: #faf5ff;
--metric-border: #e9d5ff;
}
}
.bg-panel { fill: var(--bg); stroke: var(--border); stroke-width: 1.2px; rx: 12px; }
.title { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 700; fill: var(--text-main); }
.subtitle { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 11px; fill: var(--text-sub); }
.lbl-month, .lbl-day { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 9px; fill: var(--text-muted); }
.stat-val { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 13px; font-weight: 700; fill: var(--text-main); }
.stat-lbl { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 10px; fill: var(--text-sub); }
.lang-lbl { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; font-size: 10px; font-weight: 500; fill: var(--text-sub); }
@keyframes fadeIn {
0% { opacity: 0; transform: scale(0.65); }
100% { opacity: 1; transform: scale(1); }
}
.cell {
animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
transform-origin: center;
}
.lvl-0 { fill: var(--c-lvl-0); stroke: var(--c-lvl-0-border); stroke-width: 0.5px; }
.lvl-1 { fill: var(--c-lvl-1); }
.lvl-2 { fill: var(--c-lvl-2); }
.lvl-3 { fill: var(--c-lvl-3); }
.lvl-4 { fill: var(--c-lvl-4); }
</style>
""");
// Background Panel
sb.AppendLine($" <rect class=\"bg-panel\" x=\"1\" y=\"1\" width=\"{svgWidth - 2}\" height=\"{svgHeight - 2}\" />");
// Header: Title & Total Commits Badge
sb.AppendLine(" <g transform=\"translate(20, 20)\">");
sb.AppendLine(" <!-- Fox Icon & Title -->");
sb.AppendLine(" <text class=\"title\" x=\"0\" y=\"14\">🦊 NickiCloud <tspan font-weight=\"400\" fill=\"var(--accent)\">•</tspan> <tspan font-weight=\"500\" font-size=\"12\" fill=\"var(--text-sub)\">Gitea Activity</tspan></text>");
// Header badge (right aligned)
string badgeText = $"{stats.TotalContributions365:N0} Commits im letzten Jahr";
sb.AppendLine($" <rect x=\"510\" y=\"0\" width=\"190\" height=\"22\" rx=\"11\" fill=\"var(--metric-bg)\" stroke=\"var(--metric-border)\" stroke-width=\"1\" />");
sb.AppendLine($" <circle cx=\"522\" cy=\"11\" r=\"4\" fill=\"var(--accent)\" />");
sb.AppendLine($" <text class=\"subtitle\" x=\"532\" y=\"15\" font-weight=\"600\">{badgeText}</text>");
sb.AppendLine(" </g>");
// Month Labels
sb.AppendLine(" <!-- Month Labels -->");
int lastMonth = -1;
for (int col = 0; col < 52; col++)
{
var firstDayOfCol = stats.Days.FirstOrDefault(d => d.Column == col && d.Row == 0);
if (firstDayOfCol != null && firstDayOfCol.Date.Month != lastMonth)
{
lastMonth = firstDayOfCol.Date.Month;
// Avoid rendering month if too close to right edge
if (col < 50)
{
int x = gridStartX + col * colStep;
sb.AppendLine($" <text class=\"lbl-month\" x=\"{x}\" y=\"54\">{MonthNames[lastMonth - 1]}</text>");
}
}
}
// Day of Week Labels (Mo, Mi, Fr)
sb.AppendLine(" <!-- Weekday Labels -->");
sb.AppendLine($" <text class=\"lbl-day\" x=\"15\" y=\"{gridStartY + 8}\">Mo</text>");
sb.AppendLine($" <text class=\"lbl-day\" x=\"15\" y=\"{gridStartY + 2 * colStep + 8}\">Mi</text>");
sb.AppendLine($" <text class=\"lbl-day\" x=\"15\" y=\"{gridStartY + 4 * colStep + 8}\">Fr</text>");
// Heatmap Cells (52 cols x 7 rows)
sb.AppendLine(" <!-- Heatmap Grid -->");
sb.AppendLine(" <g>");
foreach (var day in stats.Days)
{
int x = gridStartX + day.Column * colStep;
int y = gridStartY + day.Row * colStep;
int delayMs = day.Column * 12;
string tooltip = $"{day.Date:dd.MM.yyyy}: {day.Count} Commits";
sb.AppendLine($" <rect class=\"cell lvl-{day.Level}\" x=\"{x}\" y=\"{y}\" width=\"{cellSize}\" height=\"{cellSize}\" rx=\"2\" style=\"animation-delay: {delayMs}ms;\">");
sb.AppendLine($" <title>{tooltip}</title>");
sb.AppendLine(" </rect>");
}
sb.AppendLine(" </g>");
// Stats Row: 3 metric cards
sb.AppendLine(" <!-- Stat Badges -->");
int cardY = 166;
int cardHeight = 36;
int cardWidth = 216;
int gap = 14;
// Card 1: Total
RenderMetricCard(sb, gridStartX, cardY, cardWidth, cardHeight, "📦", $"{stats.TotalContributions365:N0}", "Jahres-Beiträge");
// Card 2: Current Streak
RenderMetricCard(sb, gridStartX + cardWidth + gap, cardY, cardWidth, cardHeight, "🔥", $"{stats.CurrentStreak} {(stats.CurrentStreak == 1 ? "Tag" : "Tage")}", "Aktueller Streak");
// Card 3: Longest Streak
RenderMetricCard(sb, gridStartX + (cardWidth + gap) * 2, cardY, cardWidth, cardHeight, "⚡", $"{stats.LongestStreak} {(stats.LongestStreak == 1 ? "Tag" : "Tage")}", "Längster Streak");
// Top Languages Bar (width = 676px)
sb.AppendLine(" <!-- Top Languages Bar -->");
sb.AppendLine(" <g clip-path=\"url(#langBarClip)\">");
double currentX = gridStartX;
const double totalBarWidth = 676.0;
foreach (var lang in stats.TopLanguages)
{
double w = Math.Max(2.0, (lang.Percentage / 100.0) * totalBarWidth);
sb.AppendLine($" <rect x=\"{currentX.ToString("F1", CultureInfo.InvariantCulture)}\" y=\"215\" width=\"{w.ToString("F1", CultureInfo.InvariantCulture)}\" height=\"8\" fill=\"{lang.Color}\">");
sb.AppendLine($" <title>{lang.Name}: {lang.Percentage:F1}%</title>");
sb.AppendLine(" </rect>");
currentX += w;
}
sb.AppendLine(" </g>");
// Language Legend
sb.AppendLine(" <!-- Language Legend -->");
sb.AppendLine(" <g transform=\"translate(34, 238)\">");
int legendX = 0;
foreach (var lang in stats.TopLanguages)
{
sb.AppendLine($" <circle cx=\"{legendX + 4}\" cy=\"4\" r=\"4\" fill=\"{lang.Color}\" />");
string text = $"{lang.Name} {lang.Percentage:F1}%";
sb.AppendLine($" <text class=\"lang-lbl\" x=\"{legendX + 13}\" y=\"7\">{text}</text>");
legendX += 13 + (text.Length * 7) + 16;
}
sb.AppendLine(" </g>");
sb.AppendLine("</svg>");
return sb.ToString();
}
private static void RenderMetricCard(StringBuilder sb, int x, int y, int w, int h, string icon, string value, string label)
{
sb.AppendLine($" <g transform=\"translate({x}, {y})\">");
sb.AppendLine($" <rect width=\"{w}\" height=\"{h}\" rx=\"8\" fill=\"var(--metric-bg)\" stroke=\"var(--metric-border)\" stroke-width=\"1\" />");
sb.AppendLine($" <text x=\"12\" y=\"23\" font-size=\"16\">{icon}</text>");
sb.AppendLine($" <text class=\"stat-val\" x=\"38\" y=\"17\">{value}</text>");
sb.AppendLine($" <text class=\"stat-lbl\" x=\"38\" y=\"29\">{label}</text>");
sb.AppendLine(" </g>");
}
}