first commit

This commit is contained in:
Zeeshaun
2026-04-20 18:15:08 +00:00
commit c98e158e26
111 changed files with 77565 additions and 0 deletions
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Fragalytics.Web\Fragalytics.Web.csproj" />
</ItemGroup>
</Project>
+26
View File
@@ -0,0 +1,26 @@
using Fragalytics.Web;
using Fragalytics.Web.Infrastructure;
using Fragalytics.Web.Services;
using Fragalytics.RenderWorker.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
var builder = Host.CreateApplicationBuilder(args);
builder.Configuration.AddJsonFile("appsettings.json", optional: true);
builder.Services.Configure<FragalyticsOptions>(builder.Configuration.GetSection("Fragalytics"));
builder.Services.AddSingleton<JsonAppStore>();
builder.Services.AddSingleton<ClipMediaService>();
builder.Services.AddSingleton<IClipRenderBackend>(serviceProvider =>
{
var options = serviceProvider.GetRequiredService<IOptions<FragalyticsOptions>>().Value;
return options.RenderWorker.Backend.Equals("sample-copy", StringComparison.OrdinalIgnoreCase)
? new SampleCopyRenderBackend(serviceProvider.GetRequiredService<IOptions<FragalyticsOptions>>())
: throw new InvalidOperationException($"Unknown render backend '{options.RenderWorker.Backend}'.");
});
builder.Services.AddSingleton<RenderCoordinator>();
builder.Services.AddHostedService<RenderWorkerHostedService>();
await builder.Build().RunAsync();
@@ -0,0 +1,16 @@
using Fragalytics.Web.Models;
namespace Fragalytics.RenderWorker.Services;
public interface IClipRenderBackend
{
string Name { get; }
Task<RenderedClipResult> RenderAsync(ClipJobRecord clipJob, CancellationToken cancellationToken);
}
public sealed class RenderedClipResult
{
public string FilePath { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public string ContentType { get; set; } = "video/mp4";
}
@@ -0,0 +1,116 @@
using Fragalytics.Web;
using Fragalytics.Web.Infrastructure;
using Fragalytics.Web.Models;
using Fragalytics.Web.Services;
using Microsoft.Extensions.Options;
namespace Fragalytics.RenderWorker.Services;
public sealed class RenderCoordinator
{
private readonly JsonAppStore _store;
private readonly ClipMediaService _clipMediaService;
private readonly IClipRenderBackend _backend;
private readonly FragalyticsOptions _options;
public RenderCoordinator(
JsonAppStore store,
ClipMediaService clipMediaService,
IClipRenderBackend backend,
IOptions<FragalyticsOptions> options)
{
_store = store;
_clipMediaService = clipMediaService;
_backend = backend;
_options = options.Value;
}
public async Task<int> ProcessNextAsync(CancellationToken cancellationToken)
{
var claimedJob = await _store.UpdateAsync(state =>
{
var next = state.ClipJobs
.Where(job => string.Equals(job.Status, "pending", StringComparison.OrdinalIgnoreCase))
.OrderBy(job => job.CreatedAtUtc)
.FirstOrDefault();
if (next is null)
{
return null;
}
next.Status = "rendering";
next.Renderer = _backend.Name;
next.ErrorMessage = null;
next.StartedAtUtc = DateTime.UtcNow;
next.Payload["backend"] = _backend.Name;
return new ClaimedClipJob
{
ClipJobId = next.Id,
FileNameHint = $"highlight_{next.HighlightId}.mp4"
};
}, cancellationToken);
if (claimedJob is null)
{
return 0;
}
try
{
var state = await _store.GetSnapshotAsync(cancellationToken);
var fullJob = state.ClipJobs.First(job => job.Id == claimedJob.ClipJobId);
var result = await _backend.RenderAsync(fullJob, cancellationToken);
await using (var stream = File.OpenRead(result.FilePath))
{
await _clipMediaService.AttachClipAssetAsync(
fullJob.Id,
string.IsNullOrWhiteSpace(result.FileName) ? claimedJob.FileNameHint : result.FileName,
result.ContentType,
stream,
cancellationToken);
}
if (File.Exists(result.FilePath))
{
File.Delete(result.FilePath);
}
await _store.UpdateAsync(stateUpdate =>
{
var job = stateUpdate.ClipJobs.First(item => item.Id == fullJob.Id);
job.Renderer = _backend.Name;
job.ErrorMessage = null;
job.CompletedAtUtc = DateTime.UtcNow;
return 0;
}, cancellationToken);
return 1;
}
catch (Exception exception)
{
await _store.UpdateAsync(state =>
{
var job = state.ClipJobs.First(item => item.Id == claimedJob.ClipJobId);
job.Status = "failed";
job.ErrorMessage = exception.Message;
job.CompletedAtUtc = DateTime.UtcNow;
job.Payload["failure"] = exception.Message;
return 0;
}, cancellationToken);
return 0;
}
}
public TimeSpan PollInterval => TimeSpan.FromSeconds(_options.RenderWorker.PollSeconds);
public bool RunContinuously => _options.RenderWorker.RunContinuously;
}
public sealed class ClaimedClipJob
{
public int ClipJobId { get; set; }
public string FileNameHint { get; set; } = string.Empty;
}
@@ -0,0 +1,28 @@
using Microsoft.Extensions.Hosting;
namespace Fragalytics.RenderWorker.Services;
public sealed class RenderWorkerHostedService : BackgroundService
{
private readonly RenderCoordinator _coordinator;
public RenderWorkerHostedService(RenderCoordinator coordinator)
{
_coordinator = coordinator;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_coordinator.RunContinuously)
{
await _coordinator.ProcessNextAsync(stoppingToken);
return;
}
while (!stoppingToken.IsCancellationRequested)
{
await _coordinator.ProcessNextAsync(stoppingToken);
await Task.Delay(_coordinator.PollInterval, stoppingToken);
}
}
}
@@ -0,0 +1,48 @@
using Fragalytics.Web;
using Fragalytics.Web.Models;
using Microsoft.Extensions.Options;
namespace Fragalytics.RenderWorker.Services;
public sealed class SampleCopyRenderBackend : IClipRenderBackend
{
private readonly FragalyticsOptions _options;
public SampleCopyRenderBackend(IOptions<FragalyticsOptions> options)
{
_options = options.Value;
}
public string Name => "sample-copy";
public async Task<RenderedClipResult> RenderAsync(ClipJobRecord clipJob, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_options.RenderWorker.SampleClipPath))
{
throw new InvalidOperationException("Fragalytics:RenderWorker:SampleClipPath is not configured.");
}
var sampleClipPath = Path.GetFullPath(_options.RenderWorker.SampleClipPath);
if (!File.Exists(sampleClipPath))
{
throw new FileNotFoundException("Configured sample clip was not found.", sampleClipPath);
}
var tempDirectory = Path.GetFullPath(_options.RenderWorker.TempDirectory);
Directory.CreateDirectory(tempDirectory);
var extension = Path.GetExtension(sampleClipPath);
var outputPath = Path.Combine(tempDirectory, $"clipjob_{clipJob.Id}_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}{extension}");
await using var input = File.OpenRead(sampleClipPath);
await using var output = File.Create(outputPath);
await input.CopyToAsync(output, cancellationToken);
return new RenderedClipResult
{
FilePath = outputPath,
FileName = Path.GetFileName(sampleClipPath),
ContentType = extension.Equals(".webm", StringComparison.OrdinalIgnoreCase) ? "video/webm" : "video/mp4"
};
}
}
@@ -0,0 +1,13 @@
{
"Fragalytics": {
"DataDirectory": "data",
"UploadDirectory": "data/uploads",
"RenderWorker": {
"PollSeconds": 3,
"Backend": "sample-copy",
"SampleClipPath": "",
"TempDirectory": "data/render-temp",
"RunContinuously": true
}
}
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+27
View File
@@ -0,0 +1,27 @@
namespace Fragalytics.Web;
public sealed class FragalyticsOptions
{
public string DataDirectory { get; set; } = "data";
public string UploadDirectory { get; set; } = "data/uploads";
public int WorkerPollSeconds { get; set; } = 2;
public bool AutoStartWorker { get; set; } = true;
public ParserOptions Parser { get; set; } = new();
public RenderWorkerOptions RenderWorker { get; set; } = new();
}
public sealed class ParserOptions
{
public string Mode { get; set; } = "sample";
public string? ExternalCommand { get; set; }
public string? ExternalArguments { get; set; }
}
public sealed class RenderWorkerOptions
{
public int PollSeconds { get; set; } = 3;
public string Backend { get; set; } = "sample-copy";
public string? SampleClipPath { get; set; }
public string TempDirectory { get; set; } = "data/render-temp";
public bool RunContinuously { get; set; } = true;
}
@@ -0,0 +1,76 @@
using System.Text.Json;
using Fragalytics.Web.Models;
using Microsoft.Extensions.Options;
namespace Fragalytics.Web.Infrastructure;
public sealed class JsonAppStore
{
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly string _storePath;
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
public JsonAppStore(IOptions<FragalyticsOptions> options)
{
var root = Path.GetFullPath(options.Value.DataDirectory);
Directory.CreateDirectory(root);
_storePath = Path.Combine(root, "store.json");
}
public async Task<AppState> GetSnapshotAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken);
try
{
var state = await LoadUnsafeAsync(cancellationToken);
return DeepClone(state);
}
finally
{
_gate.Release();
}
}
public async Task<T> UpdateAsync<T>(Func<AppState, T> update, CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken);
try
{
var state = await LoadUnsafeAsync(cancellationToken);
var result = update(state);
await SaveUnsafeAsync(state, cancellationToken);
return result;
}
finally
{
_gate.Release();
}
}
private async Task<AppState> LoadUnsafeAsync(CancellationToken cancellationToken)
{
if (!File.Exists(_storePath))
{
return new AppState();
}
await using var stream = File.OpenRead(_storePath);
return await JsonSerializer.DeserializeAsync<AppState>(stream, _jsonOptions, cancellationToken)
?? new AppState();
}
private async Task SaveUnsafeAsync(AppState state, CancellationToken cancellationToken)
{
await using var stream = File.Create(_storePath);
await JsonSerializer.SerializeAsync(stream, state, _jsonOptions, cancellationToken);
}
private AppState DeepClone(AppState state)
{
var json = JsonSerializer.Serialize(state, _jsonOptions);
return JsonSerializer.Deserialize<AppState>(json, _jsonOptions) ?? new AppState();
}
}
+170
View File
@@ -0,0 +1,170 @@
namespace Fragalytics.Web.Models;
public sealed class AppState
{
public int NextDemoId { get; set; } = 1;
public int NextMatchId { get; set; } = 1;
public int NextPlayerId { get; set; } = 1;
public int NextRoundId { get; set; } = 1;
public int NextEventId { get; set; } = 1;
public int NextHighlightId { get; set; } = 1;
public int NextClipJobId { get; set; } = 1;
public int NextClipAssetId { get; set; } = 1;
public int NextScoringProfileId { get; set; } = 1;
public List<DemoRecord> Demos { get; set; } = [];
public List<MatchRecord> Matches { get; set; } = [];
public List<PlayerRecord> Players { get; set; } = [];
public List<RoundRecord> Rounds { get; set; } = [];
public List<EventRecord> Events { get; set; } = [];
public List<HighlightRecord> Highlights { get; set; } = [];
public List<ClipJobRecord> ClipJobs { get; set; } = [];
public List<ClipAssetRecord> ClipAssets { get; set; } = [];
public List<ScoringProfileRecord> ScoringProfiles { get; set; } = [];
}
public sealed class DemoRecord
{
public int Id { get; set; }
public string OriginalFileName { get; set; } = string.Empty;
public string StoredPath { get; set; } = string.Empty;
public string ParserBackend { get; set; } = string.Empty;
public string Status { get; set; } = "uploaded";
public string? ErrorMessage { get; set; }
public DateTime UploadedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime? ProcessedAtUtc { get; set; }
public int? MatchId { get; set; }
}
public sealed class MatchRecord
{
public int Id { get; set; }
public int DemoId { get; set; }
public string? MapName { get; set; }
public string? ServerName { get; set; }
public int? TickRate { get; set; }
public int TotalRounds { get; set; }
public string? TeamCtName { get; set; }
public string? TeamTName { get; set; }
public DateTime ParsedAtUtc { get; set; } = DateTime.UtcNow;
}
public sealed class PlayerRecord
{
public int Id { get; set; }
public int MatchId { get; set; }
public string SteamId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? TeamName { get; set; }
public string? Side { get; set; }
}
public sealed class RoundRecord
{
public int Id { get; set; }
public int MatchId { get; set; }
public int Number { get; set; }
public int? StartTick { get; set; }
public int? EndTick { get; set; }
public string? WinnerSide { get; set; }
public string? WinnerTeamName { get; set; }
public string? Reason { get; set; }
}
public sealed class EventRecord
{
public int Id { get; set; }
public int MatchId { get; set; }
public int? RoundId { get; set; }
public string EventType { get; set; } = "kill";
public int Tick { get; set; }
public double? Second { get; set; }
public int? ActorPlayerId { get; set; }
public int? TargetPlayerId { get; set; }
public string? Weapon { get; set; }
public bool IsHeadshot { get; set; }
public bool IsWallbang { get; set; }
public bool IsSmokeKill { get; set; }
public int? KillerAliveCount { get; set; }
public int? EnemyAliveCount { get; set; }
public string? KillerNameSnapshot { get; set; }
public string? VictimNameSnapshot { get; set; }
}
public sealed class HighlightRecord
{
public int Id { get; set; }
public int MatchId { get; set; }
public int? RoundId { get; set; }
public int SourceEventId { get; set; }
public double Score { get; set; }
public List<string> Tags { get; set; } = [];
public List<ScoreExplanationRecord> Explanation { get; set; } = [];
public string ScoringVersion { get; set; } = string.Empty;
public int StartTick { get; set; }
public int EndTick { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}
public sealed class ScoreExplanationRecord
{
public string Reason { get; set; } = string.Empty;
public double Value { get; set; }
public string Detail { get; set; } = string.Empty;
}
public sealed class ClipJobRecord
{
public int Id { get; set; }
public int HighlightId { get; set; }
public string Renderer { get; set; } = "future-renderer";
public string Status { get; set; } = "pending";
public int StartTick { get; set; }
public int EndTick { get; set; }
public Dictionary<string, string> Payload { get; set; } = [];
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public int? ClipAssetId { get; set; }
public string? ErrorMessage { get; set; }
public DateTime? StartedAtUtc { get; set; }
public DateTime? CompletedAtUtc { get; set; }
}
public sealed class ClipAssetRecord
{
public int Id { get; set; }
public int ClipJobId { get; set; }
public string FileName { get; set; } = string.Empty;
public string StoredPath { get; set; } = string.Empty;
public string ContentType { get; set; } = "video/mp4";
public long FileSizeBytes { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}
public sealed class ScoringProfileRecord
{
public int Id { get; set; }
public string Name { get; set; } = "default";
public string Version { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public HighlightScoringRules Rules { get; set; } = new();
}
public sealed class HighlightScoringRules
{
public string Name { get; set; } = "default";
public Dictionary<string, double> Weights { get; set; } = new();
public ClipWindowRules Clip { get; set; } = new();
public ScoreThresholdRules Thresholds { get; set; } = new();
}
public sealed class ClipWindowRules
{
public int PreTicks { get; set; } = 160;
public int PostTicks { get; set; } = 96;
}
public sealed class ScoreThresholdRules
{
public double HighlightScoreMin { get; set; } = 18;
}
+50
View File
@@ -0,0 +1,50 @@
namespace Fragalytics.Web.Models;
public sealed class ParsedDemo
{
public string? MapName { get; set; }
public string? ServerName { get; set; }
public int? TickRate { get; set; }
public string? TeamCtName { get; set; }
public string? TeamTName { get; set; }
public List<ParsedPlayer> Players { get; set; } = [];
public List<ParsedRound> Rounds { get; set; } = [];
public List<ParsedKillEvent> KillEvents { get; set; } = [];
}
public sealed class ParsedPlayer
{
public string SteamId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? TeamName { get; set; }
public string? Side { get; set; }
}
public sealed class ParsedRound
{
public int Number { get; set; }
public int? StartTick { get; set; }
public int? EndTick { get; set; }
public string? WinnerSide { get; set; }
public string? WinnerTeamName { get; set; }
public string? Reason { get; set; }
}
public sealed class ParsedKillEvent
{
public int Tick { get; set; }
public double? Second { get; set; }
public int RoundNumber { get; set; }
public string? KillerSteamId { get; set; }
public string? KillerName { get; set; }
public string? KillerTeam { get; set; }
public string? VictimSteamId { get; set; }
public string? VictimName { get; set; }
public string? VictimTeam { get; set; }
public string? Weapon { get; set; }
public bool IsHeadshot { get; set; }
public bool IsWallbang { get; set; }
public bool IsSmokeKill { get; set; }
public int? KillerAliveCount { get; set; }
public int? EnemyAliveCount { get; set; }
}
@@ -0,0 +1,51 @@
@page "/clip-jobs"
@model Fragalytics.Web.Pages.ClipJobs.IndexModel
@{
ViewData["Title"] = "Clip Jobs";
}
<section class="card">
<h1>Clip Jobs</h1>
<p class="muted">You can manually attach a rendered `.mp4` or `.webm` file to any clip job. Once attached, the clip is playable in the page and on related highlight views.</p>
<table>
<thead><tr><th>ID</th><th>Status</th><th>Renderer</th><th>Ticks</th><th>Playback</th><th>Upload</th><th>Payload</th></tr></thead>
<tbody>
@if (Model.ClipJobs.Count == 0)
{
<tr><td colspan="7" class="muted">No clip jobs created yet.</td></tr>
}
else
{
@foreach (var row in Model.ClipJobs)
{
<tr>
<td>#@row.ClipJob.Id</td>
<td><span class="pill [email protected]">@row.ClipJob.Status</span></td>
<td>@row.ClipJob.Renderer</td>
<td>@row.ClipJob.StartTick - @row.ClipJob.EndTick</td>
<td>
@if (row.ClipAsset is null)
{
<span class="muted">Not available</span>
}
else
{
<video class="clip-player" controls preload="metadata">
<source src="/clip-jobs?handler=Media&amp;[email protected]" type="@row.ClipAsset.ContentType" />
</video>
}
</td>
<td>
<form method="post" enctype="multipart/form-data" asp-page-handler="UploadClip">
<input type="hidden" name="clipJobId" value="@row.ClipJob.Id" />
<input type="file" name="clipUpload" accept=".mp4,.webm" required />
<button type="submit" class="secondary">Attach Clip</button>
</form>
</td>
<td><code>@string.Join(", ", row.ClipJob.Payload.Select(item => $"{item.Key}={item.Value}"))</code></td>
</tr>
}
}
</tbody>
</table>
</section>
@@ -0,0 +1,48 @@
using Fragalytics.Web.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Fragalytics.Web.Pages.ClipJobs;
public sealed class IndexModel : PageModel
{
private readonly QueryService _queryService;
private readonly ClipMediaService _clipMediaService;
public IndexModel(QueryService queryService, ClipMediaService clipMediaService)
{
_queryService = queryService;
_clipMediaService = clipMediaService;
}
public List<ClipJobWithHighlightViewModel> ClipJobs { get; private set; } = [];
public async Task OnGetAsync(CancellationToken cancellationToken)
{
ClipJobs = await _queryService.GetClipJobsAsync(cancellationToken);
}
public async Task<IActionResult> OnPostUploadClipAsync(int clipJobId, IFormFile? clipUpload, CancellationToken cancellationToken)
{
if (clipUpload is null || clipUpload.Length == 0)
{
return RedirectToPage();
}
await using var stream = clipUpload.OpenReadStream();
await _clipMediaService.AttachClipAssetAsync(clipJobId, clipUpload.FileName, clipUpload.ContentType, stream, cancellationToken);
return RedirectToPage();
}
public async Task<IActionResult> OnGetMediaAsync(int clipJobId, CancellationToken cancellationToken)
{
var asset = await _queryService.GetClipAssetAsync(clipJobId, cancellationToken);
if (asset is null)
{
return NotFound();
}
var stream = System.IO.File.OpenRead(asset.Asset.StoredPath);
return File(stream, asset.ContentType);
}
}
@@ -0,0 +1,88 @@
@page "/demos/{id:int}"
@model Fragalytics.Web.Pages.Demos.DetailsModel
@{
ViewData["Title"] = $"Demo #{Model.ViewModel?.Demo.Id}";
}
@if (Model.ViewModel is null)
{
<section class="card"><h1>Demo not found</h1></section>
}
else
{
<section class="card">
<div class="section-head">
<div>
<h1>Demo #@Model.ViewModel.Demo.Id</h1>
<p class="muted">@Model.ViewModel.Demo.OriginalFileName</p>
</div>
<span class="pill [email protected]">@Model.ViewModel.Demo.Status</span>
</div>
<dl class="meta-grid">
<div><dt>Parser</dt><dd>@Model.ViewModel.Demo.ParserBackend</dd></div>
<div><dt>Uploaded</dt><dd>@Model.ViewModel.Demo.UploadedAtUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC</dd></div>
<div><dt>Stored Path</dt><dd>@Model.ViewModel.Demo.StoredPath</dd></div>
@if (!string.IsNullOrWhiteSpace(Model.ViewModel.Demo.ErrorMessage))
{
<div><dt>Error</dt><dd>@Model.ViewModel.Demo.ErrorMessage</dd></div>
}
</dl>
</section>
@if (Model.ViewModel.Match is not null)
{
<section class="card">
<div class="section-head">
<div>
<h2>Parsed Match</h2>
<p class="muted">@Model.ViewModel.Match.MapName</p>
</div>
<a class="button-link" asp-page="/Matches/Details" asp-route-id="@Model.ViewModel.Match.Id">Open Match Details</a>
</div>
<dl class="meta-grid">
<div><dt>Rounds</dt><dd>@Model.ViewModel.Match.TotalRounds</dd></div>
<div><dt>Highlights</dt><dd>@Model.ViewModel.Highlights.Count</dd></div>
<div><dt>Tick Rate</dt><dd>@(Model.ViewModel.Match.TickRate?.ToString() ?? "Unknown")</dd></div>
<div><dt>Server</dt><dd>@(Model.ViewModel.Match.ServerName ?? "Unknown")</dd></div>
</dl>
</section>
<section class="card">
<h2>Top Highlights</h2>
<table>
<thead><tr><th>Score</th><th>Round</th><th>Moment</th><th>Tags</th><th>Clip</th><th>Playback</th></tr></thead>
<tbody>
@if (Model.ViewModel.Highlights.Count == 0)
{
<tr><td colspan="6" class="muted">No highlights scored above threshold.</td></tr>
}
else
{
@foreach (var row in Model.ViewModel.Highlights.Take(10))
{
<tr>
<td>@row.Highlight.Score.ToString("0.0")</td>
<td>@(row.Round?.Number.ToString() ?? "-")</td>
<td>@(row.Actor?.Name ?? row.Event.KillerNameSnapshot) eliminated @(row.Target?.Name ?? row.Event.VictimNameSnapshot)</td>
<td>@string.Join(", ", row.Highlight.Tags)</td>
<td>@row.Highlight.StartTick - @row.Highlight.EndTick</td>
<td>
@if (row.ClipAsset is null || row.ClipJob is null)
{
<span class="muted">None</span>
}
else
{
<video class="clip-player" controls preload="metadata">
<source src="/clip-jobs?handler=Media&amp;[email protected]" type="@row.ClipAsset.ContentType" />
</video>
}
</td>
</tr>
}
}
</tbody>
</table>
</section>
}
}
@@ -0,0 +1,23 @@
using Fragalytics.Web.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Fragalytics.Web.Pages.Demos;
public sealed class DetailsModel : PageModel
{
private readonly QueryService _queryService;
public DetailsModel(QueryService queryService)
{
_queryService = queryService;
}
public DemoDetailsViewModel? ViewModel { get; private set; }
public async Task<IActionResult> OnGetAsync(int id, CancellationToken cancellationToken)
{
ViewModel = await _queryService.GetDemoDetailsAsync(id, cancellationToken);
return ViewModel is null ? NotFound() : Page();
}
}
+85
View File
@@ -0,0 +1,85 @@
@page
@model Fragalytics.Web.Pages.IndexModel
@{
ViewData["Title"] = "Demos";
}
<section class="hero card">
<div>
<h1>Upload a CS2 demo</h1>
<p class="muted">Uploads are stored locally, parsed in the background, scored into explainable highlights, and converted into clip job records for a future render worker.</p>
</div>
<form class="upload-form" method="post" enctype="multipart/form-data" asp-page-handler="Upload">
<input type="file" asp-for="Upload" accept=".dem" required />
<button type="submit">Upload Demo</button>
</form>
</section>
<section class="stats-grid">
<article class="card"><h2>@Model.Dashboard.DemoCount</h2><p>Demos</p></article>
<article class="card"><h2>@Model.Dashboard.MatchCount</h2><p>Matches</p></article>
<article class="card"><h2>@Model.Dashboard.HighlightCount</h2><p>Highlights</p></article>
<article class="card"><h2>@Model.Dashboard.ClipJobCount</h2><p>Clip Jobs</p></article>
</section>
<section class="split">
<div class="card">
<div class="section-head">
<h2>Recent Demos</h2>
<form method="post" asp-page-handler="RunWorker">
<button type="submit" class="secondary">Run Worker Once</button>
</form>
</div>
<table>
<thead><tr><th>ID</th><th>File</th><th>Status</th><th>Uploaded</th></tr></thead>
<tbody>
@if (Model.Dashboard.Demos.Count == 0)
{
<tr><td colspan="4" class="muted">No demos uploaded yet.</td></tr>
}
else
{
@foreach (var demo in Model.Dashboard.Demos)
{
<tr>
<td><a asp-page="/Demos/Details" asp-route-id="@demo.Id">#@demo.Id</a></td>
<td>@demo.OriginalFileName</td>
<td><span class="pill [email protected]">@demo.Status</span></td>
<td>@demo.UploadedAtUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC</td>
</tr>
}
}
</tbody>
</table>
</div>
<div class="card">
<div class="section-head">
<h2>Recent Clip Jobs</h2>
<form method="post" asp-page-handler="Rescore">
<button type="submit" class="secondary">Rescore All Matches</button>
</form>
</div>
<table>
<thead><tr><th>ID</th><th>Status</th><th>Ticks</th><th>Renderer</th></tr></thead>
<tbody>
@if (Model.Dashboard.ClipJobs.Count == 0)
{
<tr><td colspan="4" class="muted">No clip jobs yet.</td></tr>
}
else
{
@foreach (var job in Model.Dashboard.ClipJobs)
{
<tr>
<td><a asp-page="/ClipJobs/Index">#@job.Id</a></td>
<td><span class="pill [email protected]">@job.Status</span></td>
<td>@job.StartTick - @job.EndTick</td>
<td>@job.Renderer</td>
</tr>
}
}
</tbody>
</table>
</div>
</section>
+51
View File
@@ -0,0 +1,51 @@
using Fragalytics.Web.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Fragalytics.Web.Pages;
public sealed class IndexModel : PageModel
{
private readonly QueryService _queryService;
private readonly DemoIngestService _demoIngestService;
public IndexModel(QueryService queryService, DemoIngestService demoIngestService)
{
_queryService = queryService;
_demoIngestService = demoIngestService;
}
[BindProperty]
public IFormFile? Upload { get; set; }
public DashboardViewModel Dashboard { get; private set; } = new();
public async Task OnGetAsync(CancellationToken cancellationToken)
{
Dashboard = await _queryService.GetDashboardAsync(cancellationToken);
}
public async Task<IActionResult> OnPostUploadAsync(CancellationToken cancellationToken)
{
if (Upload is null || string.IsNullOrWhiteSpace(Upload.FileName) || !Upload.FileName.EndsWith(".dem", StringComparison.OrdinalIgnoreCase))
{
return RedirectToPage();
}
await using var stream = Upload.OpenReadStream();
var id = await _demoIngestService.SaveUploadAsync(Upload.FileName, stream, cancellationToken);
return RedirectToPage("/Demos/Details", new { id });
}
public async Task<IActionResult> OnPostRunWorkerAsync(CancellationToken cancellationToken)
{
await _demoIngestService.ProcessNextAsync(cancellationToken);
return RedirectToPage();
}
public async Task<IActionResult> OnPostRescoreAsync(CancellationToken cancellationToken)
{
await _demoIngestService.RescoreAllAsync(cancellationToken);
return RedirectToPage();
}
}
@@ -0,0 +1,118 @@
@page "/matches/{id:int}"
@model Fragalytics.Web.Pages.Matches.DetailsModel
@{
ViewData["Title"] = $"Match #{Model.ViewModel?.Match.Id}";
}
@if (Model.ViewModel is null)
{
<section class="card"><h1>Match not found</h1></section>
}
else
{
<section class="card">
<div class="section-head">
<div>
<h1>Match #@Model.ViewModel.Match.Id</h1>
<p class="muted">@Model.ViewModel.Match.MapName on @(Model.ViewModel.Match.ServerName ?? "Unknown server")</p>
</div>
<a class="button-link" asp-page="/Demos/Details" asp-route-id="@Model.ViewModel.Match.DemoId">Back to Demo</a>
</div>
<dl class="meta-grid">
<div><dt>Rounds</dt><dd>@Model.ViewModel.Match.TotalRounds</dd></div>
<div><dt>Players</dt><dd>@Model.ViewModel.Players.Count</dd></div>
<div><dt>Highlights</dt><dd>@Model.ViewModel.Highlights.Count</dd></div>
<div><dt>Tick Rate</dt><dd>@(Model.ViewModel.Match.TickRate?.ToString() ?? "Unknown")</dd></div>
</dl>
</section>
<section class="split">
<div class="card">
<h2>Players</h2>
<table>
<thead><tr><th>Name</th><th>Steam ID</th><th>Team</th><th>Side</th></tr></thead>
<tbody>
@foreach (var player in Model.ViewModel.Players)
{
<tr>
<td>@player.Name</td>
<td>@player.SteamId</td>
<td>@(player.TeamName ?? "-")</td>
<td>@(player.Side ?? "-")</td>
</tr>
}
</tbody>
</table>
</div>
<div class="card">
<h2>Rounds</h2>
<table>
<thead><tr><th>#</th><th>Winner</th><th>Reason</th><th>Ticks</th></tr></thead>
<tbody>
@foreach (var round in Model.ViewModel.Rounds)
{
<tr>
<td>@round.Number</td>
<td>@(round.WinnerSide ?? "-")</td>
<td>@(round.Reason ?? "-")</td>
<td>@(round.StartTick ?? 0) - @(round.EndTick ?? 0)</td>
</tr>
}
</tbody>
</table>
</div>
</section>
<section class="card">
<h2>Highlights</h2>
<table>
<thead><tr><th>Score</th><th>Round</th><th>Moment</th><th>Tags</th><th>Why</th><th>Clip Job</th><th>Playback</th></tr></thead>
<tbody>
@if (Model.ViewModel.Highlights.Count == 0)
{
<tr><td colspan="7" class="muted">No highlights available.</td></tr>
}
else
{
@foreach (var row in Model.ViewModel.Highlights)
{
<tr>
<td>@row.Highlight.Score.ToString("0.0")</td>
<td>@(row.Round?.Number.ToString() ?? "-")</td>
<td>@(row.Actor?.Name ?? row.Event.KillerNameSnapshot) eliminated @(row.Target?.Name ?? row.Event.VictimNameSnapshot) at tick @row.Event.Tick</td>
<td>@string.Join(", ", row.Highlight.Tags)</td>
<td>
@foreach (var item in row.Highlight.Explanation)
{
<div>@item.Reason ([email protected])</div>
}
</td>
<td>
@if (row.ClipJob is null)
{
<text>-</text>
}
else
{
<text>#@row.ClipJob.Id @row.ClipJob.Status<br />@row.ClipJob.StartTick - @row.ClipJob.EndTick</text>
}
</td>
<td>
@if (row.ClipAsset is null || row.ClipJob is null)
{
<span class="muted">None</span>
}
else
{
<video class="clip-player" controls preload="metadata">
<source src="/clip-jobs?handler=Media&amp;[email protected]" type="@row.ClipAsset.ContentType" />
</video>
}
</td>
</tr>
}
}
</tbody>
</table>
</section>
}
@@ -0,0 +1,23 @@
using Fragalytics.Web.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Fragalytics.Web.Pages.Matches;
public sealed class DetailsModel : PageModel
{
private readonly QueryService _queryService;
public DetailsModel(QueryService queryService)
{
_queryService = queryService;
}
public MatchDetailsViewModel? ViewModel { get; private set; }
public async Task<IActionResult> OnGetAsync(int id, CancellationToken cancellationToken)
{
ViewModel = await _queryService.GetMatchDetailsAsync(id, cancellationToken);
return ViewModel is null ? NotFound() : Page();
}
}
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - Fragalytics</title>
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
</head>
<body>
<header class="site-header">
<div>
<a class="brand" asp-page="/Index">Fragalytics</a>
<p class="muted">ASP.NET Core MVP for self-hosted CS2 demo analysis</p>
</div>
<nav class="nav">
<a asp-page="/Index">Demos</a>
<a asp-page="/ClipJobs/Index">Clip Jobs</a>
</nav>
</header>
<main class="page">
@RenderBody()
</main>
</body>
</html>
@@ -0,0 +1,2 @@
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
@@ -0,0 +1,3 @@
@using Fragalytics.Web
@namespace Fragalytics.Web.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}
+44
View File
@@ -0,0 +1,44 @@
using Fragalytics.Web;
using Fragalytics.Web.Infrastructure;
using Fragalytics.Web.Services;
using Fragalytics.Web.Services.Parser;
using Fragalytics.Web.Services.Render;
using Fragalytics.Web.Services.Scoring;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<FragalyticsOptions>(builder.Configuration.GetSection("Fragalytics"));
builder.Services.AddRazorPages();
builder.Services.AddSingleton<JsonAppStore>();
builder.Services.AddSingleton<ScoringRulesProvider>();
builder.Services.AddSingleton<IRenderWorkerAdapter, NoOpRenderWorkerAdapter>();
builder.Services.AddSingleton<IDemoParserAdapter>(serviceProvider =>
{
var options = serviceProvider.GetRequiredService<IOptions<FragalyticsOptions>>().Value;
return string.Equals(options.Parser.Mode, "external", StringComparison.OrdinalIgnoreCase)
? new ExternalProcessParserAdapter(serviceProvider.GetRequiredService<IOptions<FragalyticsOptions>>())
: new SampleDemoParserAdapter();
});
builder.Services.AddSingleton<MatchScoringService>();
builder.Services.AddSingleton<DemoIngestService>();
builder.Services.AddSingleton<ClipMediaService>();
builder.Services.AddSingleton<QueryService>();
builder.Services.AddHostedService<DemoProcessingWorker>();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseStaticFiles();
app.UseRouting();
app.MapRazorPages();
app.Run();
public partial class Program
{
}
@@ -0,0 +1,38 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:0",
"sslPort": 0
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:0",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:0;http://localhost:0",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,91 @@
using Fragalytics.Web.Infrastructure;
using Fragalytics.Web.Models;
using Microsoft.Extensions.Options;
namespace Fragalytics.Web.Services;
public sealed class ClipMediaService
{
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp4",
".webm"
};
private readonly JsonAppStore _store;
private readonly FragalyticsOptions _options;
public ClipMediaService(JsonAppStore store, IOptions<FragalyticsOptions> options)
{
_store = store;
_options = options.Value;
}
public async Task<int> AttachClipAssetAsync(
int clipJobId,
string fileName,
string? contentType,
Stream input,
CancellationToken cancellationToken)
{
var extension = Path.GetExtension(fileName);
if (!AllowedExtensions.Contains(extension))
{
throw new InvalidOperationException("Only .mp4 and .webm clip uploads are supported.");
}
var clipsDirectory = Path.Combine(Path.GetFullPath(_options.DataDirectory), "clips");
Directory.CreateDirectory(clipsDirectory);
var safeFileName = Path.GetFileName(fileName);
var storedName = $"{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}_{clipJobId}{extension}";
var storedPath = Path.Combine(clipsDirectory, storedName);
long fileSize;
await using (var output = File.Create(storedPath))
{
await input.CopyToAsync(output, cancellationToken);
fileSize = output.Length;
}
return await _store.UpdateAsync(state =>
{
var clipJob = state.ClipJobs.FirstOrDefault(item => item.Id == clipJobId)
?? throw new InvalidOperationException("Clip job not found.");
if (clipJob.ClipAssetId is int existingAssetId)
{
var existingAsset = state.ClipAssets.FirstOrDefault(item => item.Id == existingAssetId);
if (existingAsset is not null && File.Exists(existingAsset.StoredPath))
{
File.Delete(existingAsset.StoredPath);
}
state.ClipAssets.RemoveAll(item => item.Id == existingAssetId);
}
var asset = new ClipAssetRecord
{
Id = state.NextClipAssetId++,
ClipJobId = clipJobId,
FileName = safeFileName,
StoredPath = storedPath,
ContentType = string.IsNullOrWhiteSpace(contentType) ? InferContentType(extension) : contentType,
FileSizeBytes = fileSize
};
state.ClipAssets.Add(asset);
clipJob.ClipAssetId = asset.Id;
clipJob.Status = "rendered";
clipJob.ErrorMessage = null;
clipJob.CompletedAtUtc = DateTime.UtcNow;
clipJob.Payload["playback"] = "available";
return asset.Id;
}, cancellationToken);
}
private static string InferContentType(string extension) => extension.ToLowerInvariant() switch
{
".webm" => "video/webm",
_ => "video/mp4"
};
}
@@ -0,0 +1,229 @@
using Fragalytics.Web.Infrastructure;
using Fragalytics.Web.Models;
using Fragalytics.Web.Services.Parser;
using Fragalytics.Web.Services.Scoring;
using Microsoft.Extensions.Options;
namespace Fragalytics.Web.Services;
public sealed class DemoIngestService
{
private readonly JsonAppStore _store;
private readonly FragalyticsOptions _options;
private readonly IDemoParserAdapter _parserAdapter;
private readonly MatchScoringService _matchScoringService;
public DemoIngestService(
JsonAppStore store,
IOptions<FragalyticsOptions> options,
IDemoParserAdapter parserAdapter,
MatchScoringService matchScoringService)
{
_store = store;
_options = options.Value;
_parserAdapter = parserAdapter;
_matchScoringService = matchScoringService;
}
public async Task<int> SaveUploadAsync(string fileName, Stream input, CancellationToken cancellationToken)
{
var safeName = Path.GetFileName(fileName);
var uploadDirectory = Path.GetFullPath(_options.UploadDirectory);
Directory.CreateDirectory(uploadDirectory);
var storedName = $"{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}_{safeName}";
var storedPath = Path.Combine(uploadDirectory, storedName);
await using (var output = File.Create(storedPath))
{
await input.CopyToAsync(output, cancellationToken);
}
return await _store.UpdateAsync(state =>
{
var demo = new DemoRecord
{
Id = state.NextDemoId++,
OriginalFileName = safeName,
StoredPath = storedPath,
ParserBackend = _parserAdapter.Name,
Status = "uploaded",
UploadedAtUtc = DateTime.UtcNow
};
state.Demos.Add(demo);
return demo.Id;
}, cancellationToken);
}
public async Task<int> ProcessNextAsync(CancellationToken cancellationToken)
{
var nextDemo = await _store.UpdateAsync(state =>
{
var demo = state.Demos
.Where(candidate => candidate.Status is "uploaded" or "queued")
.OrderBy(candidate => candidate.UploadedAtUtc)
.FirstOrDefault();
if (demo is null)
{
return null;
}
demo.Status = "processing";
demo.ErrorMessage = null;
return new DemoRecord
{
Id = demo.Id,
OriginalFileName = demo.OriginalFileName,
StoredPath = demo.StoredPath,
ParserBackend = demo.ParserBackend
};
}, cancellationToken);
if (nextDemo is null)
{
return 0;
}
try
{
var parsed = await _parserAdapter.ParseAsync(nextDemo.StoredPath, cancellationToken);
await _store.UpdateAsync(state =>
{
var demo = state.Demos.First(item => item.Id == nextDemo.Id);
DeleteExistingMatchGraph(state, demo.MatchId);
var match = new MatchRecord
{
Id = state.NextMatchId++,
DemoId = demo.Id,
MapName = parsed.MapName,
ServerName = parsed.ServerName,
TickRate = parsed.TickRate,
TotalRounds = parsed.Rounds.Count,
TeamCtName = parsed.TeamCtName,
TeamTName = parsed.TeamTName
};
state.Matches.Add(match);
demo.MatchId = match.Id;
var playersBySteam = new Dictionary<string, PlayerRecord>(StringComparer.Ordinal);
foreach (var parsedPlayer in parsed.Players)
{
var player = new PlayerRecord
{
Id = state.NextPlayerId++,
MatchId = match.Id,
SteamId = parsedPlayer.SteamId,
Name = parsedPlayer.Name,
TeamName = parsedPlayer.TeamName,
Side = parsedPlayer.Side
};
state.Players.Add(player);
playersBySteam[player.SteamId] = player;
}
var roundsByNumber = new Dictionary<int, RoundRecord>();
foreach (var parsedRound in parsed.Rounds)
{
var round = new RoundRecord
{
Id = state.NextRoundId++,
MatchId = match.Id,
Number = parsedRound.Number,
StartTick = parsedRound.StartTick,
EndTick = parsedRound.EndTick,
WinnerSide = parsedRound.WinnerSide,
WinnerTeamName = parsedRound.WinnerTeamName,
Reason = parsedRound.Reason
};
state.Rounds.Add(round);
roundsByNumber[round.Number] = round;
}
foreach (var parsedKill in parsed.KillEvents)
{
playersBySteam.TryGetValue(parsedKill.KillerSteamId ?? string.Empty, out var killer);
playersBySteam.TryGetValue(parsedKill.VictimSteamId ?? string.Empty, out var victim);
roundsByNumber.TryGetValue(parsedKill.RoundNumber, out var round);
state.Events.Add(new EventRecord
{
Id = state.NextEventId++,
MatchId = match.Id,
RoundId = round?.Id,
EventType = "kill",
Tick = parsedKill.Tick,
Second = parsedKill.Second,
ActorPlayerId = killer?.Id,
TargetPlayerId = victim?.Id,
Weapon = parsedKill.Weapon,
IsHeadshot = parsedKill.IsHeadshot,
IsWallbang = parsedKill.IsWallbang,
IsSmokeKill = parsedKill.IsSmokeKill,
KillerAliveCount = parsedKill.KillerAliveCount,
EnemyAliveCount = parsedKill.EnemyAliveCount,
KillerNameSnapshot = parsedKill.KillerName,
VictimNameSnapshot = parsedKill.VictimName
});
}
_matchScoringService.RescoreMatch(state, match);
demo.Status = "parsed";
demo.ErrorMessage = null;
demo.ProcessedAtUtc = DateTime.UtcNow;
return 1;
}, cancellationToken);
return 1;
}
catch (Exception exception)
{
await _store.UpdateAsync(state =>
{
var demo = state.Demos.First(item => item.Id == nextDemo.Id);
demo.Status = "failed";
demo.ErrorMessage = exception.Message;
demo.ProcessedAtUtc = DateTime.UtcNow;
return 0;
}, cancellationToken);
return 0;
}
}
public async Task<int> RescoreAllAsync(CancellationToken cancellationToken)
{
return await _store.UpdateAsync(state =>
{
var matches = state.Matches.ToList();
var rescored = 0;
foreach (var match in matches)
{
rescored += _matchScoringService.RescoreMatch(state, match);
}
return rescored;
}, cancellationToken);
}
private static void DeleteExistingMatchGraph(AppState state, int? matchId)
{
if (matchId is null)
{
return;
}
var highlightIds = state.Highlights.Where(item => item.MatchId == matchId).Select(item => item.Id).ToHashSet();
var clipAssetIds = state.ClipJobs.Where(item => highlightIds.Contains(item.HighlightId) && item.ClipAssetId.HasValue)
.Select(item => item.ClipAssetId!.Value)
.ToHashSet();
state.ClipJobs.RemoveAll(item => highlightIds.Contains(item.HighlightId));
state.ClipAssets.RemoveAll(item => clipAssetIds.Contains(item.Id));
state.Highlights.RemoveAll(item => item.MatchId == matchId);
state.Events.RemoveAll(item => item.MatchId == matchId);
state.Rounds.RemoveAll(item => item.MatchId == matchId);
state.Players.RemoveAll(item => item.MatchId == matchId);
state.Matches.RemoveAll(item => item.Id == matchId);
}
}
@@ -0,0 +1,31 @@
using Microsoft.Extensions.Options;
namespace Fragalytics.Web.Services;
public sealed class DemoProcessingWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOptions<FragalyticsOptions> _options;
public DemoProcessingWorker(IServiceScopeFactory scopeFactory, IOptions<FragalyticsOptions> options)
{
_scopeFactory = scopeFactory;
_options = options;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_options.Value.AutoStartWorker)
{
return;
}
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var ingestService = scope.ServiceProvider.GetRequiredService<DemoIngestService>();
await ingestService.ProcessNextAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(_options.Value.WorkerPollSeconds), stoppingToken);
}
}
}
@@ -0,0 +1,54 @@
using System.Diagnostics;
using System.Text.Json;
using Fragalytics.Web.Models;
using Microsoft.Extensions.Options;
namespace Fragalytics.Web.Services.Parser;
public sealed class ExternalProcessParserAdapter : IDemoParserAdapter
{
private readonly ParserOptions _options;
public ExternalProcessParserAdapter(IOptions<FragalyticsOptions> options)
{
_options = options.Value.Parser;
}
public string Name => "external-process";
public async Task<ParsedDemo> ParseAsync(string demoPath, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_options.ExternalCommand))
{
throw new InvalidOperationException("Parser:ExternalCommand is not configured.");
}
var argumentsTemplate = _options.ExternalArguments ?? "\"{demoPath}\"";
var arguments = argumentsTemplate.Replace("{demoPath}", demoPath, StringComparison.Ordinal);
var startInfo = new ProcessStartInfo
{
FileName = _options.ExternalCommand,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
using var process = Process.Start(startInfo)
?? throw new InvalidOperationException("Failed to start external parser process.");
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
var output = await outputTask;
var error = await errorTask;
if (process.ExitCode != 0)
{
throw new InvalidOperationException($"External parser failed: {error}");
}
return JsonSerializer.Deserialize<ParsedDemo>(output, new JsonSerializerOptions(JsonSerializerDefaults.Web))
?? throw new InvalidOperationException("External parser returned invalid JSON.");
}
}
@@ -0,0 +1,9 @@
using Fragalytics.Web.Models;
namespace Fragalytics.Web.Services.Parser;
public interface IDemoParserAdapter
{
string Name { get; }
Task<ParsedDemo> ParseAsync(string demoPath, CancellationToken cancellationToken);
}
@@ -0,0 +1,45 @@
using System.Security.Cryptography;
using System.Text;
using Fragalytics.Web.Models;
namespace Fragalytics.Web.Services.Parser;
public sealed class SampleDemoParserAdapter : IDemoParserAdapter
{
public string Name => "sample";
public async Task<ParsedDemo> ParseAsync(string demoPath, CancellationToken cancellationToken)
{
var bytes = await File.ReadAllBytesAsync(demoPath, cancellationToken);
var digest = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant()[..8];
return new ParsedDemo
{
MapName = $"sample_{digest}",
ServerName = "Local Sample Parser",
TickRate = 64,
TeamCtName = "Counter-Terrorists",
TeamTName = "Terrorists",
Players =
[
new() { SteamId = "1001", Name = "Alpha", TeamName = "Counter-Terrorists", Side = "CT" },
new() { SteamId = "1002", Name = "Bravo", TeamName = "Counter-Terrorists", Side = "CT" },
new() { SteamId = "1003", Name = "Charlie", TeamName = "Terrorists", Side = "T" },
new() { SteamId = "1004", Name = "Delta", TeamName = "Terrorists", Side = "T" },
new() { SteamId = "1005", Name = "Echo", TeamName = "Terrorists", Side = "T" }
],
Rounds =
[
new() { Number = 1, StartTick = 0, EndTick = 850, WinnerSide = "CT", WinnerTeamName = "Counter-Terrorists", Reason = "elimination" },
new() { Number = 2, StartTick = 851, EndTick = 1720, WinnerSide = "T", WinnerTeamName = "Terrorists", Reason = "bomb" }
],
KillEvents =
[
new() { Tick = 120, Second = 8.6, RoundNumber = 1, KillerSteamId = "1001", KillerName = "Alpha", KillerTeam = "CT", VictimSteamId = "1003", VictimName = "Charlie", VictimTeam = "T", Weapon = "m4a1", IsHeadshot = true, KillerAliveCount = 2, EnemyAliveCount = 3 },
new() { Tick = 360, Second = 28.2, RoundNumber = 1, KillerSteamId = "1001", KillerName = "Alpha", KillerTeam = "CT", VictimSteamId = "1004", VictimName = "Delta", VictimTeam = "T", Weapon = "m4a1", IsWallbang = true, KillerAliveCount = 1, EnemyAliveCount = 2 },
new() { Tick = 430, Second = 33.7, RoundNumber = 1, KillerSteamId = "1001", KillerName = "Alpha", KillerTeam = "CT", VictimSteamId = "1005", VictimName = "Echo", VictimTeam = "T", Weapon = "m4a1", KillerAliveCount = 1, EnemyAliveCount = 1 },
new() { Tick = 980, Second = 7.1, RoundNumber = 2, KillerSteamId = "1004", KillerName = "Delta", KillerTeam = "T", VictimSteamId = "1002", VictimName = "Bravo", VictimTeam = "CT", Weapon = "ak47", IsSmokeKill = true, KillerAliveCount = 3, EnemyAliveCount = 2 }
]
};
}
}
@@ -0,0 +1,215 @@
using Fragalytics.Web.Infrastructure;
using Fragalytics.Web.Models;
using Microsoft.AspNetCore.StaticFiles;
namespace Fragalytics.Web.Services;
public sealed class QueryService
{
private readonly JsonAppStore _store;
public QueryService(JsonAppStore store)
{
_store = store;
}
public Task<AppState> GetSnapshotAsync(CancellationToken cancellationToken = default) => _store.GetSnapshotAsync(cancellationToken);
public async Task<DashboardViewModel> GetDashboardAsync(CancellationToken cancellationToken = default)
{
var state = await _store.GetSnapshotAsync(cancellationToken);
return new DashboardViewModel
{
DemoCount = state.Demos.Count,
MatchCount = state.Matches.Count,
HighlightCount = state.Highlights.Count,
ClipJobCount = state.ClipJobs.Count,
Demos = state.Demos.OrderByDescending(item => item.UploadedAtUtc).Take(20).ToList(),
ClipJobs = state.ClipJobs.OrderByDescending(item => item.CreatedAtUtc).Take(12).ToList()
};
}
public async Task<DemoDetailsViewModel?> GetDemoDetailsAsync(int id, CancellationToken cancellationToken = default)
{
var state = await _store.GetSnapshotAsync(cancellationToken);
var demo = state.Demos.FirstOrDefault(item => item.Id == id);
if (demo is null)
{
return null;
}
MatchRecord? match = null;
List<HighlightDetailsRow> highlights = [];
if (demo.MatchId is int matchId)
{
match = state.Matches.FirstOrDefault(item => item.Id == matchId);
highlights = BuildHighlightRows(state, matchId).OrderByDescending(item => item.Highlight.Score).ToList();
}
return new DemoDetailsViewModel
{
Demo = demo,
Match = match,
Highlights = highlights
};
}
public async Task<MatchDetailsViewModel?> GetMatchDetailsAsync(int matchId, CancellationToken cancellationToken = default)
{
var state = await _store.GetSnapshotAsync(cancellationToken);
var match = state.Matches.FirstOrDefault(item => item.Id == matchId);
if (match is null)
{
return null;
}
return new MatchDetailsViewModel
{
Match = match,
Players = state.Players.Where(item => item.MatchId == matchId).OrderBy(item => item.Name).ToList(),
Rounds = state.Rounds.Where(item => item.MatchId == matchId).OrderBy(item => item.Number).ToList(),
Highlights = BuildHighlightRows(state, matchId).OrderByDescending(item => item.Highlight.Score).ToList()
};
}
public async Task<List<ClipJobWithHighlightViewModel>> GetClipJobsAsync(CancellationToken cancellationToken = default)
{
var state = await _store.GetSnapshotAsync(cancellationToken);
return state.ClipJobs
.OrderByDescending(item => item.CreatedAtUtc)
.Select(job => new ClipJobWithHighlightViewModel
{
ClipJob = job,
Highlight = state.Highlights.FirstOrDefault(highlight => highlight.Id == job.HighlightId),
ClipAsset = state.ClipAssets.FirstOrDefault(asset => asset.Id == job.ClipAssetId)
})
.ToList();
}
public async Task<ClipAssetDownloadViewModel?> GetClipAssetAsync(int clipJobId, CancellationToken cancellationToken = default)
{
var state = await _store.GetSnapshotAsync(cancellationToken);
var clipJob = state.ClipJobs.FirstOrDefault(item => item.Id == clipJobId);
if (clipJob?.ClipAssetId is not int assetId)
{
return null;
}
var asset = state.ClipAssets.FirstOrDefault(item => item.Id == assetId);
if (asset is null || !File.Exists(asset.StoredPath))
{
return null;
}
var provider = new FileExtensionContentTypeProvider();
var contentType = asset.ContentType;
if (string.IsNullOrWhiteSpace(contentType) && !provider.TryGetContentType(asset.FileName, out contentType))
{
contentType = "application/octet-stream";
}
return new ClipAssetDownloadViewModel
{
Asset = asset,
ContentType = contentType
};
}
private static IEnumerable<HighlightDetailsRow> BuildHighlightRows(AppState state, int matchId)
{
var playersById = state.Players.Where(player => player.MatchId == matchId).ToDictionary(player => player.Id);
var roundsById = state.Rounds.Where(round => round.MatchId == matchId).ToDictionary(round => round.Id);
var eventsById = state.Events.Where(@event => @event.MatchId == matchId).ToDictionary(@event => @event.Id);
var clipJobsByHighlightId = state.ClipJobs.ToDictionary(job => job.HighlightId);
var clipAssetsById = state.ClipAssets.ToDictionary(asset => asset.Id);
foreach (var highlight in state.Highlights.Where(item => item.MatchId == matchId))
{
eventsById.TryGetValue(highlight.SourceEventId, out var sourceEvent);
if (sourceEvent is null)
{
continue;
}
PlayerRecord? actor = null;
PlayerRecord? target = null;
RoundRecord? round = null;
if (sourceEvent.ActorPlayerId is int actorId)
{
playersById.TryGetValue(actorId, out actor);
}
if (sourceEvent.TargetPlayerId is int targetId)
{
playersById.TryGetValue(targetId, out target);
}
if (sourceEvent.RoundId is int roundId)
{
roundsById.TryGetValue(roundId, out round);
}
yield return new HighlightDetailsRow
{
Highlight = highlight,
Event = sourceEvent,
Actor = actor,
Target = target,
Round = round,
ClipJob = clipJobsByHighlightId.GetValueOrDefault(highlight.Id),
ClipAsset = clipJobsByHighlightId.TryGetValue(highlight.Id, out var clipJob) && clipJob.ClipAssetId is int assetId
? clipAssetsById.GetValueOrDefault(assetId)
: null
};
}
}
}
public sealed class DashboardViewModel
{
public int DemoCount { get; set; }
public int MatchCount { get; set; }
public int HighlightCount { get; set; }
public int ClipJobCount { get; set; }
public List<DemoRecord> Demos { get; set; } = [];
public List<ClipJobRecord> ClipJobs { get; set; } = [];
}
public sealed class DemoDetailsViewModel
{
public DemoRecord Demo { get; set; } = new();
public MatchRecord? Match { get; set; }
public List<HighlightDetailsRow> Highlights { get; set; } = [];
}
public sealed class MatchDetailsViewModel
{
public MatchRecord Match { get; set; } = new();
public List<PlayerRecord> Players { get; set; } = [];
public List<RoundRecord> Rounds { get; set; } = [];
public List<HighlightDetailsRow> Highlights { get; set; } = [];
}
public sealed class HighlightDetailsRow
{
public HighlightRecord Highlight { get; set; } = new();
public EventRecord Event { get; set; } = new();
public PlayerRecord? Actor { get; set; }
public PlayerRecord? Target { get; set; }
public RoundRecord? Round { get; set; }
public ClipJobRecord? ClipJob { get; set; }
public ClipAssetRecord? ClipAsset { get; set; }
}
public sealed class ClipJobWithHighlightViewModel
{
public ClipJobRecord ClipJob { get; set; } = new();
public HighlightRecord? Highlight { get; set; }
public ClipAssetRecord? ClipAsset { get; set; }
}
public sealed class ClipAssetDownloadViewModel
{
public ClipAssetRecord Asset { get; set; } = new();
public string ContentType { get; set; } = "application/octet-stream";
}
@@ -0,0 +1,9 @@
using Fragalytics.Web.Models;
namespace Fragalytics.Web.Services.Render;
public interface IRenderWorkerAdapter
{
string Name { get; }
void Enqueue(ClipJobRecord clipJob);
}
@@ -0,0 +1,13 @@
using Fragalytics.Web.Models;
namespace Fragalytics.Web.Services.Render;
public sealed class NoOpRenderWorkerAdapter : IRenderWorkerAdapter
{
public string Name => "future-renderer";
public void Enqueue(ClipJobRecord clipJob)
{
clipJob.Payload["note"] = "MVP placeholder. Rendering is not implemented yet.";
}
}
@@ -0,0 +1,129 @@
using Fragalytics.Web.Models;
namespace Fragalytics.Web.Services.Scoring;
public sealed class HighlightScorer
{
private readonly HighlightScoringRules _rules;
public HighlightScorer(HighlightScoringRules rules)
{
_rules = rules;
}
public HighlightScoreResult ScoreKill(
EventRecord sourceEvent,
RoundRecord? round,
IReadOnlyList<EventRecord> roundKills,
IReadOnlyDictionary<int, PlayerRecord> playersById,
int killIndex)
{
var score = 0d;
var tags = new List<string>();
var explanation = new List<ScoreExplanationRecord>();
void Add(string key, double value, string? tag = null, string? detail = null)
{
score += value;
if (!string.IsNullOrWhiteSpace(tag) && !tags.Contains(tag, StringComparer.OrdinalIgnoreCase))
{
tags.Add(tag);
}
explanation.Add(new ScoreExplanationRecord
{
Reason = key,
Value = value,
Detail = detail ?? key.Replace('_', ' ')
});
}
Add("base_kill", Weight("base_kill"));
if (sourceEvent.IsHeadshot)
{
Add("headshot", Weight("headshot"), "headshot");
}
if (sourceEvent.IsWallbang)
{
Add("wallbang", Weight("wallbang"), "wallbang");
}
if (sourceEvent.IsSmokeKill)
{
Add("smoke_kill", Weight("smoke_kill"), "smoke_kill");
}
if (killIndex == 0)
{
Add("opening_kill", Weight("opening_kill"), "opening_kill");
}
var killerKillsThisRound = roundKills
.Take(killIndex + 1)
.Count(candidate => candidate.ActorPlayerId == sourceEvent.ActorPlayerId);
if (killerKillsThisRound >= 2)
{
var multiKey = $"multikill_{Math.Min(5, killerKillsThisRound)}";
if (_rules.Weights.ContainsKey(multiKey))
{
Add(multiKey, Weight(multiKey), "multikill", $"{killerKillsThisRound} kills in the round");
}
}
if (sourceEvent.KillerAliveCount == 1 && sourceEvent.EnemyAliveCount is >= 2)
{
var clutchKey = $"clutch_1v{Math.Min(5, sourceEvent.EnemyAliveCount.Value)}";
if (_rules.Weights.ContainsKey(clutchKey))
{
Add(clutchKey, Weight(clutchKey), "clutch", $"Kill happened in a 1v{sourceEvent.EnemyAliveCount}");
if (!tags.Contains("1vX", StringComparer.OrdinalIgnoreCase))
{
tags.Add("1vX");
}
}
}
if (sourceEvent.EnemyAliveCount == 1)
{
Add("impact_last_enemy", Weight("impact_last_enemy"), "last_enemy", "Kill removed the last visible opponent");
}
if (sourceEvent.Second is >= 35)
{
Add("impact_low_time", Weight("impact_low_time"), detail: "Late-round frag");
}
if (sourceEvent.ActorPlayerId is int actorId &&
playersById.TryGetValue(actorId, out var actor) &&
!string.IsNullOrWhiteSpace(actor.Side) &&
string.Equals(actor.Side, round?.WinnerSide, StringComparison.OrdinalIgnoreCase))
{
Add("round_win", Weight("round_win"), "round_win");
}
return new HighlightScoreResult
{
Score = score,
Tags = tags,
Explanation = explanation,
StartTick = Math.Max(0, sourceEvent.Tick - _rules.Clip.PreTicks),
EndTick = sourceEvent.Tick + _rules.Clip.PostTicks
};
}
public double MinimumScore => _rules.Thresholds.HighlightScoreMin;
private double Weight(string key) => _rules.Weights.TryGetValue(key, out var value) ? value : 0d;
}
public sealed class HighlightScoreResult
{
public double Score { get; set; }
public List<string> Tags { get; set; } = [];
public List<ScoreExplanationRecord> Explanation { get; set; } = [];
public int StartTick { get; set; }
public int EndTick { get; set; }
}
@@ -0,0 +1,125 @@
using Fragalytics.Web.Models;
using Fragalytics.Web.Services.Render;
namespace Fragalytics.Web.Services.Scoring;
public sealed class MatchScoringService
{
private readonly ScoringRulesProvider _rulesProvider;
private readonly IRenderWorkerAdapter _renderWorker;
public MatchScoringService(ScoringRulesProvider rulesProvider, IRenderWorkerAdapter renderWorker)
{
_rulesProvider = rulesProvider;
_renderWorker = renderWorker;
}
public int RescoreMatch(AppState state, MatchRecord match)
{
var rules = _rulesProvider.Load();
var version = _rulesProvider.ComputeVersion(rules);
foreach (var profile in state.ScoringProfiles)
{
profile.IsActive = false;
}
var scoringProfile = state.ScoringProfiles.FirstOrDefault(profile => profile.Version == version);
if (scoringProfile is null)
{
scoringProfile = new ScoringProfileRecord
{
Id = state.NextScoringProfileId++,
Name = rules.Name,
Version = version,
Rules = rules,
IsActive = true
};
state.ScoringProfiles.Add(scoringProfile);
}
else
{
scoringProfile.IsActive = true;
scoringProfile.Rules = rules;
}
var existingHighlightIds = state.Highlights.Where(h => h.MatchId == match.Id).Select(h => h.Id).ToHashSet();
var existingClipAssetIds = state.ClipJobs
.Where(job => existingHighlightIds.Contains(job.HighlightId) && job.ClipAssetId.HasValue)
.Select(job => job.ClipAssetId!.Value)
.ToHashSet();
state.ClipJobs.RemoveAll(job => existingHighlightIds.Contains(job.HighlightId));
state.ClipAssets.RemoveAll(asset => existingClipAssetIds.Contains(asset.Id));
state.Highlights.RemoveAll(highlight => highlight.MatchId == match.Id);
var scorer = new HighlightScorer(rules);
var playersById = state.Players.Where(player => player.MatchId == match.Id).ToDictionary(player => player.Id);
var roundsById = state.Rounds.Where(round => round.MatchId == match.Id).ToDictionary(round => round.Id);
var killEvents = state.Events
.Where(@event => @event.MatchId == match.Id && string.Equals(@event.EventType, "kill", StringComparison.OrdinalIgnoreCase))
.OrderBy(@event => @event.RoundId)
.ThenBy(@event => @event.Tick)
.ToList();
var grouped = killEvents.GroupBy(@event => @event.RoundId).ToList();
var totalHighlights = 0;
foreach (var group in grouped)
{
var roundKills = group.ToList();
for (var index = 0; index < roundKills.Count; index++)
{
var candidate = roundKills[index];
roundsById.TryGetValue(candidate.RoundId ?? 0, out var round);
var result = scorer.ScoreKill(candidate, round, roundKills, playersById, index);
if (result.Score < scorer.MinimumScore)
{
continue;
}
var highlight = new HighlightRecord
{
Id = state.NextHighlightId++,
MatchId = match.Id,
RoundId = candidate.RoundId,
SourceEventId = candidate.Id,
Score = result.Score,
Tags = result.Tags,
Explanation = result.Explanation,
ScoringVersion = version,
StartTick = result.StartTick,
EndTick = result.EndTick
};
state.Highlights.Add(highlight);
var clipJob = new ClipJobRecord
{
Id = state.NextClipJobId++,
HighlightId = highlight.Id,
Renderer = _renderWorker.Name,
Status = "pending",
StartTick = highlight.StartTick,
EndTick = highlight.EndTick,
Payload = new Dictionary<string, string>
{
["sourceEventId"] = candidate.Id.ToString(),
["matchId"] = match.Id.ToString(),
["highlightId"] = highlight.Id.ToString(),
["startTick"] = highlight.StartTick.ToString(),
["endTick"] = highlight.EndTick.ToString(),
["cameraMode"] = "killer_pov",
["outputFormat"] = "mp4",
["resolution"] = "1920x1080",
["fps"] = "60"
}
};
_renderWorker.Enqueue(clipJob);
state.ClipJobs.Add(clipJob);
totalHighlights++;
}
}
return totalHighlights;
}
}
@@ -0,0 +1,31 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Fragalytics.Web.Models;
namespace Fragalytics.Web.Services.Scoring;
public sealed class ScoringRulesProvider
{
private readonly IWebHostEnvironment _environment;
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
public ScoringRulesProvider(IWebHostEnvironment environment)
{
_environment = environment;
}
public HighlightScoringRules Load()
{
var path = Path.Combine(_environment.ContentRootPath, "scoring-rules.json");
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<HighlightScoringRules>(json, _jsonOptions)
?? new HighlightScoringRules();
}
public string ComputeVersion(HighlightScoringRules rules)
{
var payload = JsonSerializer.Serialize(rules, _jsonOptions);
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant()[..12];
}
}
@@ -0,0 +1,9 @@
{
"DetailedErrors": true,
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Fragalytics": {
"DataDirectory": "data",
"UploadDirectory": "data/uploads",
"WorkerPollSeconds": 2,
"AutoStartWorker": true,
"Parser": {
"Mode": "sample",
"ExternalCommand": "",
"ExternalArguments": "\"{demoPath}\""
},
"RenderWorker": {
"PollSeconds": 3,
"Backend": "sample-copy",
"SampleClipPath": "",
"TempDirectory": "data/render-temp",
"RunContinuously": true
}
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "default",
"weights": {
"base_kill": 10,
"headshot": 8,
"opening_kill": 12,
"wallbang": 10,
"smoke_kill": 10,
"round_win": 6,
"multikill_2": 8,
"multikill_3": 18,
"multikill_4": 30,
"multikill_5": 45,
"clutch_1v2": 18,
"clutch_1v3": 28,
"clutch_1v4": 40,
"clutch_1v5": 55,
"impact_last_enemy": 12,
"impact_low_time": 4
},
"clip": {
"preTicks": 160,
"postTicks": 96
},
"thresholds": {
"highlightScoreMin": 18
}
}
+162
View File
@@ -0,0 +1,162 @@
:root {
--bg: #f4f1ea;
--panel: #fffdf8;
--ink: #1f2421;
--muted: #5a645e;
--accent: #b6482f;
--accent-2: #24584f;
--line: #d9d0c3;
--soft: #efe8de;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Georgia, "Times New Roman", serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(182, 72, 47, 0.12), transparent 24rem),
linear-gradient(180deg, #f8f4ed, var(--bg));
}
a { color: var(--accent-2); text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, p { margin-top: 0; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.75rem; border-bottom: 1px solid var(--line); vertical-align: top; text-align: left; }
code { font-family: "Courier New", monospace; font-size: 0.9rem; }
.clip-player { width: 240px; max-width: 100%; border-radius: 12px; background: #000; }
.site-header,
.page {
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
}
.site-header {
display: flex;
justify-content: space-between;
align-items: end;
gap: 1rem;
padding: 1.5rem 0 1rem;
}
.brand {
color: var(--ink);
font-size: 2rem;
font-weight: 700;
}
.nav {
display: flex;
gap: 1rem;
}
.page {
padding-bottom: 3rem;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 18px;
padding: 1.25rem;
box-shadow: 0 8px 30px rgba(31, 36, 33, 0.05);
}
.hero,
.split,
.stats-grid,
.meta-grid {
display: grid;
gap: 1rem;
margin-bottom: 1rem;
}
.hero { grid-template-columns: 2fr 1fr; }
.split { grid-template-columns: 1.15fr 1fr; }
.stats-grid { grid-template-columns: repeat(4, 1fr); }
.meta-grid { grid-template-columns: repeat(4, 1fr); }
.upload-form {
display: grid;
gap: 0.75rem;
align-content: center;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: start;
gap: 1rem;
}
.muted {
color: var(--muted);
}
.pill {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 999px;
background: var(--soft);
border: 1px solid var(--line);
font-size: 0.85rem;
}
.status-uploaded,
.status-pending { background: #f5e8b8; }
.status-processing { background: #cfe0ff; }
.status-parsed { background: #c9ead0; }
.status-failed { background: #f1c4bd; }
button,
.button-link,
input[type="submit"] {
display: inline-block;
border: none;
border-radius: 999px;
background: var(--accent);
color: white;
padding: 0.7rem 1rem;
font: inherit;
cursor: pointer;
}
button.secondary {
background: var(--soft);
color: var(--ink);
border: 1px solid var(--line);
}
input[type="file"] {
width: 100%;
border: 1px dashed var(--line);
border-radius: 12px;
background: var(--soft);
padding: 1rem;
}
dt {
color: var(--muted);
font-size: 0.85rem;
}
dd {
margin: 0.3rem 0 0;
}
@media (max-width: 900px) {
.hero,
.split,
.stats-grid,
.meta-grid {
grid-template-columns: 1fr;
}
.site-header {
flex-direction: column;
align-items: start;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+4
View File
@@ -0,0 +1,4 @@
// Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
// for details on configuring this project to bundle and minify static web assets.
// Write your JavaScript code.
@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2011-2021 Twitter, Inc.
Copyright (c) 2011-2021 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,427 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
background-color: currentColor;
border: 0;
opacity: 0.25;
}
hr:not([size]) {
height: 1px;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title],
abbr[data-bs-original-title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.2em;
background-color: #fcf8e3;
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: #0d6efd;
text-decoration: underline;
}
a:hover {
color: #0a58ca;
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 1em;
direction: ltr /* rtl:ignore */;
unicode-bidi: bidi-override;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: #d63384;
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.2rem 0.4rem;
font-size: 0.875em;
color: #fff;
background-color: #212529;
border-radius: 0.2rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
font-weight: 700;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: #6c757d;
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]::-webkit-calendar-picker-indicator {
display: none;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
outline-offset: -2px;
-webkit-appearance: textfield;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::file-selector-button {
font: inherit;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}
/*# sourceMappingURL=bootstrap-reboot.min.css.map */
File diff suppressed because one or more lines are too long
@@ -0,0 +1,424 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
background-color: currentColor;
border: 0;
opacity: 0.25;
}
hr:not([size]) {
height: 1px;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title],
abbr[data-bs-original-title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.2em;
background-color: #fcf8e3;
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: #0d6efd;
text-decoration: underline;
}
a:hover {
color: #0a58ca;
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 1em;
direction: ltr ;
unicode-bidi: bidi-override;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: #d63384;
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.2rem 0.4rem;
font-size: 0.875em;
color: #fff;
background-color: #212529;
border-radius: 0.2rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
font-weight: 700;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: #6c757d;
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]::-webkit-calendar-picker-indicator {
display: none;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
outline-offset: -2px;
-webkit-appearance: textfield;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::file-selector-button {
font: inherit;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-right:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-right:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:right}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:right;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:right}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}[type=email],[type=number],[type=tel],[type=url]{direction:ltr}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}
/*# sourceMappingURL=bootstrap-reboot.rtl.min.css.map */
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,23 @@
The MIT License (MIT)
Copyright (c) .NET Foundation and Contributors
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,435 @@
/**
* @license
* Unobtrusive validation support library for jQuery and jQuery Validate
* Copyright (c) .NET Foundation. All rights reserved.
* Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
* @version v4.0.0
*/
/*jslint white: true, browser: true, onevar: true, undef: true, nomen: true, eqeqeq: true, plusplus: true, bitwise: true, regexp: true, newcap: true, immed: true, strict: false */
/*global document: false, jQuery: false */
(function (factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define("jquery.validate.unobtrusive", ['jquery-validation'], factory);
} else if (typeof module === 'object' && module.exports) {
// CommonJS-like environments that support module.exports
module.exports = factory(require('jquery-validation'));
} else {
// Browser global
jQuery.validator.unobtrusive = factory(jQuery);
}
}(function ($) {
var $jQval = $.validator,
adapters,
data_validation = "unobtrusiveValidation";
function setValidationValues(options, ruleName, value) {
options.rules[ruleName] = value;
if (options.message) {
options.messages[ruleName] = options.message;
}
}
function splitAndTrim(value) {
return value.replace(/^\s+|\s+$/g, "").split(/\s*,\s*/g);
}
function escapeAttributeValue(value) {
// As mentioned on http://api.jquery.com/category/selectors/
return value.replace(/([!"#$%&'()*+,./:;<=>?@\[\\\]^`{|}~])/g, "\\$1");
}
function getModelPrefix(fieldName) {
return fieldName.substr(0, fieldName.lastIndexOf(".") + 1);
}
function appendModelPrefix(value, prefix) {
if (value.indexOf("*.") === 0) {
value = value.replace("*.", prefix);
}
return value;
}
function onError(error, inputElement) { // 'this' is the form element
var container = $(this).find("[data-valmsg-for='" + escapeAttributeValue(inputElement[0].name) + "']"),
replaceAttrValue = container.attr("data-valmsg-replace"),
replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) !== false : null;
container.removeClass("field-validation-valid").addClass("field-validation-error");
error.data("unobtrusiveContainer", container);
if (replace) {
container.empty();
error.removeClass("input-validation-error").appendTo(container);
}
else {
error.hide();
}
}
function onErrors(event, validator) { // 'this' is the form element
var container = $(this).find("[data-valmsg-summary=true]"),
list = container.find("ul");
if (list && list.length && validator.errorList.length) {
list.empty();
container.addClass("validation-summary-errors").removeClass("validation-summary-valid");
$.each(validator.errorList, function () {
$("<li />").html(this.message).appendTo(list);
});
}
}
function onSuccess(error) { // 'this' is the form element
var container = error.data("unobtrusiveContainer");
if (container) {
var replaceAttrValue = container.attr("data-valmsg-replace"),
replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) : null;
container.addClass("field-validation-valid").removeClass("field-validation-error");
error.removeData("unobtrusiveContainer");
if (replace) {
container.empty();
}
}
}
function onReset(event) { // 'this' is the form element
var $form = $(this),
key = '__jquery_unobtrusive_validation_form_reset';
if ($form.data(key)) {
return;
}
// Set a flag that indicates we're currently resetting the form.
$form.data(key, true);
try {
$form.data("validator").resetForm();
} finally {
$form.removeData(key);
}
$form.find(".validation-summary-errors")
.addClass("validation-summary-valid")
.removeClass("validation-summary-errors");
$form.find(".field-validation-error")
.addClass("field-validation-valid")
.removeClass("field-validation-error")
.removeData("unobtrusiveContainer")
.find(">*") // If we were using valmsg-replace, get the underlying error
.removeData("unobtrusiveContainer");
}
function validationInfo(form) {
var $form = $(form),
result = $form.data(data_validation),
onResetProxy = $.proxy(onReset, form),
defaultOptions = $jQval.unobtrusive.options || {},
execInContext = function (name, args) {
var func = defaultOptions[name];
func && $.isFunction(func) && func.apply(form, args);
};
if (!result) {
result = {
options: { // options structure passed to jQuery Validate's validate() method
errorClass: defaultOptions.errorClass || "input-validation-error",
errorElement: defaultOptions.errorElement || "span",
errorPlacement: function () {
onError.apply(form, arguments);
execInContext("errorPlacement", arguments);
},
invalidHandler: function () {
onErrors.apply(form, arguments);
execInContext("invalidHandler", arguments);
},
messages: {},
rules: {},
success: function () {
onSuccess.apply(form, arguments);
execInContext("success", arguments);
}
},
attachValidation: function () {
$form
.off("reset." + data_validation, onResetProxy)
.on("reset." + data_validation, onResetProxy)
.validate(this.options);
},
validate: function () { // a validation function that is called by unobtrusive Ajax
$form.validate();
return $form.valid();
}
};
$form.data(data_validation, result);
}
return result;
}
$jQval.unobtrusive = {
adapters: [],
parseElement: function (element, skipAttach) {
/// <summary>
/// Parses a single HTML element for unobtrusive validation attributes.
/// </summary>
/// <param name="element" domElement="true">The HTML element to be parsed.</param>
/// <param name="skipAttach" type="Boolean">[Optional] true to skip attaching the
/// validation to the form. If parsing just this single element, you should specify true.
/// If parsing several elements, you should specify false, and manually attach the validation
/// to the form when you are finished. The default is false.</param>
var $element = $(element),
form = $element.parents("form")[0],
valInfo, rules, messages;
if (!form) { // Cannot do client-side validation without a form
return;
}
valInfo = validationInfo(form);
valInfo.options.rules[element.name] = rules = {};
valInfo.options.messages[element.name] = messages = {};
$.each(this.adapters, function () {
var prefix = "data-val-" + this.name,
message = $element.attr(prefix),
paramValues = {};
if (message !== undefined) { // Compare against undefined, because an empty message is legal (and falsy)
prefix += "-";
$.each(this.params, function () {
paramValues[this] = $element.attr(prefix + this);
});
this.adapt({
element: element,
form: form,
message: message,
params: paramValues,
rules: rules,
messages: messages
});
}
});
$.extend(rules, { "__dummy__": true });
if (!skipAttach) {
valInfo.attachValidation();
}
},
parse: function (selector) {
/// <summary>
/// Parses all the HTML elements in the specified selector. It looks for input elements decorated
/// with the [data-val=true] attribute value and enables validation according to the data-val-*
/// attribute values.
/// </summary>
/// <param name="selector" type="String">Any valid jQuery selector.</param>
// $forms includes all forms in selector's DOM hierarchy (parent, children and self) that have at least one
// element with data-val=true
var $selector = $(selector),
$forms = $selector.parents()
.addBack()
.filter("form")
.add($selector.find("form"))
.has("[data-val=true]");
$selector.find("[data-val=true]").each(function () {
$jQval.unobtrusive.parseElement(this, true);
});
$forms.each(function () {
var info = validationInfo(this);
if (info) {
info.attachValidation();
}
});
}
};
adapters = $jQval.unobtrusive.adapters;
adapters.add = function (adapterName, params, fn) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="params" type="Array" optional="true">[Optional] An array of parameter names (strings) that will
/// be extracted from the data-val-nnnn-mmmm HTML attributes (where nnnn is the adapter name, and
/// mmmm is the parameter name).</param>
/// <param name="fn" type="Function">The function to call, which adapts the values from the HTML
/// attributes into jQuery Validate rules and/or messages.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
if (!fn) { // Called with no params, just a function
fn = params;
params = [];
}
this.push({ name: adapterName, params: params, adapt: fn });
return this;
};
adapters.addBool = function (adapterName, ruleName) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation rule has no parameter values.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="ruleName" type="String" optional="true">[Optional] The name of the jQuery Validate rule. If not provided, the value
/// of adapterName will be used instead.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, function (options) {
setValidationValues(options, ruleName || adapterName, true);
});
};
adapters.addMinMax = function (adapterName, minRuleName, maxRuleName, minMaxRuleName, minAttribute, maxAttribute) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation has three potential rules (one for min-only, one for max-only, and
/// one for min-and-max). The HTML parameters are expected to be named -min and -max.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="minRuleName" type="String">The name of the jQuery Validate rule to be used when you only
/// have a minimum value.</param>
/// <param name="maxRuleName" type="String">The name of the jQuery Validate rule to be used when you only
/// have a maximum value.</param>
/// <param name="minMaxRuleName" type="String">The name of the jQuery Validate rule to be used when you
/// have both a minimum and maximum value.</param>
/// <param name="minAttribute" type="String" optional="true">[Optional] The name of the HTML attribute that
/// contains the minimum value. The default is "min".</param>
/// <param name="maxAttribute" type="String" optional="true">[Optional] The name of the HTML attribute that
/// contains the maximum value. The default is "max".</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, [minAttribute || "min", maxAttribute || "max"], function (options) {
var min = options.params.min,
max = options.params.max;
if (min && max) {
setValidationValues(options, minMaxRuleName, [min, max]);
}
else if (min) {
setValidationValues(options, minRuleName, min);
}
else if (max) {
setValidationValues(options, maxRuleName, max);
}
});
};
adapters.addSingleVal = function (adapterName, attribute, ruleName) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation rule has a single value.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute(where nnnn is the adapter name).</param>
/// <param name="attribute" type="String">[Optional] The name of the HTML attribute that contains the value.
/// The default is "val".</param>
/// <param name="ruleName" type="String" optional="true">[Optional] The name of the jQuery Validate rule. If not provided, the value
/// of adapterName will be used instead.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, [attribute || "val"], function (options) {
setValidationValues(options, ruleName || adapterName, options.params[attribute]);
});
};
$jQval.addMethod("__dummy__", function (value, element, params) {
return true;
});
$jQval.addMethod("regex", function (value, element, params) {
var match;
if (this.optional(element)) {
return true;
}
match = new RegExp(params).exec(value);
return (match && (match.index === 0) && (match[0].length === value.length));
});
$jQval.addMethod("nonalphamin", function (value, element, nonalphamin) {
var match;
if (nonalphamin) {
match = value.match(/\W/g);
match = match && match.length >= nonalphamin;
}
return match;
});
if ($jQval.methods.extension) {
adapters.addSingleVal("accept", "mimtype");
adapters.addSingleVal("extension", "extension");
} else {
// for backward compatibility, when the 'extension' validation method does not exist, such as with versions
// of JQuery Validation plugin prior to 1.10, we should use the 'accept' method for
// validating the extension, and ignore mime-type validations as they are not supported.
adapters.addSingleVal("extension", "extension", "accept");
}
adapters.addSingleVal("regex", "pattern");
adapters.addBool("creditcard").addBool("date").addBool("digits").addBool("email").addBool("number").addBool("url");
adapters.addMinMax("length", "minlength", "maxlength", "rangelength").addMinMax("range", "min", "max", "range");
adapters.addMinMax("minlength", "minlength").addMinMax("maxlength", "minlength", "maxlength");
adapters.add("equalto", ["other"], function (options) {
var prefix = getModelPrefix(options.element.name),
other = options.params.other,
fullOtherName = appendModelPrefix(other, prefix),
element = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(fullOtherName) + "']")[0];
setValidationValues(options, "equalTo", element);
});
adapters.add("required", function (options) {
// jQuery Validate equates "required" with "mandatory" for checkbox elements
if (options.element.tagName.toUpperCase() !== "INPUT" || options.element.type.toUpperCase() !== "CHECKBOX") {
setValidationValues(options, "required", true);
}
});
adapters.add("remote", ["url", "type", "additionalfields"], function (options) {
var value = {
url: options.params.url,
type: options.params.type || "GET",
data: {}
},
prefix = getModelPrefix(options.element.name);
$.each(splitAndTrim(options.params.additionalfields || options.element.name), function (i, fieldName) {
var paramName = appendModelPrefix(fieldName, prefix);
value.data[paramName] = function () {
var field = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(paramName) + "']");
// For checkboxes and radio buttons, only pick up values from checked fields.
if (field.is(":checkbox")) {
return field.filter(":checked").val() || field.filter(":hidden").val() || '';
}
else if (field.is(":radio")) {
return field.filter(":checked").val() || '';
}
return field.val();
};
});
setValidationValues(options, "remote", value);
});
adapters.add("password", ["min", "nonalphamin", "regex"], function (options) {
if (options.params.min) {
setValidationValues(options, "minlength", options.params.min);
}
if (options.params.nonalphamin) {
setValidationValues(options, "nonalphamin", options.params.nonalphamin);
}
if (options.params.regex) {
setValidationValues(options, "regex", options.params.regex);
}
});
adapters.add("fileextensions", ["extensions"], function (options) {
setValidationValues(options, "extension", options.params.extensions);
});
$(function () {
$jQval.unobtrusive.parse(document);
});
return $jQval.unobtrusive;
}));
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
The MIT License (MIT)
=====================
Copyright Jörn Zaefferer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
Copyright OpenJS Foundation and other contributors, https://openjsf.org/
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

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