Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f934e69c90 | ||
|
|
937b8554dd | ||
|
|
c9a3c252e1 | ||
|
|
a9860f4c94 | ||
|
|
d9e05fb487 | ||
|
|
e10663c8c0 | ||
|
|
cd03b39c20 | ||
|
|
9d8a7b3100 | ||
|
|
e99479d8fb | ||
|
|
a9150ff58c | ||
|
|
fba822a0af | ||
|
|
73407e5867 | ||
|
|
91664406c4 | ||
|
|
bc95ef157d | ||
|
|
454c12a5a9 | ||
|
|
88fb4935b5 | ||
|
|
ede8ff088c | ||
|
|
f466bb9174 | ||
|
|
b7f65a5a98 | ||
|
|
212527bfc9 | ||
|
|
4d1df14fe5 | ||
|
|
99257c3422 |
@@ -0,0 +1,83 @@
|
|||||||
|
# Copy to .env next to docker-compose.yml and edit. Compose reads it automatically;
|
||||||
|
# git ignores .env. Every value here is optional — the defaults in docker-compose.yml
|
||||||
|
# are what you get without it.
|
||||||
|
|
||||||
|
# --- Ports on your machine -----------------------------------------------------------
|
||||||
|
# Container-side ports are fixed (API 5000, nginx 80); only these move.
|
||||||
|
#LADOSE_WEB_PORT=8080
|
||||||
|
#LADOSE_API_PORT=5000
|
||||||
|
|
||||||
|
# --- Database ------------------------------------------------------------------------
|
||||||
|
# Overrides ConnectionStrings:DbContext from LaDOSE.Api/appsettings.json. The stack has
|
||||||
|
# no Postgres of its own, so this has to point at a reachable server.
|
||||||
|
#
|
||||||
|
# Postgres on the machine running the containers:
|
||||||
|
#LADOSE_DB_CONNECTION=Host=host.docker.internal;Username=tom;Password=tom;Database=ladoseapi
|
||||||
|
#
|
||||||
|
# Postgres elsewhere on the LAN — check the name resolves *inside* the container
|
||||||
|
# (`docker compose run --rm api getent hosts kafka.local`) and use the IP if it does not:
|
||||||
|
#LADOSE_DB_CONNECTION=Host=kafka.local;Username=tom;Password=tom;Database=ladoseapi
|
||||||
|
#
|
||||||
|
# Sql/dump_20240316.sql then Sql/2026-08-05_roles.sql populate an empty database.
|
||||||
|
|
||||||
|
# --- Secrets -------------------------------------------------------------------------
|
||||||
|
# appsettings.json carries placeholders for these. Any value works for JWT locally, but
|
||||||
|
# it must be at least 32 characters: Startup.cs feeds it to HMAC-SHA256 as raw ASCII.
|
||||||
|
#LADOSE_JWT_SECRET=dev-only-secret-not-for-any-deployed-environment
|
||||||
|
#LADOSE_SMASH_API_KEY=
|
||||||
|
#LADOSE_CHALLONGE_API_KEY=
|
||||||
|
|
||||||
|
# --- Google Sheets export ------------------------------------------------------------
|
||||||
|
# Powers "Push to Google Sheets" next to Export CSV on /tournaments: writes the generated
|
||||||
|
# ranking table into one tab, named after the latest event in the selection.
|
||||||
|
#
|
||||||
|
# Writer: Disabled | Logging | ServiceAccount.
|
||||||
|
# Disabled the button says "not configured" and refuses (the default)
|
||||||
|
# Logging writes the payload to the API log instead of Google — use this to check
|
||||||
|
# a selection without touching a spreadsheet
|
||||||
|
# ServiceAccount writes for real
|
||||||
|
#LADOSE_SHEETS_WRITER=Disabled
|
||||||
|
#
|
||||||
|
# The target spreadsheet. THIS is the value to change each year when you start a new
|
||||||
|
# sheet — then share the new sheet with the service-account address below as Editor.
|
||||||
|
# It is the id from the sheet URL, not the whole URL:
|
||||||
|
# https://docs.google.com/spreadsheets/d/<THIS PART>/edit
|
||||||
|
#LADOSE_SHEETS_SPREADSHEET_ID=
|
||||||
|
#
|
||||||
|
# Path *inside the container* to the service-account JSON key. docker-compose.yml mounts
|
||||||
|
# ./secrets (git-ignored) at /run/secrets, so drop the key there and leave this as is.
|
||||||
|
#
|
||||||
|
# One-time Google setup: create a project, enable the Google Sheets API, create a service
|
||||||
|
# account, create a JSON key, save it as secrets/ladose-sheets-sa.json, then share the
|
||||||
|
# spreadsheet with the account's ...iam.gserviceaccount.com address as Editor. Only that
|
||||||
|
# last sharing step repeats when you switch to a new sheet.
|
||||||
|
#LADOSE_SHEETS_SA_CREDENTIALS_PATH=/run/secrets/ladose-sheets-sa.json
|
||||||
|
|
||||||
|
# --- Discord bot -----------------------------------------------------------------------
|
||||||
|
# LaDOSE.DiscordBot only runs when you ask for it: `docker compose --profile bot up`.
|
||||||
|
# These values are rendered into the container's settings.json at start — the bot reads
|
||||||
|
# that file and never the environment.
|
||||||
|
#
|
||||||
|
# From https://discord.com/developers/applications -> your application -> Bot -> Reset
|
||||||
|
# Token. Setting this is what enables the rendering; leave it commented out and the bot
|
||||||
|
# falls back to the placeholder settings.json baked into the image (which cannot connect).
|
||||||
|
# The bot also needs the MESSAGE CONTENT intent enabled on that same page: Program.cs asks
|
||||||
|
# for DiscordIntents.MessageContents and Discord refuses the connection otherwise.
|
||||||
|
#LADOSE_DISCORD_TOKEN=
|
||||||
|
#
|
||||||
|
# Where the bot finds LaDOSE.Api. Resolved inside the container, so this is the compose
|
||||||
|
# service name, not localhost, and LADOSE_API_PORT does not apply.
|
||||||
|
#LADOSE_BOT_REST_URL=http://api:5000
|
||||||
|
#
|
||||||
|
# A LaDOSE.Api login for the bot — the commands that read events and rankings authenticate
|
||||||
|
# with it. Without it those commands log "Unable to contact services" and the rest of the
|
||||||
|
# bot still works.
|
||||||
|
#LADOSE_BOT_REST_USER=
|
||||||
|
#LADOSE_BOT_REST_PASSWORD=
|
||||||
|
#
|
||||||
|
# Challonge is shared with the api service: LADOSE_CHALLONGE_API_KEY above feeds both.
|
||||||
|
|
||||||
|
# --- Frontend ------------------------------------------------------------------------
|
||||||
|
# Only needed if the API is not on http://localhost:${LADOSE_API_PORT}. Resolved by the
|
||||||
|
# browser, so container names like http://api:5000 will not work.
|
||||||
|
#LADOSE_API_BASE_URL=http://localhost:5000
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
name: Build App
|
||||||
|
run-name: ${{ gitea.actor }} is building
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
Build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Update
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -y -qq
|
||||||
|
sudo apt-get install zip
|
||||||
|
sudo sh -c "echo '192.168.1.253 descartes.local' >> /etc/hosts"
|
||||||
|
sudo sh -c "echo '192.168.1.253 build.ladose.net' >> /etc/hosts"
|
||||||
|
- name: GetDNS
|
||||||
|
run: |
|
||||||
|
cat /etc/resolv.conf
|
||||||
|
- name: Setup .NET 6.x
|
||||||
|
uses: actions/setup-dotnet@v3
|
||||||
|
with:
|
||||||
|
# Semantic version range syntax or exact version of a dotnet version
|
||||||
|
dotnet-version: '8.x'
|
||||||
|
|
||||||
|
- run: echo "Build."
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Restore Deps
|
||||||
|
run : |
|
||||||
|
cd LaDOSE.Src/
|
||||||
|
dotnet restore LaDOSE.linux.sln
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
cd LaDOSE.Src/
|
||||||
|
dotnet build --configuration Release --os linux LaDOSE.DesktopApp.Avalonia
|
||||||
|
dotnet build --configuration Release --os win LaDOSE.DesktopApp.Avalonia
|
||||||
|
- name: Zip file
|
||||||
|
run: |
|
||||||
|
zip -rj build-winx64.zip ./LaDOSE.Src/LaDOSE.DesktopApp.Avalonia/bin/Release/net8.0/win-x64/
|
||||||
|
zip -rj build-linux64.zip ./LaDOSE.Src/LaDOSE.DesktopApp.Avalonia/bin/Release/net8.0/linux-x64/
|
||||||
|
- name: Upload Artifact Windows
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
path: build-linux64.zip
|
||||||
|
name: build-linux64.zip
|
||||||
|
retention-days: 30
|
||||||
|
overwrite: true
|
||||||
|
|
||||||
|
- name: Upload Artifact Linux
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
path: build-winx64.zip
|
||||||
|
name: build-winx64.zip
|
||||||
|
retention-days: 30
|
||||||
|
overwrite: true
|
||||||
|
|
||||||
|
- name: Get current date
|
||||||
|
id: date
|
||||||
|
run: echo "date=$(echo $(date +'%Y-%m-%d'))" >> $GITHUB_OUTPUT
|
||||||
|
- name: Release
|
||||||
|
if: github.ref_name == 'master'
|
||||||
|
uses: akkuman/gitea-release-action@v1
|
||||||
|
env:
|
||||||
|
with:
|
||||||
|
tag_name: release-${{ steps.date.outputs.date }}
|
||||||
|
files: |-
|
||||||
|
build-winx64.zip
|
||||||
|
build-linux64.zip
|
||||||
|
|
||||||
+14
@@ -328,3 +328,17 @@ ASALocalRun/
|
|||||||
|
|
||||||
# MFractors (Xamarin productivity tool) working folder
|
# MFractors (Xamarin productivity tool) working folder
|
||||||
.mfractor/
|
.mfractor/
|
||||||
|
|
||||||
|
# Local docker-compose overrides: connection string, API keys, ports.
|
||||||
|
# .env.example is documentation and stays tracked.
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# Google service-account key for the Sheets export, mounted at /run/secrets.
|
||||||
|
# Full write access to the ranking spreadsheet — never commit it.
|
||||||
|
# `secrets/*`, not `secrets/`: excluding the directory itself would stop git from
|
||||||
|
# looking inside it at all, and the un-ignore below would never apply.
|
||||||
|
secrets/*
|
||||||
|
!secrets/.gitkeep
|
||||||
|
|||||||
@@ -1,2 +1,22 @@
|
|||||||
*/*/bin*
|
# Context for LaDOSE.Src/Dockerfile.
|
||||||
*/*/obj*
|
#
|
||||||
|
# The previous patterns here were */*/bin* and */*/obj*, which matched nothing: this
|
||||||
|
# context is rooted at LaDOSE.Src, so build output sits one level down (LaDOSE.Api/bin),
|
||||||
|
# not two.
|
||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
|
||||||
|
# The frontend is a separate image with its own context (LaDOSE.WebApp/Dockerfile).
|
||||||
|
# Its node_modules alone was adding ~118 MB to every API build.
|
||||||
|
LaDOSE.WebApp/node_modules/
|
||||||
|
LaDOSE.WebApp/build/
|
||||||
|
LaDOSE.WebApp/.svelte-kit/
|
||||||
|
|
||||||
|
# Local state and editor noise. Note Libraries/ is NOT excluded: LaDOSE.Business
|
||||||
|
# references ChallongeCSharpDriver.dll from there by HintPath.
|
||||||
|
.git
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
|||||||
+40
-11
@@ -1,15 +1,44 @@
|
|||||||
FROM microsoft/dotnet:sdk AS build-env
|
# Builds the LaDOSE.Api image. Context is LaDOSE.Src (see .dockerignore next to this file).
|
||||||
|
#
|
||||||
|
# Only LaDOSE.Api is published. LaDOSE.linux.sln also carries the Avalonia desktop app,
|
||||||
|
# the Discord bot and LinuxTest, and `dotnet publish <sln> -o out` flattens every project
|
||||||
|
# into that one directory — which is why the previous version of this file copied from
|
||||||
|
# /app/LaDOSE.Api/out/ and found nothing there.
|
||||||
|
ARG DOTNET_VERSION=9.0
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Debug is deliberate for local work, and docker-compose.yml passes it: the OpenAPI
|
||||||
|
# document and the Scalar UI are gated behind `#if DEBUG` in Startup.cs *and* behind
|
||||||
|
# Condition="'$(Configuration)' == 'Debug'" on their PackageReferences in
|
||||||
|
# LaDOSE.Api.csproj. A Release image therefore serves no /openapi/v1.json, which is
|
||||||
|
# exactly what LaDOSE.WebApp's `npm run api:sync` reads. Default stays Release.
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
|
||||||
|
# Project files first so this layer survives every .cs edit. Restore has to run under
|
||||||
|
# the same Configuration as the publish below, or the conditional PackageReferences
|
||||||
|
# above make the two disagree about which packages the assets file should contain.
|
||||||
|
COPY global.json ./
|
||||||
|
COPY LaDOSE.Api/LaDOSE.Api.csproj LaDOSE.Api/
|
||||||
|
COPY LaDOSE.DTO/LaDOSE.DTO.csproj LaDOSE.DTO/
|
||||||
|
COPY LaDOSE.Entity/LaDOSE.Entity.csproj LaDOSE.Entity/
|
||||||
|
COPY LaDOSE.Service/LaDOSE.Business.csproj LaDOSE.Service/
|
||||||
|
RUN dotnet restore LaDOSE.Api/LaDOSE.Api.csproj -p:Configuration=${BUILD_CONFIGURATION}
|
||||||
|
|
||||||
|
# Libraries/ChallongeCSharpDriver.dll is a HintPath reference from LaDOSE.Business,
|
||||||
|
# so the build needs the whole tree, not just the projects listed above.
|
||||||
|
COPY . .
|
||||||
|
RUN dotnet publish LaDOSE.Api/LaDOSE.Api.csproj -c ${BUILD_CONFIGURATION} --no-restore -o /app/out
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/out/ ./
|
||||||
|
|
||||||
# Copy everything else and build
|
# Fixed in the image on purpose. Program.cs binds Kestrel from appsettings.json's
|
||||||
COPY . ./
|
# AllowedHosts/Port through a ConfigurationBuilder that reads *only* that file, so a
|
||||||
|
# Port env var would not move the listener — remap on the host side instead.
|
||||||
RUN dotnet publish LaDOSE.linux.sln -c Release -o out
|
# Everything Startup.cs reads does honour env vars (ConnectionStrings__DbContext,
|
||||||
|
# ApiKey__SmashApiKey, ApiKey__ChallongeApiKey, JWTTokenSecret).
|
||||||
# Build runtime image
|
|
||||||
FROM microsoft/dotnet:aspnetcore-runtime
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=build-env /app/LaDOSE.Api/out/ .
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
ENTRYPOINT ["dotnet", "LaDOSE.Api.dll"]
|
ENTRYPOINT ["dotnet", "LaDOSE.Api.dll"]
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,17 @@ namespace LaDOSE.Api.Controllers
|
|||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
public class GameController : GenericControllerDTO<IGameService, Game, GameDTO>
|
public class GameController : GenericControllerDTO<IGameService, Game, GameDTO>
|
||||||
{
|
{
|
||||||
public GameController(IMapper mapper,IGameService service) : base(mapper,service)
|
private IExternalProviderService provider;
|
||||||
|
public GameController(IMapper mapper,IGameService service, IExternalProviderService service2) : base(mapper,service)
|
||||||
{
|
{
|
||||||
|
provider = service2;
|
||||||
|
}
|
||||||
|
[HttpGet("smash/{name}")]
|
||||||
|
public async Task<List<GameDTO>> GetIdFromSmash(string name)
|
||||||
|
{
|
||||||
|
var smashGame = await provider.GetSmashGame(name);
|
||||||
|
|
||||||
|
return _mapper.Map<List<GameDTO>>(smashGame);;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using AutoMapper;
|
||||||
|
using LaDOSE.Business.Interface;
|
||||||
|
using LaDOSE.DTO;
|
||||||
|
using LaDOSE.Entity;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace LaDOSE.Api.Controllers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pushes ranking tables into the club's Google Spreadsheet.
|
||||||
|
///
|
||||||
|
/// The tables arrive already built: the browser reuses the same buildRanking() that produces
|
||||||
|
/// the CSV export, so the sheet and the CSV cannot drift. This controller resolves the target
|
||||||
|
/// from configuration, validates, and relays — see SheetsExportService.
|
||||||
|
///
|
||||||
|
/// Returns IActionResult with explicit ProducesResponseType, like UsersController: the API has
|
||||||
|
/// no exception middleware, so an escaping throw reaches the browser as an HTML developer page
|
||||||
|
/// that the client can only report as a bare status.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize]
|
||||||
|
[Produces("application/json")]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class SheetsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ISheetsExportService _service;
|
||||||
|
private readonly IMapper _mapper;
|
||||||
|
private readonly ILogger<SheetsController> _logger;
|
||||||
|
|
||||||
|
public SheetsController(IMapper mapper, ISheetsExportService service, ILogger<SheetsController> logger)
|
||||||
|
{
|
||||||
|
_mapper = mapper;
|
||||||
|
_service = service;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the export is usable and where it points, so the panel can render itself and
|
||||||
|
/// disable the button when the server is not set up. Carries no secret and no key path.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("Config")]
|
||||||
|
[ProducesResponseType(typeof(SheetsConfigDTO), StatusCodes.Status200OK)]
|
||||||
|
public IActionResult GetConfig()
|
||||||
|
{
|
||||||
|
return Ok(_mapper.Map<SheetsConfigDTO>(_service.GetConfig()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes one tab per table, in the order given — oldest event first, each tab holding the
|
||||||
|
/// cumulative result up to and including its own event.
|
||||||
|
///
|
||||||
|
/// Existing tabs of the same name are cleared and rewritten in place, keeping their
|
||||||
|
/// formatting; anything typed into them by hand is lost. Tabs not named in the request are
|
||||||
|
/// never touched or deleted.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("Export")]
|
||||||
|
[ProducesResponseType(typeof(SheetExportResultDTO), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status502BadGateway)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||||
|
public async Task<IActionResult> Export([FromBody] SheetExportRequestDTO dto, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (dto?.Tabs == null || dto.Tabs.Count == 0)
|
||||||
|
return BadRequest(new { message = "No table to write." });
|
||||||
|
|
||||||
|
var request = _mapper.Map<SheetExportRequest>(dto);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _service.ExportAsync(request, ct);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Sheets export by user {User}: {TabCount} tabs to spreadsheet {SpreadsheetId} via {Writer}",
|
||||||
|
User?.Identity?.Name, result.Tabs?.Count ?? 0, result.SpreadsheetId, result.Writer);
|
||||||
|
|
||||||
|
return Ok(_mapper.Map<SheetExportResultDTO>(result));
|
||||||
|
}
|
||||||
|
catch (SheetsExportException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Sheets export refused ({StatusCode})", ex.StatusCode);
|
||||||
|
return StatusCode(ex.StatusCode, new { message = ex.Message });
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// The caller navigated away or hit cancel; nothing to report to them.
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Anything unforeseen still has to arrive as JSON with a message.
|
||||||
|
_logger.LogError(ex, "Sheets export failed");
|
||||||
|
return StatusCode(StatusCodes.Status502BadGateway, new { message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using AutoMapper;
|
||||||
|
using LaDOSE.Business.Interface;
|
||||||
|
using LaDOSE.DTO;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace LaDOSE.Api.Controllers
|
||||||
|
{
|
||||||
|
[Authorize]
|
||||||
|
[Produces("application/json")]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class StatisticsController : Controller
|
||||||
|
{
|
||||||
|
private readonly IStatisticsService _service;
|
||||||
|
private readonly IMapper _mapper;
|
||||||
|
|
||||||
|
public StatisticsController(IMapper mapper, IStatisticsService service)
|
||||||
|
{
|
||||||
|
_mapper = mapper;
|
||||||
|
_service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Match statistics for the given Event ids. Body is a bare JSON array of ids,
|
||||||
|
/// like TournamentController.GetResults.
|
||||||
|
/// A null or empty array returns a zeroed, empty MatchStatsDTO rather than an error.
|
||||||
|
/// Read Coverage before trusting the numbers: many brackets have no set rows at all.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("Matches")]
|
||||||
|
public async Task<MatchStatsDTO> GetMatchStats([FromBody] List<int> ids)
|
||||||
|
{
|
||||||
|
var stats = await _service.GetMatchStats(ids);
|
||||||
|
return _mapper.Map<MatchStatsDTO>(stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The players a versus lookup can report on: everyone with at least one set in a
|
||||||
|
/// bracket whose game is known, by display name. Not the application users —
|
||||||
|
/// these are tournament players, the entities set rows point at.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("Players")]
|
||||||
|
public async Task<List<PlayerOptionDTO>> GetVersusPlayers()
|
||||||
|
{
|
||||||
|
var players = await _service.GetVersusPlayers();
|
||||||
|
return _mapper.Map<List<PlayerOptionDTO>>(players);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every recorded meeting between two players, across all events, split per game.
|
||||||
|
/// Sets played in a bracket with no game attached are excluded and only counted in
|
||||||
|
/// UnknownGameSets. Two identical or unknown ids return an empty breakdown, not an error.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("Versus/{playerAId}/{playerBId}")]
|
||||||
|
public async Task<PlayerVersusDTO> GetVersus(int playerAId, int playerBId)
|
||||||
|
{
|
||||||
|
var versus = await _service.GetVersus(playerAId, playerBId);
|
||||||
|
return _mapper.Map<PlayerVersusDTO>(versus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ namespace LaDOSE.Api.Controllers
|
|||||||
|
|
||||||
private IMapper _mapper;
|
private IMapper _mapper;
|
||||||
|
|
||||||
// GET
|
// GETawa
|
||||||
public TournamentController(IMapper mapper, IExternalProviderService service)
|
public TournamentController(IMapper mapper, IExternalProviderService service)
|
||||||
{
|
{
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -9,6 +9,7 @@ using LaDOSE.Business.Interface;
|
|||||||
using LaDOSE.DTO;
|
using LaDOSE.DTO;
|
||||||
using LaDOSE.Entity;
|
using LaDOSE.Entity;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@@ -33,12 +34,32 @@ namespace LaDOSE.Api.Controllers
|
|||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Public view of a user: no password, no hash, no salt.</summary>
|
||||||
|
private static ApplicationUserDTO ToDto(ApplicationUser user)
|
||||||
|
{
|
||||||
|
return new ApplicationUserDTO
|
||||||
|
{
|
||||||
|
Id = user.Id,
|
||||||
|
Username = user.Username,
|
||||||
|
FirstName = user.FirstName,
|
||||||
|
LastName = user.LastName,
|
||||||
|
Roles = user.Names()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The id the JWT was issued for; null if the request is not authenticated.</summary>
|
||||||
|
private int? CurrentUserId()
|
||||||
|
{
|
||||||
|
return int.TryParse(User?.Identity?.Name, out var id) ? id : (int?)null;
|
||||||
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("auth")]
|
[HttpPost("auth")]
|
||||||
public IActionResult Authenticate([FromBody]ApplicationUser userDto)
|
[ProducesResponseType(typeof(ApplicationUserDTO), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public IActionResult Authenticate([FromBody]ApplicationUserDTO userDto)
|
||||||
{
|
{
|
||||||
var user = _userService.Authenticate(userDto.Username, userDto.Password);
|
var user = _userService.Authenticate(userDto?.Username, userDto?.Password);
|
||||||
|
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return BadRequest(new { message = "Username or password is incorrect" });
|
return BadRequest(new { message = "Username or password is incorrect" });
|
||||||
@@ -47,6 +68,9 @@ namespace LaDOSE.Api.Controllers
|
|||||||
var key = Encoding.ASCII.GetBytes(this._configuration["JWTTokenSecret"]);
|
var key = Encoding.ASCII.GetBytes(this._configuration["JWTTokenSecret"]);
|
||||||
var tokenDescriptor = new SecurityTokenDescriptor
|
var tokenDescriptor = new SecurityTokenDescriptor
|
||||||
{
|
{
|
||||||
|
// Only the user id goes in the token. Roles are read from the database on
|
||||||
|
// every request instead, so granting or revoking Admin takes effect at
|
||||||
|
// once rather than whenever the current token happens to expire.
|
||||||
Subject = new ClaimsIdentity(new Claim[]
|
Subject = new ClaimsIdentity(new Claim[]
|
||||||
{
|
{
|
||||||
new Claim(ClaimTypes.Name, user.Id.ToString()),
|
new Claim(ClaimTypes.Name, user.Id.ToString()),
|
||||||
@@ -60,29 +84,62 @@ namespace LaDOSE.Api.Controllers
|
|||||||
var tokenString = tokenHandler.WriteToken(token);
|
var tokenString = tokenHandler.WriteToken(token);
|
||||||
|
|
||||||
// return basic user info (without password) and token to store client side
|
// return basic user info (without password) and token to store client side
|
||||||
return Ok(new ApplicationUserDTO
|
var dto = ToDto(user);
|
||||||
{
|
dto.Token = tokenString;
|
||||||
Id = user.Id,
|
dto.Expire = token.ValidTo;
|
||||||
Username = user.Username,
|
return Ok(dto);
|
||||||
FirstName = user.FirstName,
|
|
||||||
LastName = user.LastName,
|
|
||||||
Token = tokenString,
|
|
||||||
Expire = token.ValidTo
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//[AllowAnonymous]
|
/// <summary>Every account, for the admin user-management screen.</summary>
|
||||||
[HttpPost("register")]
|
[Authorize(Roles = Roles.Admin)]
|
||||||
public IActionResult Register([FromBody]ApplicationUser userDto)
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(List<ApplicationUserDTO>), StatusCodes.Status200OK)]
|
||||||
|
public IActionResult GetUsers()
|
||||||
{
|
{
|
||||||
// map dto to entity
|
var users = _userService.GetAll()
|
||||||
|
.OrderBy(user => user.Username)
|
||||||
|
.Select(ToDto)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Ok(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The role names that may be assigned, from the applicationrole table.</summary>
|
||||||
|
[Authorize(Roles = Roles.Admin)]
|
||||||
|
[HttpGet("Roles")]
|
||||||
|
[ProducesResponseType(typeof(List<string>), StatusCodes.Status200OK)]
|
||||||
|
public IActionResult GetRoles()
|
||||||
|
{
|
||||||
|
return Ok(_userService.GetAllRoles().Select(role => role.Name).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an account. This replaces the old anonymous <c>register</c> endpoint —
|
||||||
|
/// only an admin may create users now, so the very first admin has to be promoted
|
||||||
|
/// directly in the database (see Sql/2026-08-05_roles.sql).
|
||||||
|
/// </summary>
|
||||||
|
[Authorize(Roles = Roles.Admin)]
|
||||||
|
[HttpPost("AddUser")]
|
||||||
|
[ProducesResponseType(typeof(ApplicationUserDTO), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public IActionResult AddUser([FromBody]ApplicationUserDTO userDto)
|
||||||
|
{
|
||||||
|
if (userDto == null)
|
||||||
|
return BadRequest(new { message = "No user supplied" });
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// save
|
var created = _userService.Create(
|
||||||
_userService.Create(userDto, userDto.Password);
|
new ApplicationUser
|
||||||
return Ok();
|
{
|
||||||
|
Username = userDto.Username?.Trim(),
|
||||||
|
FirstName = userDto.FirstName,
|
||||||
|
LastName = userDto.LastName
|
||||||
|
},
|
||||||
|
userDto.Password,
|
||||||
|
userDto.Roles);
|
||||||
|
|
||||||
|
return Ok(ToDto(created));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -91,7 +148,35 @@ namespace LaDOSE.Api.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes an account. Refuses to delete the caller: since only an admin can reach
|
||||||
|
/// this, and an admin cannot remove themselves, at least one admin always survives
|
||||||
|
/// — which matters because there is no anonymous way back in any more.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize(Roles = Roles.Admin)]
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public IActionResult DeleteUser(int id)
|
||||||
|
{
|
||||||
|
var user = _userService.GetById(id);
|
||||||
|
if (user == null)
|
||||||
|
return NotFound(new { message = "User not found" });
|
||||||
|
|
||||||
|
if (CurrentUserId() == id)
|
||||||
|
return BadRequest(new { message = "You cannot delete your own account" });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_userService.Delete(id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ namespace LaDOSE.Api.Controllers
|
|||||||
[HttpGet("UpdateDb")]
|
[HttpGet("UpdateDb")]
|
||||||
public bool UpdateDb()
|
public bool UpdateDb()
|
||||||
{
|
{
|
||||||
return _service.UpdateBooking();
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("CreateChallonge/{gameId:int}/{wpEventId:int}")]
|
[HttpGet("CreateChallonge/{gameId:int}/{wpEventId:int}")]
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#if DEBUG
|
||||||
|
using System.Linq;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ApplicationModels;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Routing;
|
||||||
|
|
||||||
|
namespace LaDOSE.Api.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The controllers in this project are attribute-routed but do not carry [ApiController].
|
||||||
|
/// Without it MVC never sets ApiExplorer visibility, so ApiExplorer yields no descriptions
|
||||||
|
/// and the generated OpenAPI document comes out with an empty "paths" object.
|
||||||
|
/// This convention opts the attribute-routed actions into ApiExplorer for the
|
||||||
|
/// OpenAPI/Scalar tooling only, without pulling in the [ApiController] behaviours
|
||||||
|
/// (automatic 400 responses, [FromBody] inference) that would change runtime binding.
|
||||||
|
/// </summary>
|
||||||
|
public class ApiExplorerVisibilityConvention : IControllerModelConvention
|
||||||
|
{
|
||||||
|
public void Apply(ControllerModel controller)
|
||||||
|
{
|
||||||
|
// Default the controller to hidden, then opt in action by action.
|
||||||
|
controller.ApiExplorer.IsVisible ??= false;
|
||||||
|
|
||||||
|
foreach (var action in controller.Actions)
|
||||||
|
{
|
||||||
|
if (action.ApiExplorer.IsVisible != null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An action with a [Route] but no verb attribute (e.g. BotEventController's
|
||||||
|
// CreateBotEvent) matches every HTTP method, so ApiExplorer reports an empty
|
||||||
|
// method and OpenAPI generation throws "Unsupported HTTP method".
|
||||||
|
// Only document actions that pin down a verb.
|
||||||
|
action.ApiExplorer.IsVisible = action.Attributes
|
||||||
|
.OfType<IActionHttpMethodProvider>()
|
||||||
|
.Any(provider => provider.HttpMethods?.Any() == true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal;
|
||||||
|
|
||||||
|
namespace LaDOSE.Api;
|
||||||
|
|
||||||
|
#pragma warning disable EF1001
|
||||||
|
public class NpgsqlSqlGenerationLowercaseHelper : NpgsqlSqlGenerationHelper
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
static string ToLowerCase(string input) => input.ToLower();
|
||||||
|
public NpgsqlSqlGenerationLowercaseHelper(RelationalSqlGenerationHelperDependencies dependencies)
|
||||||
|
: base(dependencies) { }
|
||||||
|
public override string DelimitIdentifier(string identifier)
|
||||||
|
=> base.DelimitIdentifier(ToLowerCase(identifier));
|
||||||
|
public override void DelimitIdentifier(StringBuilder builder, string identifier)
|
||||||
|
=> base.DelimitIdentifier(builder, ToLowerCase(identifier));
|
||||||
|
}
|
||||||
|
#pragma warning restore EF1001
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Platforms>AnyCPU;x64</Platforms>
|
<Platforms>AnyCPU;x64</Platforms>
|
||||||
|
<LangVersion>12</LangVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -10,13 +11,16 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AutoMapper" Version="10.0.0" />
|
<PackageReference Include="AutoMapper" Version="13.0.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="3.1.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.12" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.1.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.12" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.8" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="3.1.4" />
|
<PackageReference Include="Microsoft.OpenApi" Version="1.6.17" Condition="'$(Configuration)' == 'Debug'" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.18" Condition="'$(Configuration)' == 'Debug'" />
|
||||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="6.0.2" />
|
<PackageReference Include="Scalar.AspNetCore" Version="2.16.17" Condition="'$(Configuration)' == 'Debug'" />
|
||||||
|
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.7" />
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,37 +1,35 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using LaDOSE.Business.Interface;
|
using LaDOSE.Business.Interface;
|
||||||
using LaDOSE.Business.Provider;
|
|
||||||
using LaDOSE.Business.Service;
|
using LaDOSE.Business.Service;
|
||||||
using LaDOSE.Entity;
|
using LaDOSE.Entity;
|
||||||
using LaDOSE.Entity.Context;
|
using LaDOSE.Entity.Context;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
//using Microsoft.AspNetCore.HttpsPolicy;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Pomelo.EntityFrameworkCore.MySql;
|
|
||||||
using Pomelo.EntityFrameworkCore.MySql.Infrastructure;
|
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using LaDOSE.Api.Helpers;
|
using LaDOSE.Api.Helpers;
|
||||||
using LaDOSE.Business.Helper;
|
using LaDOSE.Business.Helper;
|
||||||
using LaDOSE.Business.Provider.ChallongProvider;
|
using LaDOSE.Business.Provider.ChallongProvider;
|
||||||
|
using LaDOSE.Business.Provider.SheetsProvider;
|
||||||
using LaDOSE.Business.Provider.SmashProvider;
|
using LaDOSE.Business.Provider.SmashProvider;
|
||||||
using LaDOSE.Entity.Challonge;
|
using LaDOSE.Entity.Challonge;
|
||||||
using LaDOSE.Entity.Wordpress;
|
using LaDOSE.Entity.Wordpress;
|
||||||
using Result = LaDOSE.Entity.Challonge.Result;
|
using Result = LaDOSE.Entity.Challonge.Result;
|
||||||
using LaDOSE.Entity.BotEvent;
|
using LaDOSE.Entity.BotEvent;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
#if DEBUG
|
||||||
|
using Scalar.AspNetCore;
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace LaDOSE.Api
|
namespace LaDOSE.Api
|
||||||
{
|
{
|
||||||
@@ -48,11 +46,6 @@ namespace LaDOSE.Api
|
|||||||
public void ConfigureServices(IServiceCollection services)
|
public void ConfigureServices(IServiceCollection services)
|
||||||
{
|
{
|
||||||
//Fix Gentoo Issue.
|
//Fix Gentoo Issue.
|
||||||
|
|
||||||
var MySqlServer = this.Configuration["MySql:Server"];
|
|
||||||
var MySqlDatabase = this.Configuration["MySql:Database"];
|
|
||||||
var MySqlUser = this.Configuration["MySql:User"];
|
|
||||||
var MySqlPassword = this.Configuration["MySql:Password"];
|
|
||||||
if (Convert.ToBoolean(this.Configuration["FixGentoo"]))
|
if (Convert.ToBoolean(this.Configuration["FixGentoo"]))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -67,11 +60,25 @@ namespace LaDOSE.Api
|
|||||||
}
|
}
|
||||||
|
|
||||||
services.AddCors();
|
services.AddCors();
|
||||||
services.AddMvc().AddNewtonsoftJson(x =>
|
services.AddMvc(options =>
|
||||||
|
{
|
||||||
|
#if DEBUG
|
||||||
|
// Make the attribute-routed controllers visible to ApiExplorer so the
|
||||||
|
// OpenAPI document is actually populated. See ApiExplorerVisibilityConvention.
|
||||||
|
options.Conventions.Add(new ApiExplorerVisibilityConvention());
|
||||||
|
#endif
|
||||||
|
}).AddNewtonsoftJson(x =>
|
||||||
{
|
{
|
||||||
x.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore;
|
x.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore;
|
||||||
x.SerializerSettings.MaxDepth= 4;
|
// MaxDepth governs *reading*, so it caps how deep an inbound body may nest.
|
||||||
|
// ReferenceLoopHandling above is what tames the outbound Entity graph.
|
||||||
|
// The Sheets export body is 7 deep (root > tabs > tab > rows > row > points >
|
||||||
|
// number), so the previous value of 4 rejected it outright.
|
||||||
|
x.SerializerSettings.MaxDepth = 32;
|
||||||
});
|
});
|
||||||
|
#if DEBUG
|
||||||
|
services.AddOpenApi();
|
||||||
|
#endif
|
||||||
// services.AddDbContextPool<LaDOSEDbContext>( // replace "YourDbContext" with the class name of your DbContext
|
// services.AddDbContextPool<LaDOSEDbContext>( // replace "YourDbContext" with the class name of your DbContext
|
||||||
//
|
//
|
||||||
// options => options.UseMySql($"Server={MySqlServer};Database={MySqlDatabase};User={MySqlUser};Password={MySqlPassword};", // replace with your Connection String
|
// options => options.UseMySql($"Server={MySqlServer};Database={MySqlDatabase};User={MySqlUser};Password={MySqlPassword};", // replace with your Connection String
|
||||||
@@ -82,9 +89,7 @@ namespace LaDOSE.Api
|
|||||||
// ));
|
// ));
|
||||||
services.AddDbContextPool<LaDOSEDbContext>(options =>
|
services.AddDbContextPool<LaDOSEDbContext>(options =>
|
||||||
{
|
{
|
||||||
options.UseMySql(
|
options.UseNpgsql(Configuration.GetConnectionString("DbContext")).ReplaceService<ISqlGenerationHelper,NpgsqlSqlGenerationLowercaseHelper>();
|
||||||
$"Server={MySqlServer};Database={MySqlDatabase};User={MySqlUser};Password={MySqlPassword};",
|
|
||||||
new MariaDbServerVersion(new Version(10, 1)));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
var key = Encoding.ASCII.GetBytes(this.Configuration["JWTTokenSecret"]);
|
var key = Encoding.ASCII.GetBytes(this.Configuration["JWTTokenSecret"]);
|
||||||
@@ -106,6 +111,18 @@ namespace LaDOSE.Api
|
|||||||
{
|
{
|
||||||
// return unauthorized if user no longer exists
|
// return unauthorized if user no longer exists
|
||||||
context.Fail("Unauthorized");
|
context.Fail("Unauthorized");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roles are attached here, from the database, rather than being
|
||||||
|
// signed into the token: a promotion or demotion then applies to
|
||||||
|
// the caller's very next request instead of waiting 16 minutes.
|
||||||
|
if (context.Principal.Identity is ClaimsIdentity identity)
|
||||||
|
{
|
||||||
|
foreach (var role in user.Names())
|
||||||
|
{
|
||||||
|
identity.AddClaim(new Claim(identity.RoleClaimType, role));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@@ -144,6 +161,26 @@ namespace LaDOSE.Api
|
|||||||
cfg.CreateMapTwoWay<Game, LaDOSE.DTO.GameDTO>();
|
cfg.CreateMapTwoWay<Game, LaDOSE.DTO.GameDTO>();
|
||||||
cfg.CreateMapTwoWay<Todo, LaDOSE.DTO.TodoDTO>();
|
cfg.CreateMapTwoWay<Todo, LaDOSE.DTO.TodoDTO>();
|
||||||
|
|
||||||
|
// Match statistics: plain POCO aggregates computed by StatisticsService,
|
||||||
|
// mapped by name (same pattern as TournamentsResult above).
|
||||||
|
cfg.CreateMap<MatchStats, LaDOSE.DTO.MatchStatsDTO>();
|
||||||
|
cfg.CreateMap<MatchCoverage, LaDOSE.DTO.MatchCoverageDTO>();
|
||||||
|
cfg.CreateMap<PlayerMatchStats, LaDOSE.DTO.PlayerMatchStatsDTO>();
|
||||||
|
cfg.CreateMap<HeadToHead, LaDOSE.DTO.HeadToHeadDTO>();
|
||||||
|
cfg.CreateMap<PlayerVersus, LaDOSE.DTO.PlayerVersusDTO>();
|
||||||
|
cfg.CreateMap<VersusGameStats, LaDOSE.DTO.VersusGameStatsDTO>();
|
||||||
|
cfg.CreateMap<PlayerOption, LaDOSE.DTO.PlayerOptionDTO>();
|
||||||
|
|
||||||
|
// Sheets export. Two-way: the request arrives as a DTO and has to become a POCO,
|
||||||
|
// which plain CreateMap does not give. SheetExportRequest.SpreadsheetId has no DTO
|
||||||
|
// counterpart on purpose — SheetsExportService fills it from configuration.
|
||||||
|
cfg.CreateMapTwoWay<SheetExportRequest, LaDOSE.DTO.SheetExportRequestDTO>();
|
||||||
|
cfg.CreateMapTwoWay<SheetTable, LaDOSE.DTO.SheetTableDTO>();
|
||||||
|
cfg.CreateMapTwoWay<SheetRow, LaDOSE.DTO.SheetRowDTO>();
|
||||||
|
cfg.CreateMap<SheetExportResult, LaDOSE.DTO.SheetExportResultDTO>();
|
||||||
|
cfg.CreateMap<SheetTabResult, LaDOSE.DTO.SheetTabResultDTO>();
|
||||||
|
cfg.CreateMap<SheetsConfig, LaDOSE.DTO.SheetsConfigDTO>();
|
||||||
|
|
||||||
});
|
});
|
||||||
IMapper mapper = mapperConfig.CreateMapper();
|
IMapper mapper = mapperConfig.CreateMapper();
|
||||||
services.AddSingleton(mapper);
|
services.AddSingleton(mapper);
|
||||||
@@ -163,6 +200,7 @@ namespace LaDOSE.Api
|
|||||||
services.AddScoped<IBotEventService, BotEventService>();
|
services.AddScoped<IBotEventService, BotEventService>();
|
||||||
|
|
||||||
services.AddScoped<IPlayerService, PlayerService>();
|
services.AddScoped<IPlayerService, PlayerService>();
|
||||||
|
services.AddScoped<IStatisticsService, StatisticsService>();
|
||||||
services.AddTransient<IChallongeProvider>(p => new ChallongeProvider( p.GetRequiredService<IGameService>(),
|
services.AddTransient<IChallongeProvider>(p => new ChallongeProvider( p.GetRequiredService<IGameService>(),
|
||||||
p.GetRequiredService<IEventService>(),
|
p.GetRequiredService<IEventService>(),
|
||||||
p.GetRequiredService<IPlayerService>(),
|
p.GetRequiredService<IPlayerService>(),
|
||||||
@@ -174,11 +212,50 @@ namespace LaDOSE.Api
|
|||||||
this.Configuration["ApiKey:SmashApiKey"]));
|
this.Configuration["ApiKey:SmashApiKey"]));
|
||||||
services.AddScoped<IExternalProviderService, ExternalProviderService>();
|
services.AddScoped<IExternalProviderService, ExternalProviderService>();
|
||||||
|
|
||||||
|
#region Google Sheets export
|
||||||
|
|
||||||
|
// Limits and the target spreadsheet. The spreadsheet is reset every year, so it lives
|
||||||
|
// in configuration rather than in code — see .env.example.
|
||||||
|
services.AddSingleton(new SheetsSettings
|
||||||
|
{
|
||||||
|
SpreadsheetId = this.Configuration["GoogleSheets:SpreadsheetId"],
|
||||||
|
MaxTabs = ReadInt("GoogleSheets:MaxTabs", 60),
|
||||||
|
MaxRowsPerTab = ReadInt("GoogleSheets:MaxRowsPerTab", 5000),
|
||||||
|
MaxColumns = ReadInt("GoogleSheets:MaxColumns", 200)
|
||||||
|
});
|
||||||
|
|
||||||
|
// One writer, chosen by configuration. "Logging" exercises the whole feature without
|
||||||
|
// touching Google; anything unrecognised disables the export with a message rather
|
||||||
|
// than a null reference.
|
||||||
|
services.AddScoped<ISheetsWriter>(p =>
|
||||||
|
{
|
||||||
|
switch (this.Configuration["GoogleSheets:Writer"])
|
||||||
|
{
|
||||||
|
case "ServiceAccount":
|
||||||
|
return new GoogleApiSheetsWriter(
|
||||||
|
this.Configuration["GoogleSheets:ServiceAccount:CredentialsPath"]);
|
||||||
|
case "Logging":
|
||||||
|
return new LoggingSheetsWriter(
|
||||||
|
p.GetRequiredService<ILogger<LoggingSheetsWriter>>());
|
||||||
|
default:
|
||||||
|
return new DisabledSheetsWriter();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddScoped<ISheetsExportService, SheetsExportService>();
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Configuration is all strings; a missing or unparsable value takes the default.</summary>
|
||||||
|
private int ReadInt(string key, int fallback)
|
||||||
|
{
|
||||||
|
return int.TryParse(this.Configuration[key], out var value) && value > 0 ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
|
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
|
||||||
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
|
public void Configure(IApplicationBuilder app, IHostEnvironment env, ILoggerFactory loggerFactory)
|
||||||
{
|
{
|
||||||
//loggerFactory.AddConsole(Configuration.GetSection("Logging"));
|
//loggerFactory.AddConsole(Configuration.GetSection("Logging"));
|
||||||
//loggerFactory.AddDebug();
|
//loggerFactory.AddDebug();
|
||||||
@@ -199,7 +276,17 @@ namespace LaDOSE.Api
|
|||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseEndpoints(x => x.MapControllers());
|
app.UseEndpoints(x =>
|
||||||
|
{
|
||||||
|
x.MapControllers();
|
||||||
|
#if DEBUG
|
||||||
|
if (env.IsDevelopment())
|
||||||
|
{
|
||||||
|
x.MapOpenApi();
|
||||||
|
x.MapScalarApiReference();
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Warning"
|
"Default": "Warning",
|
||||||
|
"LaDOSE": "Information"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DbContext":"Host=kafka.local;Username=tom;Password=tom;Database=ladoseapi"
|
||||||
|
},
|
||||||
"CertificateSettings": {
|
"CertificateSettings": {
|
||||||
"fileName": "localhost.pfx",
|
"fileName": "localhost.pfx",
|
||||||
"password": "YourSecurePassword"
|
"password": "YourSecurePassword"
|
||||||
},
|
},
|
||||||
"MySql": {
|
|
||||||
"Server": "host",
|
|
||||||
"Database": "database",
|
|
||||||
"User": "User",
|
|
||||||
"Password": "Password"
|
|
||||||
},
|
|
||||||
"ApiKey": {
|
"ApiKey": {
|
||||||
"ChallongeApiKey": "Challonge ApiKey"
|
"ChallongeApiKey": "Challonge ApiKey",
|
||||||
|
"SmashApiKey": "SmashApiKey"
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"GoogleSheets": {
|
||||||
|
"Writer": "ServiceAccount",
|
||||||
|
"SpreadsheetId": "1FMS3ZesZC7yBNsJG3CpB5c8qzF95JHUpnsj2q45BSs4",
|
||||||
|
"MaxTabs": 60,
|
||||||
|
"MaxRowsPerTab": 5000,
|
||||||
|
"MaxColumns": 200,
|
||||||
|
"ServiceAccount": {
|
||||||
|
"CredentialsPath": "/home/tom/test-agent/LaDOSE/secrets/test.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "0.0.0.0",
|
||||||
"Port": 5000,
|
"Port": 5000,
|
||||||
"JWTTokenSecret": "here goes the custom Secret key for authnetication"
|
"JWTTokenSecret": "here goes the custom Secret key for authnetication"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace LaDOSE.DTO
|
namespace LaDOSE.DTO
|
||||||
{
|
{
|
||||||
@@ -9,8 +10,13 @@ namespace LaDOSE.DTO
|
|||||||
public string FirstName { get; set; }
|
public string FirstName { get; set; }
|
||||||
public string LastName { get; set; }
|
public string LastName { get; set; }
|
||||||
public string Username { get; set; }
|
public string Username { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Only ever read from a request; never populated on a response.</summary>
|
||||||
public string Password { get; set; }
|
public string Password { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Role names held by the user, e.g. <c>["Admin"]</c>. Empty means a plain user.</summary>
|
||||||
|
public List<string> Roles { get; set; }
|
||||||
|
|
||||||
public string Token { get; set; }
|
public string Token { get; set; }
|
||||||
public DateTime Expire { get; set; }
|
public DateTime Expire { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
namespace LaDOSE.DTO
|
using System;
|
||||||
|
|
||||||
|
namespace LaDOSE.DTO
|
||||||
{
|
{
|
||||||
public class EventDTO
|
public class EventDTO
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Event date, mapped by convention from Event.Date. Used for time-series charts.</summary>
|
||||||
|
public DateTime Date { get; set; }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Platforms>AnyCPU;x64</Platforms>
|
<Platforms>AnyCPU;x64</Platforms>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LaDOSE.DTO
|
||||||
|
{
|
||||||
|
public class MatchStatsDTO
|
||||||
|
{
|
||||||
|
public MatchCoverageDTO Coverage { get; set; }
|
||||||
|
public List<PlayerMatchStatsDTO> Players { get; set; }
|
||||||
|
public List<HeadToHeadDTO> HeadToHead { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MatchCoverageDTO
|
||||||
|
{
|
||||||
|
/// <summary>Requested event ids that actually exist.</summary>
|
||||||
|
public int Events { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Tournaments (brackets) belonging to those events.</summary>
|
||||||
|
public int Brackets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Of those brackets, how many have at least one set row.</summary>
|
||||||
|
public int BracketsWithSets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Total set rows in scope, including unusable ones.</summary>
|
||||||
|
public int Sets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Sets with a determinable winner.</summary>
|
||||||
|
public int DecidedSets { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlayerMatchStatsDTO
|
||||||
|
{
|
||||||
|
public int PlayerId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gamertag, falling back to Name, else "#<id>".</summary>
|
||||||
|
public string Player { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Decided sets only. Always equals Wins + Losses.</summary>
|
||||||
|
public int Sets { get; set; }
|
||||||
|
|
||||||
|
public int Wins { get; set; }
|
||||||
|
public int Losses { get; set; }
|
||||||
|
public int GamesWon { get; set; }
|
||||||
|
public int GamesLost { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class HeadToHeadDTO
|
||||||
|
{
|
||||||
|
public int PlayerAId { get; set; }
|
||||||
|
public string PlayerA { get; set; }
|
||||||
|
public int PlayerBId { get; set; }
|
||||||
|
public string PlayerB { get; set; }
|
||||||
|
public int WinsA { get; set; }
|
||||||
|
public int WinsB { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LaDOSE.DTO
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Every recorded meeting between two players, broken down per game.
|
||||||
|
/// Sets belonging to a bracket with no game attached are not in <see cref="Games"/>
|
||||||
|
/// nor in the totals — they are only counted in <see cref="UnknownGameSets"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class PlayerVersusDTO
|
||||||
|
{
|
||||||
|
public int PlayerAId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gamertag, falling back to Name, else "#<id>".</summary>
|
||||||
|
public string PlayerA { get; set; }
|
||||||
|
|
||||||
|
public int PlayerBId { get; set; }
|
||||||
|
|
||||||
|
public string PlayerB { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Meetings in a bracket whose game is known — the sum of the rows in <see cref="Games"/>.</summary>
|
||||||
|
public int Sets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Of those, the ones with a determinable winner.</summary>
|
||||||
|
public int DecidedSets { get; set; }
|
||||||
|
|
||||||
|
public int WinsA { get; set; }
|
||||||
|
public int WinsB { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Meetings dropped because the bracket has no game attached.</summary>
|
||||||
|
public int UnknownGameSets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>One row per game they met in, most-played first.</summary>
|
||||||
|
public List<VersusGameStatsDTO> Games { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One game's slice of a <see cref="PlayerVersusDTO"/>.</summary>
|
||||||
|
public class VersusGameStatsDTO
|
||||||
|
{
|
||||||
|
public int GameId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Game name, or "#<id>" when the game row is gone.</summary>
|
||||||
|
public string Game { get; set; }
|
||||||
|
|
||||||
|
public string GameLongName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Meetings in this game, decided or not.</summary>
|
||||||
|
public int Sets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Meetings with a determinable winner. Always equals WinsA + WinsB.</summary>
|
||||||
|
public int DecidedSets { get; set; }
|
||||||
|
|
||||||
|
public int WinsA { get; set; }
|
||||||
|
public int WinsB { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Individual games won inside the decided sets.</summary>
|
||||||
|
public int GamesWonA { get; set; }
|
||||||
|
|
||||||
|
public int GamesWonB { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A player who can be picked for a versus lookup: one with at least one set in a
|
||||||
|
/// bracket whose game is known.
|
||||||
|
/// </summary>
|
||||||
|
public class PlayerOptionDTO
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gamertag, falling back to Name, else "#<id>".</summary>
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Sets in a bracket with a known game, whoever the opponent was.</summary>
|
||||||
|
public int Sets { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LaDOSE.DTO
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Body of POST /api/Sheets/Export. The target spreadsheet is deliberately absent: it comes
|
||||||
|
/// from server configuration only, so a caller cannot redirect the export.
|
||||||
|
/// </summary>
|
||||||
|
public class SheetExportRequestDTO
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One tab per selected event, oldest first. Tab N holds events 1..N merged, so the last
|
||||||
|
/// tab is the full cumulative ranking for the selection.
|
||||||
|
/// </summary>
|
||||||
|
public List<SheetTableDTO> Tabs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One tab: a players x games grid with a Total column, as built by buildRanking().</summary>
|
||||||
|
public class SheetTableDTO
|
||||||
|
{
|
||||||
|
/// <summary>Desired tab title, e.g. "Ranking #1301". Sanitised server-side.</summary>
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Provenance and logging only; tabs are addressed by title.</summary>
|
||||||
|
public int EventId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>["Players", <game names...>, "Total"].</summary>
|
||||||
|
public List<string> Header { get; set; }
|
||||||
|
|
||||||
|
public List<SheetRowDTO> Rows { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Provenance lines written below the grid.</summary>
|
||||||
|
public List<string> Footer { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SheetRowDTO
|
||||||
|
{
|
||||||
|
public string Player { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Index-aligned with Header[1..^1]. Typed int, not string, so the values land in the
|
||||||
|
/// sheet as numbers and existing formulas and conditional formatting keep working.
|
||||||
|
/// </summary>
|
||||||
|
public List<int> Points { get; set; }
|
||||||
|
|
||||||
|
public int Total { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SheetExportResultDTO
|
||||||
|
{
|
||||||
|
public string SpreadsheetId { get; set; }
|
||||||
|
public string SpreadsheetUrl { get; set; }
|
||||||
|
public string Writer { get; set; }
|
||||||
|
public List<SheetTabResultDTO> Tabs { get; set; }
|
||||||
|
public List<string> Warnings { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SheetTabResultDTO
|
||||||
|
{
|
||||||
|
public string RequestedName { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public int Rows { get; set; }
|
||||||
|
public int Columns { get; set; }
|
||||||
|
public bool Created { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response of GET /api/Sheets/Config. Never carries the credentials path or any secret —
|
||||||
|
/// only what the export panel needs to render itself.
|
||||||
|
/// </summary>
|
||||||
|
public class SheetsConfigDTO
|
||||||
|
{
|
||||||
|
/// <summary>"ServiceAccount" | "Logging" | "Disabled".</summary>
|
||||||
|
public string Writer { get; set; }
|
||||||
|
|
||||||
|
public bool Configured { get; set; }
|
||||||
|
public string SpreadsheetId { get; set; }
|
||||||
|
public int MaxTabs { get; set; }
|
||||||
|
public int MaxRowsPerTab { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ namespace LaDOSE.DTO
|
|||||||
public List<GameDTO> Games { get; set; }
|
public List<GameDTO> Games { get; set; }
|
||||||
|
|
||||||
public List<ResultDTO> Results { get; set; }
|
public List<ResultDTO> Results { get; set; }
|
||||||
|
|
||||||
|
public string Slug { get; set; }
|
||||||
}
|
}
|
||||||
public class ResultDTO
|
public class ResultDTO
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="LaDOSE.DesktopApp.Avalonia.App"
|
||||||
|
xmlns:local="using:LaDOSE.DesktopApp.Avalonia"
|
||||||
|
RequestedThemeVariant="Dark">
|
||||||
|
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
|
||||||
|
<Application.Styles>
|
||||||
|
<FluentTheme />
|
||||||
|
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
|
||||||
|
</Application.Styles>
|
||||||
|
</Application>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using LaDOSE.DesktopApp.Avalonia.ViewModels;
|
||||||
|
using LaDOSE.DesktopApp.Avalonia.Views;
|
||||||
|
using LaDOSE.REST;
|
||||||
|
using MsBox.Avalonia;
|
||||||
|
using ReactiveUI;
|
||||||
|
using Splat;
|
||||||
|
|
||||||
|
namespace LaDOSE.DesktopApp.Avalonia;
|
||||||
|
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public override void Initialize()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
Locator.CurrentMutable.Register(() => new GamesView(), typeof(IViewFor<GamesViewModel>));
|
||||||
|
Locator.CurrentMutable.Register(() => new InfoView(), typeof(IViewFor<InfoViewModel>));
|
||||||
|
Locator.CurrentMutable.Register(() => new TournamentResultView(), typeof(IViewFor<TournamentResultViewModel>));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnFrameworkInitializationCompleted()
|
||||||
|
{
|
||||||
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
desktop.MainWindow = new MainWindow
|
||||||
|
{
|
||||||
|
DataContext = new MainWindowViewModel(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
<LangVersion>12</LangVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Models\"/>
|
||||||
|
<AvaloniaResource Include="Assets\**"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" Version="11.2.3" />
|
||||||
|
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.3" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.3" />
|
||||||
|
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||||
|
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.2.3" />
|
||||||
|
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.3" />
|
||||||
|
<PackageReference Include="MessageBox.Avalonia" Version="3.2.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LaDOSE.DTO\LaDOSE.DTO.csproj" />
|
||||||
|
<ProjectReference Include="..\LaDOSE.REST\LaDOSE.REST.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="settings.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.ReactiveUI;
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.IO;
|
||||||
|
using LaDOSE.REST;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using MsBox.Avalonia;
|
||||||
|
using MsBox.Avalonia.Enums;
|
||||||
|
using Splat;
|
||||||
|
// using Xilium.CefGlue;
|
||||||
|
// using Xilium.CefGlue.Common;
|
||||||
|
// using Avalonia.Visuals;
|
||||||
|
namespace LaDOSE.DesktopApp.Avalonia;
|
||||||
|
|
||||||
|
sealed class Program
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||||
|
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||||
|
// yet and stuff might break.
|
||||||
|
[STAThread]
|
||||||
|
public static void Main(string[] args)
|
||||||
|
{
|
||||||
|
RegisterDependencies(Locator.CurrentMutable, Locator.Current);
|
||||||
|
|
||||||
|
var app = BuildAvaloniaApp();
|
||||||
|
app.StartWithClassicDesktopLifetime(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RegisterDependencies(IMutableDependencyResolver currentMutable, IReadonlyDependencyResolver current)
|
||||||
|
{
|
||||||
|
|
||||||
|
var builder = new ConfigurationBuilder()
|
||||||
|
.AddJsonFile("settings.json", optional: true, reloadOnChange: true).Build();
|
||||||
|
var restUrl = builder["REST:Url"].ToString();
|
||||||
|
var restUser = builder["REST:User"].ToString();
|
||||||
|
var restPassword = builder["REST:Password"].ToString();
|
||||||
|
|
||||||
|
currentMutable.Register<RestService>(() =>
|
||||||
|
{
|
||||||
|
var restService = new RestService(new Uri(restUrl), restUser, restPassword);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
restService.Connect(new Uri(restUrl), restUser, restPassword);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Console.WriteLine(e);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return restService;
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avalonia configuration, don't remove; also used by visual designer.
|
||||||
|
public static AppBuilder BuildAvaloniaApp()
|
||||||
|
=> AppBuilder
|
||||||
|
.Configure<App>()
|
||||||
|
.UsePlatformDetect()
|
||||||
|
.WithInterFont()
|
||||||
|
.LogToTrace()
|
||||||
|
.UseReactiveUI();
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
@@ -0,0 +1,343 @@
|
|||||||
|
body {
|
||||||
|
color: #efefef;
|
||||||
|
background-color: #141415;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--breakpoint-xs: 0;
|
||||||
|
--breakpoint-sm: 576px;
|
||||||
|
--breakpoint-md: 768px;
|
||||||
|
--breakpoint-lg: 992px;
|
||||||
|
--breakpoint-xl: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
vertical-align: top;
|
||||||
|
border-top: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table thead th {
|
||||||
|
vertical-align: bottom;
|
||||||
|
border-bottom: 2px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody + tbody {
|
||||||
|
border-top: 2px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .table {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-sm th,
|
||||||
|
.table-sm td {
|
||||||
|
padding: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-bordered {
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-bordered th,
|
||||||
|
.table-bordered td {
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-bordered thead th,
|
||||||
|
.table-bordered thead td {
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-borderless th,
|
||||||
|
.table-borderless td,
|
||||||
|
.table-borderless thead th,
|
||||||
|
.table-borderless tbody + tbody {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-striped tbody tr:nth-of-type(odd) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover tbody tr:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.075);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-primary,
|
||||||
|
.table-primary > th,
|
||||||
|
.table-primary > td {
|
||||||
|
background-color: #b8daff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover .table-primary:hover {
|
||||||
|
background-color: #9fcdff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover .table-primary:hover > td,
|
||||||
|
.table-hover .table-primary:hover > th {
|
||||||
|
background-color: #9fcdff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-secondary,
|
||||||
|
.table-secondary > th,
|
||||||
|
.table-secondary > td {
|
||||||
|
background-color: #d6d8db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover .table-secondary:hover {
|
||||||
|
background-color: #c8cbcf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover .table-secondary:hover > td,
|
||||||
|
.table-hover .table-secondary:hover > th {
|
||||||
|
background-color: #c8cbcf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-success,
|
||||||
|
.table-success > th,
|
||||||
|
.table-success > td {
|
||||||
|
background-color: #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover .table-success:hover {
|
||||||
|
background-color: #b1dfbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover .table-success:hover > td,
|
||||||
|
.table-hover .table-success:hover > th {
|
||||||
|
background-color: #b1dfbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-info,
|
||||||
|
.table-info > th,
|
||||||
|
.table-info > td {
|
||||||
|
background-color: #bee5eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover .table-info:hover {
|
||||||
|
background-color: #abdde5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover .table-info:hover > td,
|
||||||
|
.table-hover .table-info:hover > th {
|
||||||
|
background-color: #abdde5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-warning,
|
||||||
|
.table-warning > th,
|
||||||
|
.table-warning > td {
|
||||||
|
background-color: #ffeeba;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover .table-warning:hover {
|
||||||
|
background-color: #ffe8a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover .table-warning:hover > td,
|
||||||
|
.table-hover .table-warning:hover > th {
|
||||||
|
background-color: #ffe8a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-danger,
|
||||||
|
.table-danger > th,
|
||||||
|
.table-danger > td {
|
||||||
|
background-color: #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover .table-danger:hover {
|
||||||
|
background-color: #f1b0b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover .table-danger:hover > td,
|
||||||
|
.table-hover .table-danger:hover > th {
|
||||||
|
background-color: #f1b0b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-light,
|
||||||
|
.table-light > th,
|
||||||
|
.table-light > td {
|
||||||
|
background-color: #fdfdfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover .table-light:hover {
|
||||||
|
background-color: #ececf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover .table-light:hover > td,
|
||||||
|
.table-hover .table-light:hover > th {
|
||||||
|
background-color: #ececf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-dark,
|
||||||
|
.table-dark > th,
|
||||||
|
.table-dark > td {
|
||||||
|
background-color: #c6c8ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover .table-dark:hover {
|
||||||
|
background-color: #b9bbbe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover .table-dark:hover > td,
|
||||||
|
.table-hover .table-dark:hover > th {
|
||||||
|
background-color: #b9bbbe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-active,
|
||||||
|
.table-active > th,
|
||||||
|
.table-active > td {
|
||||||
|
background-color: rgba(0, 0, 0, 0.075);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover .table-active:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.075);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover .table-active:hover > td,
|
||||||
|
.table-hover .table-active:hover > th {
|
||||||
|
background-color: rgba(0, 0, 0, 0.075);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .thead-dark th {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #212529;
|
||||||
|
border-color: #32383e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .thead-light th {
|
||||||
|
color: #495057;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
border-color: #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-dark {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-dark th,
|
||||||
|
.table-dark td,
|
||||||
|
.table-dark thead th {
|
||||||
|
border-color: #32383e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-dark.table-bordered {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-dark.table-striped tbody tr:nth-of-type(odd) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-dark.table-hover tbody tr:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.075);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
.table-responsive-sm {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
-ms-overflow-style: -ms-autohiding-scrollbar;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive-sm > .table-bordered {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.table-responsive-md {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
-ms-overflow-style: -ms-autohiding-scrollbar;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive-md > .table-bordered {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.table-responsive-lg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
-ms-overflow-style: -ms-autohiding-scrollbar;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive-lg > .table-bordered {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1199.98px) {
|
||||||
|
.table-responsive-xl {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
-ms-overflow-style: -ms-autohiding-scrollbar;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive-xl > .table-bordered {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
-ms-overflow-style: -ms-autohiding-scrollbar;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive > .table-bordered {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
border-collapse: collapse !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td,
|
||||||
|
.table th {
|
||||||
|
/*background-color: #fff !important;*/
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-bordered th,
|
||||||
|
.table-bordered td {
|
||||||
|
border: 1px solid #dee2e6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-dark {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #212529
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-dark th,
|
||||||
|
.table-dark td,
|
||||||
|
.table-dark thead th,
|
||||||
|
.table-dark tbody + tbody {
|
||||||
|
border-color: #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .thead-dark th {
|
||||||
|
color: inherit;
|
||||||
|
border-color: #dee2e6;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using ReactiveUI;
|
||||||
|
|
||||||
|
namespace LaDOSE.DesktopApp.Avalonia.Utils;
|
||||||
|
|
||||||
|
public abstract class BaseViewModel : ReactiveObject, IRoutableViewModel,INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
protected void RaisePropertyChanged([CallerMemberName] string? propertyName = null)
|
||||||
|
{
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected BaseViewModel(IScreen hostScreen, string? urlPathSegment)
|
||||||
|
{
|
||||||
|
UrlPathSegment = urlPathSegment;
|
||||||
|
HostScreen = hostScreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? UrlPathSegment { get; }
|
||||||
|
public IScreen HostScreen { get; }
|
||||||
|
}
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace LaDOSE.DesktopApp.Utils
|
namespace LaDOSE.DesktopApp.Avalonia.Utils
|
||||||
{
|
{
|
||||||
public static class CustomListExtension
|
public static class CustomListExtension
|
||||||
{
|
{
|
||||||
@@ -14,14 +14,14 @@ namespace LaDOSE.DesktopApp.Utils
|
|||||||
_compare = c;
|
_compare = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Equals(T x, T y)
|
public bool Equals(T? x, T? y)
|
||||||
{
|
{
|
||||||
return _compare(x, y);
|
return _compare(x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
public int GetHashCode(T obj)
|
public int GetHashCode(T obj)
|
||||||
{
|
{
|
||||||
return 0;
|
return obj.GetHashCode();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using LaDOSE.DesktopApp.Avalonia.Utils;
|
||||||
|
using LaDOSE.DesktopApp.Avalonia.ViewModels;
|
||||||
|
using LaDOSE.DTO;
|
||||||
|
using LaDOSE.REST;
|
||||||
|
using ReactiveUI;
|
||||||
|
using Splat;
|
||||||
|
|
||||||
|
namespace LaDOSE.DesktopApp.Avalonia.ViewModels
|
||||||
|
{
|
||||||
|
public class GamesViewModel : BaseViewModel
|
||||||
|
{
|
||||||
|
|
||||||
|
public string DisplayName => "Games";
|
||||||
|
|
||||||
|
private GameDTO _currentGame;
|
||||||
|
private List<GameDTO> _games;
|
||||||
|
private List<GameDTO> _Searchgames;
|
||||||
|
private RestService RestService { get; set; }
|
||||||
|
public GamesViewModel(IScreen screen): base(screen,"Games")
|
||||||
|
{
|
||||||
|
this.RestService = Locator.Current.GetService<RestService>();
|
||||||
|
this.Games=new List<GameDTO>();
|
||||||
|
OnInitialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void OnInitialize()
|
||||||
|
{
|
||||||
|
LoadGames();
|
||||||
|
this.CurrentGame = Games.First();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void LoadGames()
|
||||||
|
{
|
||||||
|
var gameDtos = this.RestService.GetGames().OrderBy(e=>e.Order).ToList();
|
||||||
|
this.Games = gameDtos;
|
||||||
|
RaisePropertyChanged(nameof(this.Games));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<GameDTO> Games
|
||||||
|
{
|
||||||
|
get => _games;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_games = value;
|
||||||
|
RaisePropertyChanged(nameof(this.Games));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<GameDTO> SearchGame
|
||||||
|
{
|
||||||
|
get => _Searchgames;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_Searchgames = value;
|
||||||
|
RaisePropertyChanged(nameof(this.SearchGame));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public GameDTO CurrentGame
|
||||||
|
{
|
||||||
|
get => _currentGame;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_currentGame = value;
|
||||||
|
RaisePropertyChanged(nameof(this.CurrentGame));
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Update()
|
||||||
|
{
|
||||||
|
this.RestService.UpdateGame(this.CurrentGame);
|
||||||
|
LoadGames();
|
||||||
|
|
||||||
|
}
|
||||||
|
public void AddGame()
|
||||||
|
{
|
||||||
|
var item = new GameDTO();
|
||||||
|
this.RestService.UpdateGame(item);
|
||||||
|
LoadGames();
|
||||||
|
}
|
||||||
|
public void DeleteGame()
|
||||||
|
{
|
||||||
|
|
||||||
|
this.RestService.DeleteGame(this.CurrentGame.Id);
|
||||||
|
LoadGames();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void GetGame()
|
||||||
|
{
|
||||||
|
SearchGame = this.RestService.GetSmashGames(this.CurrentGame.LongName);
|
||||||
|
}
|
||||||
|
public bool CanDeleteGame => CurrentGame != null;
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using ReactiveUI;
|
||||||
|
|
||||||
|
namespace LaDOSE.DesktopApp.Avalonia.ViewModels;
|
||||||
|
|
||||||
|
public class InfoViewModel: ReactiveObject, IRoutableViewModel,INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
|
||||||
|
public InfoViewModel(IScreen screen)
|
||||||
|
{
|
||||||
|
HostScreen = screen;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? UrlPathSegment => "Info";
|
||||||
|
public IScreen HostScreen { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
|
||||||
|
namespace LaDOSE.DesktopApp.Avalonia.ViewModels;
|
||||||
|
|
||||||
|
public class MainWindowViewModel : Window
|
||||||
|
{
|
||||||
|
public RoutedViewHostViewModel RoutedViewViewHost { get; } = new();
|
||||||
|
|
||||||
|
public void CloseApp()
|
||||||
|
{
|
||||||
|
if (Application.Current != null && Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime)
|
||||||
|
((Application.Current.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)!).Shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using LaDOSE.DesktopApp.Avalonia.Utils;
|
||||||
|
using ReactiveUI;
|
||||||
|
|
||||||
|
namespace LaDOSE.DesktopApp.Avalonia.ViewModels;
|
||||||
|
|
||||||
|
public class RoutedViewHostViewModel : ReactiveObject, IScreen, INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
protected void RaisePropertyChanged([CallerMemberName] string? propertyName = null)
|
||||||
|
{
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string _current;
|
||||||
|
|
||||||
|
public RoutedViewHostViewModel()
|
||||||
|
{
|
||||||
|
Games = new GamesViewModel(this);
|
||||||
|
Info = new InfoViewModel(this);
|
||||||
|
Tournament = new TournamentResultViewModel(this);
|
||||||
|
Router.Navigate.Execute(Tournament);
|
||||||
|
Current = "Tournament";
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Current
|
||||||
|
{
|
||||||
|
get => _current;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_current = value;
|
||||||
|
RaisePropertyChanged(nameof(Current));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public RoutingState Router { get; } = new();
|
||||||
|
public GamesViewModel Games { get; }
|
||||||
|
public InfoViewModel Info { get; }
|
||||||
|
|
||||||
|
public TournamentResultViewModel Tournament { get; }
|
||||||
|
|
||||||
|
|
||||||
|
public void ShowGames()
|
||||||
|
{
|
||||||
|
Router.Navigate.Execute(Games);
|
||||||
|
Current = "Games";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowInfo()
|
||||||
|
{
|
||||||
|
Router.Navigate.Execute(Info);
|
||||||
|
Current = "Info";
|
||||||
|
}
|
||||||
|
public void ShowTournament()
|
||||||
|
{
|
||||||
|
Router.Navigate.Execute(Tournament);
|
||||||
|
Current = "Tournament";
|
||||||
|
}
|
||||||
|
}
|
||||||
+151
-142
@@ -1,5 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Data;
|
using System.Data;
|
||||||
@@ -8,70 +7,64 @@ using System.Linq;
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Windows.Controls;
|
using Avalonia.Collections;
|
||||||
using System.Windows.Forms;
|
using Avalonia.Controls;
|
||||||
using LaDOSE.DesktopApp.Utils;
|
using LaDOSE.DesktopApp.Avalonia.Utils;
|
||||||
using LaDOSE.DTO;
|
using LaDOSE.DTO;
|
||||||
using LaDOSE.REST;
|
using LaDOSE.REST;
|
||||||
using SaveFileDialog = Microsoft.Win32.SaveFileDialog;
|
using ReactiveUI;
|
||||||
using Screen = Caliburn.Micro.Screen;
|
using Splat;
|
||||||
|
|
||||||
namespace LaDOSE.DesktopApp.ViewModels
|
namespace LaDOSE.DesktopApp.Avalonia.ViewModels
|
||||||
{
|
{
|
||||||
public class TournamentResultViewModel : Screen
|
public class TournamentResultViewModel : BaseViewModel
|
||||||
{
|
{
|
||||||
public override string DisplayName => "Tournament Result";
|
public string DisplayName => "Tournament Result";
|
||||||
|
|
||||||
private RestService RestService { get; set; }
|
private RestService? RestService { get; set; }
|
||||||
//Dictionary<string, Dictionary<int, int>> _computedResult;
|
//Dictionary<string, Dictionary<int, int>> _computedResult;
|
||||||
|
|
||||||
#region Properties
|
#region Properties
|
||||||
|
|
||||||
private string css = string.Empty;
|
private string css = string.Empty;
|
||||||
|
|
||||||
//"strong { font-weight: 700;} ". +
|
private string? _selectRegex;
|
||||||
// "a { color: #ff9024;}"+
|
|
||||||
// "body { color: #efefef;background-color: #141415; }" +
|
|
||||||
// ""+
|
|
||||||
// "a:hover, .entry-meta span a:hover, .comments-link a:hover, body.coldisplay2 #front-columns a:active {color: #cb5920;}"+
|
|
||||||
// "tr td { border: 1px dashed #3D3D3D;} ";
|
|
||||||
private String _selectRegex;
|
|
||||||
|
|
||||||
public String SelectRegex
|
public string? SelectRegex
|
||||||
{
|
{
|
||||||
get { return _selectRegex; }
|
get { return _selectRegex; }
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_selectRegex = value;
|
_selectRegex = value;
|
||||||
NotifyOfPropertyChange(() => SelectRegex);
|
RaisePropertyChanged(nameof(SelectRegex));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String _selectEventRegex;
|
private string? _selectEventRegex;
|
||||||
|
|
||||||
public String SelectEventRegex
|
public string? SelectEventRegex
|
||||||
{
|
{
|
||||||
get { return _selectEventRegex; }
|
get { return _selectEventRegex; }
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_selectEventRegex = value;
|
_selectEventRegex = value;
|
||||||
NotifyOfPropertyChange(() => SelectEventRegex);
|
RaisePropertyChanged(nameof(SelectEventRegex));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private string _slug;
|
private string? _slug;
|
||||||
public String Slug
|
public string? Slug
|
||||||
{
|
{
|
||||||
get { return _slug; }
|
get { return _slug; }
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_slug = value;
|
_slug = value;
|
||||||
NotifyOfPropertyChange(() => Slug);
|
RaisePropertyChanged(nameof(Slug));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String _html;
|
private string? _html;
|
||||||
|
|
||||||
public String Html
|
public string? Html
|
||||||
{
|
{
|
||||||
get { return $"<html><head><style>{this.css}</style></head><body>{HtmlContent}</body></html>"; }
|
get { return $"<html><head><style>{this.css}</style></head><body>{HtmlContent}</body></html>"; }
|
||||||
set
|
set
|
||||||
@@ -79,58 +72,58 @@ namespace LaDOSE.DesktopApp.ViewModels
|
|||||||
_html = value;
|
_html = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private String _htmlContent;
|
private string? _htmlContent;
|
||||||
|
|
||||||
public String HtmlContent
|
public string? HtmlContent
|
||||||
{
|
{
|
||||||
get { return _htmlContent; }
|
get { return _htmlContent; }
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_htmlContent = value;
|
_htmlContent = value;
|
||||||
NotifyOfPropertyChange(() => HtmlContent);
|
RaisePropertyChanged(nameof(HtmlContent));
|
||||||
NotifyOfPropertyChange(() => Html);
|
RaisePropertyChanged(nameof(Html));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private DateTime _from;
|
private DateTimeOffset _from;
|
||||||
|
|
||||||
public DateTime From
|
public DateTimeOffset From
|
||||||
{
|
{
|
||||||
get { return _from; }
|
get { return _from; }
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_from = value;
|
_from = value;
|
||||||
NotifyOfPropertyChange(() => From);
|
RaisePropertyChanged(nameof(From));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private DateTime _to;
|
private DateTimeOffset _to;
|
||||||
|
|
||||||
public DateTime To
|
public DateTimeOffset To
|
||||||
{
|
{
|
||||||
get { return _to; }
|
get { return _to; }
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_to = value;
|
_to = value;
|
||||||
NotifyOfPropertyChange(() => To);
|
RaisePropertyChanged(nameof(To));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private TournamentsResultDTO _results;
|
private TournamentsResultDTO? _results;
|
||||||
public List<TournamentDTO> Tournaments { get; set; }
|
public List<TournamentDTO> Tournaments { get; set; }
|
||||||
|
|
||||||
public List<EventDTO> Events { get; set; }
|
public List<EventDTO> Events { get; set; }
|
||||||
|
|
||||||
public TournamentsResultDTO Results
|
public TournamentsResultDTO? Results
|
||||||
{
|
{
|
||||||
get => _results;
|
get => _results;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_results = value;
|
_results = value;
|
||||||
NotifyOfPropertyChange(() => Results);
|
RaisePropertyChanged(nameof(Results));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +135,7 @@ namespace LaDOSE.DesktopApp.ViewModels
|
|||||||
set
|
set
|
||||||
{
|
{
|
||||||
_selectedEvents = value;
|
_selectedEvents = value;
|
||||||
NotifyOfPropertyChange(() => SelectedEvents);
|
RaisePropertyChanged(nameof(SelectedEvents));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,14 +147,14 @@ namespace LaDOSE.DesktopApp.ViewModels
|
|||||||
set
|
set
|
||||||
{
|
{
|
||||||
_selectedTournaments = value;
|
_selectedTournaments = value;
|
||||||
NotifyOfPropertyChange(() => SelectedTournaments);
|
RaisePropertyChanged(nameof(SelectedTournaments));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private GameDTO _selectedGame;
|
private GameDTO? _selectedGame;
|
||||||
|
|
||||||
|
|
||||||
public GameDTO SelectedGame
|
public GameDTO? SelectedGame
|
||||||
{
|
{
|
||||||
get { return _selectedGame; }
|
get { return _selectedGame; }
|
||||||
set
|
set
|
||||||
@@ -175,136 +168,151 @@ namespace LaDOSE.DesktopApp.ViewModels
|
|||||||
SelectedGameResult = new ObservableCollection<ResultDTO>(resultForGame);
|
SelectedGameResult = new ObservableCollection<ResultDTO>(resultForGame);
|
||||||
}
|
}
|
||||||
|
|
||||||
NotifyOfPropertyChange(() => SelectedGame);
|
RaisePropertyChanged(nameof(SelectedGame));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ObservableCollection<ResultDTO> _selectedGameResult;
|
private ObservableCollection<ResultDTO>? _selectedGameResult;
|
||||||
|
|
||||||
public ObservableCollection<ResultDTO> SelectedGameResult
|
public ObservableCollection<ResultDTO>? SelectedGameResult
|
||||||
{
|
{
|
||||||
get { return _selectedGameResult; }
|
get { return _selectedGameResult; }
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_selectedGameResult = value;
|
_selectedGameResult = value;
|
||||||
NotifyOfPropertyChange(() => SelectedGameResult);
|
RaisePropertyChanged(nameof(SelectedGameResult));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String _first;
|
private string? _first;
|
||||||
private DataTable _gridDataTable;
|
private DataTable? _gridDataTable;
|
||||||
|
private string? _error;
|
||||||
|
|
||||||
public String First
|
public string? First
|
||||||
{
|
{
|
||||||
get { return _first; }
|
get { return _first; }
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_first = value;
|
_first = value;
|
||||||
NotifyOfPropertyChange(() => First);
|
RaisePropertyChanged(nameof(First));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
public TournamentResultViewModel(RestService restService)
|
public TournamentResultViewModel(IScreen hostScreen):base(hostScreen,"Tournament")
|
||||||
{
|
{
|
||||||
this.RestService = restService;
|
this.RestService = Locator.Current.GetService<RestService>();;
|
||||||
_selectedTournaments = new ObservableCollection<TournamentDTO>();
|
_selectedTournaments = new ObservableCollection<TournamentDTO>();
|
||||||
_selectedEvents = new ObservableCollection<EventDTO>();
|
_selectedEvents = new ObservableCollection<EventDTO>();
|
||||||
Tournaments = new List<TournamentDTO>();
|
Tournaments = new List<TournamentDTO>();
|
||||||
Events = new List<EventDTO>();
|
Events = new List<EventDTO>();
|
||||||
|
OnInitialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
protected override void OnInitialize()
|
protected void OnInitialize()
|
||||||
{
|
{
|
||||||
var manifestResourceStream = Assembly.GetExecutingAssembly().GetManifestResourceStream("LaDOSE.DesktopApp.Resources.css.css");
|
// var manifestResourceStream = Assembly.GetExecutingAssembly().GetManifestResourceStream("LaDOSE.DesktopApp.Resources.css.css");
|
||||||
using (var sr = new StreamReader(manifestResourceStream))
|
// using (var sr = new StreamReader(manifestResourceStream))
|
||||||
{
|
// {
|
||||||
this.css = sr.ReadToEnd();
|
// this.css = sr.ReadToEnd();
|
||||||
}
|
// }
|
||||||
|
|
||||||
|
|
||||||
this.To = DateTime.Now;
|
this.To = new DateTimeOffset(DateTime.Now);
|
||||||
this.From = DateTime.Now.AddMonths(-1);
|
this.From = new DateTimeOffset(DateTime.Now.AddMonths(-1));
|
||||||
this.SelectRegex = "Ranking";
|
this.SelectRegex = "Ranking";
|
||||||
this.SelectEventRegex = @"Ranking #10\d{2}";
|
this.SelectEventRegex = @"Ranking #13\d{2}";
|
||||||
this.Slug = "ranking-1001";
|
this.Slug = "ranking-130";
|
||||||
|
|
||||||
LoadTournaments();
|
LoadTournaments();
|
||||||
LoadEvents();
|
LoadEvents();
|
||||||
base.OnInitialize();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void LoadTournaments()
|
public void LoadTournaments()
|
||||||
{
|
{
|
||||||
WpfUtil.Await(() =>
|
|
||||||
{
|
|
||||||
var tournamentDtos = this.RestService
|
|
||||||
.GetTournaments(new TimeRangeDTO() {From = this.From, To = this.To}).ToList();
|
|
||||||
this.Tournaments = tournamentDtos;
|
|
||||||
|
|
||||||
NotifyOfPropertyChange("Tournaments");
|
// var tournamentDtos = this.RestService
|
||||||
});
|
// .GetTournaments(new TimeRangeDTO() {From = this.From, To = this.To}).ToList();
|
||||||
|
// this.Tournaments = tournamentDtos;
|
||||||
|
|
||||||
|
RaisePropertyChanged(nameof(Tournaments));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void LoadEvents()
|
public void LoadEvents()
|
||||||
{
|
{
|
||||||
WpfUtil.Await(() =>
|
|
||||||
{
|
List<EventDTO> eventsDtos = this.RestService
|
||||||
var eventsDtos = this.RestService
|
|
||||||
.GetAllEvents().ToList();
|
.GetAllEvents().ToList();
|
||||||
this.Events = eventsDtos;
|
this.Events = eventsDtos;
|
||||||
|
|
||||||
NotifyOfPropertyChange("Events");
|
RaisePropertyChanged(nameof(Events));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public DataTable GridDataTable
|
public DataTable? GridDataTable
|
||||||
{
|
{
|
||||||
get => _gridDataTable;
|
get => _gridDataTable;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_gridDataTable = value;
|
_gridDataTable = value;
|
||||||
NotifyOfPropertyChange(() => GridDataTable);
|
RaisePropertyChanged(nameof(GridDataTable));
|
||||||
|
RaisePropertyChanged(nameof(GridDataTableView));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public DataView? GridDataTableView
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
DataView gridDataTableView = _gridDataTable?.AsDataView();
|
||||||
|
return gridDataTableView;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Select()
|
public void Select()
|
||||||
{
|
{
|
||||||
WpfUtil.Await(() =>
|
|
||||||
{
|
List<int> tournamentsIds = SelectedEvents.Select(e => e.Id).ToList();
|
||||||
var tournamentsIds = SelectedEvents.Select(e => e.Id).ToList();
|
TournamentsResultDTO? resultsDto = this.RestService.GetResults(tournamentsIds);
|
||||||
var resultsDto = this.RestService.GetResults(tournamentsIds);
|
|
||||||
this.Results = resultsDto;
|
this.Results = resultsDto;
|
||||||
ComputeDataGrid();
|
ComputeDataGrid();
|
||||||
ComputeHtml();
|
ComputeHtml();
|
||||||
});
|
|
||||||
}
|
}
|
||||||
public void GetSmash()
|
public void GetSmash()
|
||||||
{
|
|
||||||
WpfUtil.Await(() =>
|
|
||||||
{
|
{
|
||||||
|
|
||||||
var resultsDto = this.RestService.ParseSmash(Slug);
|
|
||||||
|
bool resultsDto = this.RestService.ParseSmash(Slug);
|
||||||
if (!resultsDto)
|
if (!resultsDto)
|
||||||
{
|
{
|
||||||
MessageBox.Show("Fail");
|
Error = "Error getting Smash";
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string? Error
|
||||||
|
{
|
||||||
|
get => _error;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value == _error) return;
|
||||||
|
_error = value;
|
||||||
|
RaisePropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void GetChallonge()
|
public void GetChallonge()
|
||||||
{
|
{
|
||||||
WpfUtil.Await(() =>
|
|
||||||
{
|
List<int> ids = SelectedTournaments.Select(e => e.ChallongeId).ToList();
|
||||||
var ids = SelectedTournaments.Select(e => e.ChallongeId).ToList();
|
bool resultsDto = this.RestService.ParseChallonge(ids);
|
||||||
var resultsDto = this.RestService.ParseChallonge(ids);
|
|
||||||
if (!resultsDto)
|
if (!resultsDto)
|
||||||
{
|
{
|
||||||
MessageBox.Show("Fail");
|
Error = "Fail";
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdateEvent()
|
public void UpdateEvent()
|
||||||
@@ -326,30 +334,30 @@ namespace LaDOSE.DesktopApp.ViewModels
|
|||||||
|
|
||||||
public void SelectRegexp()
|
public void SelectRegexp()
|
||||||
{
|
{
|
||||||
var selectedTournaments = this.Tournaments.Where(e => Regex.IsMatch(e.Name, this.SelectRegex)).ToList();
|
List<TournamentDTO> selectedTournaments = this.Tournaments.Where(e => Regex.IsMatch(e.Name, this.SelectRegex)).ToList();
|
||||||
this.SelectedTournaments.Clear();
|
this.SelectedTournaments.Clear();
|
||||||
if (selectedTournaments.Count > 0)
|
if (selectedTournaments.Count > 0)
|
||||||
selectedTournaments.ForEach(e => this.SelectedTournaments.AddUI(e));
|
selectedTournaments.ForEach(e => this.SelectedTournaments.Add(e));
|
||||||
}
|
}
|
||||||
public void SelectEvent()
|
public void SelectEvent()
|
||||||
{
|
{
|
||||||
var selectedEvents = this.Events.Where(e => Regex.IsMatch(e.Name, this.SelectEventRegex)).ToList();
|
List<EventDTO> selectedEvents = this.Events.Where(e => Regex.IsMatch(e.Name, this.SelectEventRegex)).ToList();
|
||||||
this.SelectedEvents.Clear();
|
this.SelectedEvents.Clear();
|
||||||
if (selectedEvents.Count > 0)
|
if (selectedEvents.Count > 0)
|
||||||
selectedEvents.ForEach(e => this.SelectedEvents.AddUI(e));
|
selectedEvents.ForEach(e => this.SelectedEvents.Add(e));
|
||||||
}
|
}
|
||||||
//This could be simplified the Dictionary was for a previous usage, but i m too lazy to rewrite it.
|
//This could be simplified the Dictionary was for a previous usage, but i m too lazy to rewrite it.
|
||||||
private void ComputeDataGrid()
|
private void ComputeDataGrid()
|
||||||
{
|
{
|
||||||
var resultsParticipents = this.Results.Participents.Select(e=>e.Name).Distinct(new CustomListExtension.EqualityComparer<String>((a, b) => a.ToUpperInvariant()== b.ToUpperInvariant())).OrderBy(e=>e).ToList();
|
List<string> resultsParticipents = this.Results.Participents.Select(e=>e.Name).Distinct(new CustomListExtension.EqualityComparer<String>((a, b) => a.ToUpperInvariant()== b.ToUpperInvariant())).OrderBy(e=>e).ToList();
|
||||||
//At start the dictionnary was for some fancy dataviz things, but since the point are inside
|
//At start the dictionnary was for some fancy dataviz things, but since the point are inside
|
||||||
//i m to lazy to rewrite this functions (this is so ugly...)
|
//i m to lazy to rewrite this functions (this is so ugly...)
|
||||||
//_computedResult = ResultsToDataDictionary(resultsParticipents);
|
//_computedResult = ResultsToDataDictionary(resultsParticipents);
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
|
|
||||||
DataTable grid = new DataTable();
|
DataTable? grid = new DataTable();
|
||||||
var games = Results.Games.Distinct().OrderBy(e => e.Order).ToList();
|
List<GameDTO> games = Results.Games.Distinct().OrderBy(e => e.Order).ToList();
|
||||||
grid.Columns.Add("Players");
|
grid.Columns.Add("Players");
|
||||||
games.ForEach(e => grid.Columns.Add(e.Name.Replace('.', ' '),typeof(Int32)));
|
games.ForEach(e => grid.Columns.Add(e.Name.Replace('.', ' '),typeof(Int32)));
|
||||||
grid.Columns.Add("Total").DataType = typeof(Int32);
|
grid.Columns.Add("Total").DataType = typeof(Int32);
|
||||||
@@ -357,17 +365,18 @@ namespace LaDOSE.DesktopApp.ViewModels
|
|||||||
|
|
||||||
for (int i = 0; i < resultsParticipents.Count; i++)
|
for (int i = 0; i < resultsParticipents.Count; i++)
|
||||||
{
|
{
|
||||||
var dataRow = grid.Rows.Add();
|
DataRow dataRow = grid.Rows.Add();
|
||||||
var resultsParticipent = resultsParticipents[i];
|
string resultsParticipent = resultsParticipents[i];
|
||||||
int total = 0;
|
int total = 0;
|
||||||
dataRow["Players"] = resultsParticipent;
|
dataRow["Players"] = resultsParticipent;
|
||||||
|
|
||||||
|
|
||||||
for (int j = 0; j < games.Count; j++)
|
for (int j = 0; j < games.Count; j++)
|
||||||
{
|
{
|
||||||
var resultsGame = Results.Games[j];
|
GameDTO? resultsGame = Results.Games[j];
|
||||||
var points = GetPlayerPoint(resultsParticipent, resultsGame.Id);
|
int points = GetPlayerPoint(resultsParticipent, resultsGame.Id);
|
||||||
dataRow[resultsGame.Name.Replace('.', ' ')] = points!=0?(object) points:DBNull.Value;
|
var o = dataRow[resultsGame.Name.Replace('.', ' ')];
|
||||||
|
dataRow[resultsGame.Name.Replace('.', ' ')] = points!=0?points:0;
|
||||||
total += points;
|
total += points;
|
||||||
}
|
}
|
||||||
dataRow["Total"] = total;
|
dataRow["Total"] = total;
|
||||||
@@ -389,33 +398,33 @@ namespace LaDOSE.DesktopApp.ViewModels
|
|||||||
|
|
||||||
private void ExportToCSV()
|
private void ExportToCSV()
|
||||||
{
|
{
|
||||||
if (this.GridDataTable != null)
|
// if (this.GridDataTable != null)
|
||||||
{
|
// {
|
||||||
var dataTable = this.GridDataTable.DefaultView.ToTable();
|
// var dataTable = this.GridDataTable.DefaultView.ToTable();
|
||||||
SaveFileDialog sfDialog = new SaveFileDialog()
|
// SaveFileDialog sfDialog = new SaveFileDialog()
|
||||||
{
|
// {
|
||||||
Filter = "Csv Files (*.csv)|*.csv|All Files (*.*)|*.*",
|
// Filter = "Csv Files (*.csv)|*.csv|All Files (*.*)|*.*",
|
||||||
AddExtension = true
|
// AddExtension = true
|
||||||
};
|
// };
|
||||||
if (sfDialog.ShowDialog() == true)
|
// if (sfDialog.ShowDialog() == true)
|
||||||
{
|
// {
|
||||||
StringBuilder sb = new StringBuilder();
|
// StringBuilder sb = new StringBuilder();
|
||||||
|
//
|
||||||
IEnumerable<string> columnNames = dataTable.Columns.Cast<DataColumn>()
|
// IEnumerable<string> columnNames = dataTable.Columns.Cast<DataColumn>()
|
||||||
.Select(column => column.ColumnName);
|
// .Select(column => column.ColumnName);
|
||||||
sb.AppendLine(string.Join(";", columnNames));
|
// sb.AppendLine(string.Join(";", columnNames));
|
||||||
|
//
|
||||||
foreach (DataRow row in dataTable.Rows)
|
// foreach (DataRow row in dataTable.Rows)
|
||||||
{
|
// {
|
||||||
//EXCEL IS A BITCH
|
// //EXCEL IS A BITCH
|
||||||
IEnumerable<string> fields = row.ItemArray.Select(field =>
|
// IEnumerable<string> fields = row.ItemArray.Select(field =>
|
||||||
string.Concat("\"", field.ToString().Replace("\"", "\"\""), "\""));
|
// string.Concat("\"", field.ToString().Replace("\"", "\"\""), "\""));
|
||||||
sb.AppendLine(string.Join(";", fields));
|
// sb.AppendLine(string.Join(";", fields));
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
File.WriteAllText(sfDialog.FileName, sb.ToString());
|
// File.WriteAllText(sfDialog.FileName, sb.ToString());
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ComputeHtml()
|
private void ComputeHtml()
|
||||||
@@ -426,10 +435,10 @@ namespace LaDOSE.DesktopApp.ViewModels
|
|||||||
|
|
||||||
int columns = 0;
|
int columns = 0;
|
||||||
|
|
||||||
var distinct = Results.Results.Select(e => e.GameId).Distinct();
|
IEnumerable<int> distinct = Results.Results.Select(e => e.GameId).Distinct();
|
||||||
|
|
||||||
var gamePlayed = Results.Games.Where(e=> distinct.Contains(e.Id)).OrderBy(e=>e.Order);
|
IOrderedEnumerable<GameDTO> gamePlayed = Results.Games.Where(e=> distinct.Contains(e.Id)).OrderBy(e=>e.Order);
|
||||||
foreach (var game in gamePlayed)
|
foreach (GameDTO game in gamePlayed)
|
||||||
{
|
{
|
||||||
List<ResultDTO> enumerable = Results.Results.Where(r => r.GameId == game.Id).ToList();
|
List<ResultDTO> enumerable = Results.Results.Where(r => r.GameId == game.Id).ToList();
|
||||||
List<string> top3 = enumerable.OrderBy(e => e.Rank).Take(3).Select(e => e.Player).ToList();
|
List<string> top3 = enumerable.OrderBy(e => e.Rank).Take(3).Select(e => e.Player).ToList();
|
||||||
@@ -443,7 +452,7 @@ namespace LaDOSE.DesktopApp.ViewModels
|
|||||||
sb.Append("<tr>");
|
sb.Append("<tr>");
|
||||||
}
|
}
|
||||||
columns++;
|
columns++;
|
||||||
var span = 1;
|
int span = 1;
|
||||||
if (columns == gamePlayed.Count())
|
if (columns == gamePlayed.Count())
|
||||||
{
|
{
|
||||||
if (columns % 2 != 0)
|
if (columns % 2 != 0)
|
||||||
@@ -460,10 +469,10 @@ namespace LaDOSE.DesktopApp.ViewModels
|
|||||||
{
|
{
|
||||||
sb.AppendLine($"<br> 1/ {top3[0]}<br> 2/ {top3[1]}<br> 3/ {top3[2]} <br>");
|
sb.AppendLine($"<br> 1/ {top3[0]}<br> 2/ {top3[1]}<br> 3/ {top3[2]} <br>");
|
||||||
//<a href=\"https://challonge.com/fr/{enumerable.First().TournamentUrl}\" target=\"_blank\">https://challonge.com/fr/{enumerable.First().TournamentUrl}</a>
|
//<a href=\"https://challonge.com/fr/{enumerable.First().TournamentUrl}\" target=\"_blank\">https://challonge.com/fr/{enumerable.First().TournamentUrl}</a>
|
||||||
var url = enumerable.FirstOrDefault().TournamentUrl;
|
string url = enumerable.FirstOrDefault()?.TournamentUrl;
|
||||||
url = url.Replace(" ", "-");
|
url = url.Replace(" ", "-");
|
||||||
url = url.Replace(".", "-");
|
url = url.Replace(".", "-");
|
||||||
sb.AppendLine($"<a href=\"https://smash.gg/tournament/ranking-1002/event/{url}\" target=\"_blank\">Voir le Bracket</p></td>");
|
sb.AppendLine($"<a href=\"https://start.gg/tournament/{Results.Slug}/event/{url}\" target=\"_blank\">Voir le Bracket</p></td>");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -483,7 +492,7 @@ namespace LaDOSE.DesktopApp.ViewModels
|
|||||||
}
|
}
|
||||||
public void CopyHtml()
|
public void CopyHtml()
|
||||||
{
|
{
|
||||||
System.Windows.Clipboard.SetText(this.HtmlContent);
|
// System.Windows.Clipboard.SetText(this.HtmlContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
private int GetPlayerPoint(string name, int gameid)
|
private int GetPlayerPoint(string name, int gameid)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="650"
|
||||||
|
x:Class="LaDOSE.DesktopApp.Avalonia.Views.GamesView"
|
||||||
|
xmlns:vm="using:LaDOSE.DesktopApp.Avalonia.ViewModels"
|
||||||
|
xmlns:dto="clr-namespace:LaDOSE.DTO;assembly=LaDOSE.DTO"
|
||||||
|
x:DataType="vm:GamesViewModel"
|
||||||
|
>
|
||||||
|
<Grid Row="4" Column="1">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"></RowDefinition>
|
||||||
|
<RowDefinition Height="*"></RowDefinition>
|
||||||
|
<RowDefinition Height="*"></RowDefinition>
|
||||||
|
<RowDefinition Height="Auto"></RowDefinition>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Button Grid.Row="0" x:Name="LoadGames" Command="{Binding LoadGames}" >Load Games</Button>
|
||||||
|
|
||||||
|
<ListBox Grid.Row="1" ItemsSource="{Binding Games}" x:Name="GamesListView" SelectedItem="{Binding CurrentGame}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<Label Content="{Binding Order}"></Label>
|
||||||
|
<Label> - </Label>
|
||||||
|
<Label Content="{Binding Name}"></Label>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
|
||||||
|
</ListBox>
|
||||||
|
<Grid Grid.Row="2">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition></ColumnDefinition>
|
||||||
|
<ColumnDefinition></ColumnDefinition>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"></RowDefinition>
|
||||||
|
<RowDefinition Height="Auto"></RowDefinition>
|
||||||
|
<RowDefinition Height="Auto"></RowDefinition>
|
||||||
|
<RowDefinition Height="Auto"></RowDefinition>
|
||||||
|
<RowDefinition Height="Auto"></RowDefinition>
|
||||||
|
<RowDefinition Height="Auto"></RowDefinition>
|
||||||
|
<RowDefinition Height="Auto"></RowDefinition>
|
||||||
|
<RowDefinition Height="Auto"></RowDefinition>
|
||||||
|
|
||||||
|
<RowDefinition Height="*"></RowDefinition>
|
||||||
|
<RowDefinition Height="Auto"></RowDefinition>
|
||||||
|
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Button Grid.Row="0" Grid.Column="0" x:Name="AddGame" Command="{Binding AddGame}">Add Game</Button>
|
||||||
|
|
||||||
|
<Button Grid.Row="0" Grid.Column="1" x:Name="DeleteGame" Command="{Binding DeleteGame}">Delete Game</Button>
|
||||||
|
<Label Grid.Row="1" Grid.Column="0">Id</Label>
|
||||||
|
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Path=CurrentGame.Id,Mode=TwoWay}" IsReadOnly="True"></TextBox>
|
||||||
|
|
||||||
|
<Label Grid.Row="2" Grid.Column="0">Name</Label>
|
||||||
|
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding Path=CurrentGame.Name,Mode=TwoWay}" ></TextBox>
|
||||||
|
|
||||||
|
<Label Grid.Row="3" Grid.Column="0">Order</Label>
|
||||||
|
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding Path=CurrentGame.Order,Mode=TwoWay}">
|
||||||
|
<!-- <i:Interaction.Behaviors> -->
|
||||||
|
<!-- <behaviors:TextBoxInputRegExBehaviour RegularExpression="^\d+$" MaxLength="9" EmptyValue="0"> -->
|
||||||
|
<!-- -->
|
||||||
|
<!-- </behaviors:TextBoxInputRegExBehaviour> -->
|
||||||
|
<!-- </i:Interaction.Behaviors> -->
|
||||||
|
</TextBox>
|
||||||
|
<Label Grid.Row="4" Grid.Column="0">LongName</Label>
|
||||||
|
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding Path=CurrentGame.LongName,Mode=TwoWay}" ></TextBox>
|
||||||
|
|
||||||
|
<Label Grid.Row="5" Grid.Column="0">WpTag</Label>
|
||||||
|
<TextBox Grid.Row="5" Grid.Column="1" Text="{Binding Path=CurrentGame.WordPressTag,Mode=TwoWay}" ></TextBox>
|
||||||
|
<Label Grid.Row="6" Grid.Column="0">WpTagOs</Label>
|
||||||
|
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Path=CurrentGame.WordPressTagOs,Mode=TwoWay}" ></TextBox>
|
||||||
|
<Label Grid.Row="7" Grid.Column="0">SmashId</Label>
|
||||||
|
<AutoCompleteBox Grid.Row="7" Grid.Column="1" Text="{Binding Path=CurrentGame.SmashId,Mode=TwoWay}" ItemsSource="{Binding Path=SearchGame}">
|
||||||
|
<AutoCompleteBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<DockPanel LastChildFill="True" Margin="2" x:DataType="dto:GameDTO">
|
||||||
|
<TextBox Text="{Binding Id}"></TextBox>
|
||||||
|
<TextBlock Text="{Binding Name}" DockPanel.Dock="Left"/>
|
||||||
|
</DockPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</AutoCompleteBox.ItemTemplate>
|
||||||
|
</AutoCompleteBox>
|
||||||
|
|
||||||
|
<Button Grid.Row="9" x:Name="Update" Command="{Binding Update}">Update</Button>
|
||||||
|
<Button Grid.Row="9" Grid.Column="1" x:Name="SmashGame" Command="{Binding GetGame}">Get Game From Smash</Button>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using LaDOSE.DesktopApp.Avalonia.ViewModels;
|
||||||
|
using ReactiveUI;
|
||||||
|
|
||||||
|
namespace LaDOSE.DesktopApp.Avalonia.Views;
|
||||||
|
|
||||||
|
public partial class GamesView : UserControl,IViewFor<GamesViewModel>
|
||||||
|
{
|
||||||
|
public GamesView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
object? IViewFor.ViewModel
|
||||||
|
{
|
||||||
|
get => ViewModel;
|
||||||
|
set => ViewModel = (GamesViewModel?)value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GamesViewModel? ViewModel { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
|
x:Class="LaDOSE.DesktopApp.Avalonia.Views.InfoView">
|
||||||
|
<Decorator x:Name="browserWrapper"/>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using LaDOSE.DesktopApp.Avalonia.ViewModels;
|
||||||
|
using ReactiveUI;
|
||||||
|
|
||||||
|
namespace LaDOSE.DesktopApp.Avalonia.Views;
|
||||||
|
|
||||||
|
public partial class InfoView : UserControl, IViewFor<InfoViewModel>
|
||||||
|
{
|
||||||
|
// private AvaloniaCefBrowser browser;
|
||||||
|
public InfoView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
object? IViewFor.ViewModel
|
||||||
|
{
|
||||||
|
get => ViewModel;
|
||||||
|
set => ViewModel = (InfoViewModel?)value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InfoViewModel? ViewModel { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:LaDOSE.DesktopApp.Avalonia.ViewModels"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:reactiveUi="http://reactiveui.net"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
|
x:Class="LaDOSE.DesktopApp.Avalonia.Views.MainWindow"
|
||||||
|
x:DataType="vm:MainWindowViewModel"
|
||||||
|
Icon="/Assets/avalonia-logo.ico"
|
||||||
|
Title="LaDOSE.DesktopApp.Avalonia">
|
||||||
|
|
||||||
|
<Design.DataContext>
|
||||||
|
<!-- This only sets the DataContext for the previewer in an IDE,
|
||||||
|
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
|
||||||
|
<vm:MainWindowViewModel/>
|
||||||
|
</Design.DataContext>
|
||||||
|
|
||||||
|
<Grid Row="4" Column="1">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"></RowDefinition>
|
||||||
|
<RowDefinition Height="*"></RowDefinition>
|
||||||
|
<RowDefinition Height="Auto"></RowDefinition>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Menu Grid.Row="0" DockPanel.Dock="Top">
|
||||||
|
<MenuItem Header="_File">
|
||||||
|
<MenuItem Header="_Events" Command="{Binding RoutedViewViewHost.ShowTournament}">
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="_Games" Command="{Binding RoutedViewViewHost.ShowGames}" >
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="_Tournaments">
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="_EventPlayers">
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="_Info" Command="{Binding RoutedViewViewHost.ShowInfo}" />
|
||||||
|
<MenuItem Header="_Close" Command="{Binding CloseApp}" />
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<TabControl Grid.Row="1" TabStripPlacement="Left">
|
||||||
|
<TabItem Header="{Binding Path=RoutedViewViewHost.Current}">
|
||||||
|
<DockPanel DataContext="{Binding RoutedViewViewHost}">
|
||||||
|
<reactiveUi:RoutedViewHost Router="{Binding Router}"/>
|
||||||
|
</DockPanel>
|
||||||
|
</TabItem>
|
||||||
|
</TabControl>
|
||||||
|
<StackPanel Grid.Row="2" Orientation="Horizontal">
|
||||||
|
<TextBlock> User : </TextBlock>
|
||||||
|
<TextBlock Margin="5,0,0,0"></TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace LaDOSE.DesktopApp.Avalonia.Views;
|
||||||
|
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
|
x:Class="LaDOSE.DesktopApp.Avalonia.Views.TournamentResultView"
|
||||||
|
xmlns:vm="using:LaDOSE.DesktopApp.Avalonia.ViewModels"
|
||||||
|
x:DataType="vm:TournamentResultViewModel"
|
||||||
|
>
|
||||||
|
<Grid Row="2" Column="1">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="2*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid Row="0" Column="0">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid Row="0" Column="0">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Row="0" Orientation="Vertical" Margin="0,0,0,5">
|
||||||
|
<Label>Date :</Label>
|
||||||
|
<StackPanel Orientation="Vertical" VerticalAlignment="Stretch">
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Stretch">
|
||||||
|
<DatePicker SelectedDate="{Binding From}" Margin="5,0,5,0" MonthFormat="MMMM" YearFormat="yyyy" DayVisible="False">
|
||||||
|
</DatePicker>
|
||||||
|
<Button Padding="5,0,5,0" Margin="5,0,5,0" x:Name="SelectMonth" HorizontalContentAlignment="Center" Command="{Binding SelectMonth}" Width="60">Month</Button>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Stretch">
|
||||||
|
<DatePicker SelectedDate="{Binding To}" Margin="5,0,5,0" MonthFormat="MMMM" YearFormat="yyyy" DayVisible="False">
|
||||||
|
</DatePicker>
|
||||||
|
<Button Padding="5,0,5,0" Margin="5,0,5,0" x:Name="SelectYear" HorizontalContentAlignment="Center" Width="60" Command="{Binding SelectYear}">Year</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Row="1" x:Name="LoadTournaments" Command="{Binding LoadTournaments}">Update</Button>
|
||||||
|
<ListBox Grid.Row="2" ItemsSource="{Binding Tournaments}" x:Name="TournamentList" Margin="0,0,0,5"
|
||||||
|
IsTextSearchEnabled="True" TextSearch.Text="Name"
|
||||||
|
SelectedItems="{Binding SelectedTournaments}"
|
||||||
|
SelectionMode="Multiple">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Margin="5,0,0,0" Text="{Binding Name}" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
|
||||||
|
</ListBox>
|
||||||
|
<DockPanel Grid.Row="3" Dock="Left">
|
||||||
|
<Label>Select :</Label>
|
||||||
|
<TextBox Width="200" Text="{Binding SelectRegex}"></TextBox>
|
||||||
|
<Button Padding="5,0,5,0" Margin="5,0,5,0" x:Name="SelectRegexp" Command="{Binding SelectRegexp}">Select</Button>
|
||||||
|
<Button Padding="5,0,5,0" Margin="5,0,5,0" x:Name="GetChallonge" Command="{Binding GetChallonge}">Import</Button>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
<Grid Row="0" Column="1">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" Margin="0,0,0,6">
|
||||||
|
<Label> Smash Slug :</Label>
|
||||||
|
<TextBox Width="200" Text="{Binding Slug}"></TextBox>
|
||||||
|
<Button Margin="5,0,5,0" x:Name="GetSmash" Command="{Binding GetSmash}" >Import Smash Event</Button>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button Grid.Row="1" x:Name="UpdateEvent" Command="{Binding UpdateEvent}">Update Event</Button>
|
||||||
|
|
||||||
|
<ListBox Grid.Row="2" ItemsSource="{Binding Events}" Margin="0,0,0,5"
|
||||||
|
IsTextSearchEnabled="True" TextSearch.Text="Name" SelectionMode="Multiple"
|
||||||
|
SelectedItems="{Binding SelectedEvents}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="{Binding Id}" />
|
||||||
|
<TextBlock Margin="5,0,0,0" Text="{Binding Name}" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
|
||||||
|
</ListBox>
|
||||||
|
<DockPanel Grid.Row="3" Dock="Left">
|
||||||
|
<Label>Select :</Label>
|
||||||
|
<TextBox Width="200" Text="{Binding SelectEventRegex}"></TextBox>
|
||||||
|
<Button Padding="5,0,5,0" Margin="5,0,5,0" x:Name="SelectEvent" Command="{Binding SelectEvent}">Select</Button>
|
||||||
|
</DockPanel>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Row="3">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="2*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<!--<DockPanel Grid.Row="0" Grid.ColumnSpan="3" Dock="Left">
|
||||||
|
<Label>Select :</Label>
|
||||||
|
<TextBox Width="200" Text="{Binding SelectRegex}"></TextBox>
|
||||||
|
<Button Padding="5,0,5,0" Margin="5,0,5,0" x:Name="SelectRegexp">Select</Button>
|
||||||
|
<Button x:Name="Select" >Get Tournaments Result</Button>
|
||||||
|
</DockPanel>-->
|
||||||
|
<Button x:Name="Select" Grid.ColumnSpan="3" VerticalAlignment="Stretch" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" Command="{Binding Select}">Get Tournaments Result</Button>
|
||||||
|
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="0" Grid.Row="1" Grid.ColumnSpan="3" Orientation="Horizontal">
|
||||||
|
<TextBlock> Game :</TextBlock>
|
||||||
|
<TextBlock Margin="5,0,0,0" Text="{Binding Results.Games.Count}" />
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
<ListBox Grid.Column="0" Grid.Row="2" ItemsSource="{Binding Results.Games}" Margin="5,5,5,5"
|
||||||
|
IsTextSearchEnabled="True" TextSearch.Text="Name"
|
||||||
|
SelectedItem="{Binding SelectedGame}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="{Binding Id}" />
|
||||||
|
<TextBlock Margin="5,0,0,0" Text="{Binding Name}" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
|
||||||
|
</ListBox>
|
||||||
|
<StackPanel Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Orientation="Horizontal">
|
||||||
|
<TextBlock> Participents :</TextBlock>
|
||||||
|
<TextBlock Margin="5,0,0,0" Text="{Binding Results.Participents.Count}" />
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
<ListBox Grid.Row="2" Grid.Column="1" ItemsSource="{Binding Results.Participents}" Margin="5,5,5,5"
|
||||||
|
IsTextSearchEnabled="True" TextSearch.Text="Name">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Margin="5,0,0,0" Text="{Binding Name}" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
|
||||||
|
</ListBox>
|
||||||
|
|
||||||
|
<TabControl Grid.Row="2" Grid.Column="2">
|
||||||
|
<TabItem Header="Result">
|
||||||
|
<DataGrid x:Name="DataGrid" PropertyChanged="DataGrid_OnPropertyChanged" ItemsSource="{Binding GridDataTableView}" CanUserSortColumns="True" BorderThickness="1" BorderBrush="Gray"/>
|
||||||
|
</TabItem>
|
||||||
|
<TabItem Header="By Game">
|
||||||
|
<DockPanel>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" DockPanel.Dock="Top">
|
||||||
|
<TextBlock> Total :</TextBlock>
|
||||||
|
<TextBlock Text="{Binding SelectedGameResult.Count}" />
|
||||||
|
</StackPanel>
|
||||||
|
<ListBox ItemsSource="{Binding SelectedGameResult}" Margin="5,5,5,5"
|
||||||
|
IsTextSearchEnabled="True" TextSearch.Text="Name" DockPanel.Dock="Top">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Margin="5,0,0,0" Text="{Binding Player}" />
|
||||||
|
<TextBlock Margin="5,0,0,0" Text="{Binding Point}" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
|
||||||
|
</ListBox>
|
||||||
|
|
||||||
|
</DockPanel>
|
||||||
|
</TabItem>
|
||||||
|
<TabItem Header="HTML">
|
||||||
|
<DockPanel>
|
||||||
|
<Button x:Name="CopyHtml" DockPanel.Dock="Top" Command="{Binding CopyHtml}">Copy HTML to clipboard</Button>
|
||||||
|
<TextBox Text="{Binding Html}" />
|
||||||
|
</DockPanel>
|
||||||
|
</TabItem>
|
||||||
|
</TabControl>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
<Button Grid.Column="0" Grid.Row="4" VerticalAlignment="Stretch" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" x:Name="Export" Command="{Binding Export}">Export</Button>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
|
||||||
|
using System.Data;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Data;
|
||||||
|
using LaDOSE.DesktopApp.Avalonia.ViewModels;
|
||||||
|
using ReactiveUI;
|
||||||
|
|
||||||
|
namespace LaDOSE.DesktopApp.Avalonia.Views
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Interaction logic for ShellView.xaml
|
||||||
|
/// </summary>
|
||||||
|
public partial class TournamentResultView : UserControl, IViewFor<TournamentResultViewModel>
|
||||||
|
{
|
||||||
|
public TournamentResultView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
object? IViewFor.ViewModel
|
||||||
|
{
|
||||||
|
get => ViewModel;
|
||||||
|
set => ViewModel = (TournamentResultViewModel)value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TournamentResultViewModel? ViewModel { get; set; }
|
||||||
|
|
||||||
|
private void DataGrid_OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Property.Name == "ItemsSource")
|
||||||
|
{
|
||||||
|
Trace.WriteLine("Changed Binding");
|
||||||
|
|
||||||
|
var grid = (sender as DataGrid);
|
||||||
|
grid.Columns.Clear();
|
||||||
|
var data = ViewModel.GridDataTable;
|
||||||
|
|
||||||
|
foreach (DataColumn? view in data.Columns)
|
||||||
|
{
|
||||||
|
|
||||||
|
grid.Columns.Add(new DataGridTextColumn()
|
||||||
|
{
|
||||||
|
Header = view.ColumnName,
|
||||||
|
CanUserSort = true,
|
||||||
|
Binding = new Binding($"Row.ItemArray[{view.Ordinal}]")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<!-- This manifest is used on Windows only.
|
||||||
|
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||||
|
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="LaDOSE.DesktopApp.Avalonia.Desktop"/>
|
||||||
|
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- A list of the Windows versions that this application has been tested on
|
||||||
|
and is designed to work with. Uncomment the appropriate elements
|
||||||
|
and Windows will automatically select the most compatible environment. -->
|
||||||
|
|
||||||
|
<!-- Windows 10 -->
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
export ANDROID_HOME=/home/tom/src/android/
|
||||||
|
export PATH=$PATH:$ANDROID_HOME/build-tools/34.0.0:$ANDROID_HOME/platforms/android-34
|
||||||
|
|
||||||
|
dotnet build LaDOSE.DesktopApp.Avalonia.csproj -p:TargetFramework=net6.0-android -p:AndroidSdkDirectory=$ANDROID_HOME/build-tools/34.0.0
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"REST": {
|
||||||
|
"Url": "http://localhost:5000",
|
||||||
|
"User": "user",
|
||||||
|
"Password": "password"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<RootNamespace>LaDOSE.DesktopApp</RootNamespace>
|
<RootNamespace>LaDOSE.DesktopApp</RootNamespace>
|
||||||
<AssemblyName>LaDOSE.DesktopApp</AssemblyName>
|
<AssemblyName>LaDOSE.DesktopApp</AssemblyName>
|
||||||
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
|
<TargetFrameworkVersion>v4.7</TargetFrameworkVersion>
|
||||||
<FileAlignment>512</FileAlignment>
|
<FileAlignment>512</FileAlignment>
|
||||||
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
|
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
|
||||||
<WarningLevel>4</WarningLevel>
|
<WarningLevel>4</WarningLevel>
|
||||||
@@ -71,27 +71,6 @@
|
|||||||
<OutputPath>bin\x64\Release\</OutputPath>
|
<OutputPath>bin\x64\Release\</OutputPath>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Reference Include="Caliburn.Micro, Version=3.2.0.0, Culture=neutral, PublicKeyToken=8e5891231f2ed21f, processorArchitecture=MSIL">
|
|
||||||
<HintPath>..\packages\Caliburn.Micro.Core.3.2.0\lib\net45\Caliburn.Micro.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="Caliburn.Micro.Platform, Version=3.2.0.0, Culture=neutral, PublicKeyToken=8e5891231f2ed21f, processorArchitecture=MSIL">
|
|
||||||
<HintPath>..\packages\Caliburn.Micro.3.2.0\lib\net45\Caliburn.Micro.Platform.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="Caliburn.Micro.Platform.Core, Version=3.2.0.0, Culture=neutral, PublicKeyToken=8e5891231f2ed21f, processorArchitecture=MSIL">
|
|
||||||
<HintPath>..\packages\Caliburn.Micro.3.2.0\lib\net45\Caliburn.Micro.Platform.Core.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="CefSharp, Version=103.0.120.0, Culture=neutral, PublicKeyToken=40c4b6fc221f4138, processorArchitecture=MSIL">
|
|
||||||
<HintPath>..\packages\CefSharp.Common.103.0.120\lib\net452\CefSharp.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="CefSharp.Core, Version=103.0.120.0, Culture=neutral, PublicKeyToken=40c4b6fc221f4138, processorArchitecture=MSIL">
|
|
||||||
<HintPath>..\packages\CefSharp.Common.103.0.120\lib\net452\CefSharp.Core.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="CefSharp.Wpf, Version=103.0.120.0, Culture=neutral, PublicKeyToken=40c4b6fc221f4138, processorArchitecture=MSIL">
|
|
||||||
<HintPath>..\packages\CefSharp.Wpf.103.0.120\lib\net452\CefSharp.Wpf.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="RestSharp, Version=106.11.4.0, Culture=neutral, PublicKeyToken=598062e77f915f75, processorArchitecture=MSIL">
|
|
||||||
<HintPath>..\packages\RestSharp.106.11.4\lib\net452\RestSharp.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="System" />
|
<Reference Include="System" />
|
||||||
<Reference Include="System.Configuration" />
|
<Reference Include="System.Configuration" />
|
||||||
<Reference Include="System.Data" />
|
<Reference Include="System.Data" />
|
||||||
@@ -101,9 +80,6 @@
|
|||||||
<Reference Include="System.Web" />
|
<Reference Include="System.Web" />
|
||||||
<Reference Include="System.Web.Extensions" />
|
<Reference Include="System.Web.Extensions" />
|
||||||
<Reference Include="System.Windows.Forms" />
|
<Reference Include="System.Windows.Forms" />
|
||||||
<Reference Include="System.Windows.Interactivity, Version=4.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
|
|
||||||
<HintPath>..\packages\Caliburn.Micro.3.2.0\lib\net45\System.Windows.Interactivity.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="System.Xml" />
|
<Reference Include="System.Xml" />
|
||||||
<Reference Include="Microsoft.CSharp" />
|
<Reference Include="Microsoft.CSharp" />
|
||||||
<Reference Include="System.Core" />
|
<Reference Include="System.Core" />
|
||||||
@@ -126,8 +102,6 @@
|
|||||||
<Compile Include="Behaviors\TextBoxInputRegExBehaviour.cs" />
|
<Compile Include="Behaviors\TextBoxInputRegExBehaviour.cs" />
|
||||||
<Compile Include="Behaviors\MultiSelectorBehaviours.cs" />
|
<Compile Include="Behaviors\MultiSelectorBehaviours.cs" />
|
||||||
<Compile Include="Bootstrapper.cs" />
|
<Compile Include="Bootstrapper.cs" />
|
||||||
<Compile Include="Themes\LeftMarginMultiplierConverter.cs" />
|
|
||||||
<Compile Include="Themes\TreeViewItemExtensions.cs" />
|
|
||||||
<Compile Include="Utils\CustomEqualityCompare.cs" />
|
<Compile Include="Utils\CustomEqualityCompare.cs" />
|
||||||
<Compile Include="Utils\PhpSerialize.cs" />
|
<Compile Include="Utils\PhpSerialize.cs" />
|
||||||
<Compile Include="UserControls\BookingUserControl.xaml.cs">
|
<Compile Include="UserControls\BookingUserControl.xaml.cs">
|
||||||
@@ -163,10 +137,6 @@
|
|||||||
<DependentUpon>App.xaml</DependentUpon>
|
<DependentUpon>App.xaml</DependentUpon>
|
||||||
<SubType>Code</SubType>
|
<SubType>Code</SubType>
|
||||||
</Compile>
|
</Compile>
|
||||||
<Page Include="Themes\Styles.xaml">
|
|
||||||
<Generator>MSBuild:Compile</Generator>
|
|
||||||
<SubType>Designer</SubType>
|
|
||||||
</Page>
|
|
||||||
<Page Include="UserControls\BookingUserControl.xaml">
|
<Page Include="UserControls\BookingUserControl.xaml">
|
||||||
<SubType>Designer</SubType>
|
<SubType>Designer</SubType>
|
||||||
<Generator>MSBuild:Compile</Generator>
|
<Generator>MSBuild:Compile</Generator>
|
||||||
@@ -214,7 +184,6 @@
|
|||||||
<Generator>ResXFileCodeGenerator</Generator>
|
<Generator>ResXFileCodeGenerator</Generator>
|
||||||
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
<None Include="packages.config" />
|
|
||||||
<None Include="Properties\Settings.settings">
|
<None Include="Properties\Settings.settings">
|
||||||
<Generator>SettingsSingleFileGenerator</Generator>
|
<Generator>SettingsSingleFileGenerator</Generator>
|
||||||
<LastGenOutput>Settings.Designer.cs</LastGenOutput>
|
<LastGenOutput>Settings.Designer.cs</LastGenOutput>
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using System.Windows;
|
|
||||||
using System.Windows.Controls;
|
|
||||||
using System.Windows.Data;
|
|
||||||
|
|
||||||
namespace DarkBlendTheme
|
|
||||||
{
|
|
||||||
public class LeftMarginMultiplierConverter : IValueConverter
|
|
||||||
{
|
|
||||||
public double Length { get; set; }
|
|
||||||
|
|
||||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
|
||||||
{
|
|
||||||
var item = value as TreeViewItem;
|
|
||||||
if (item == null)
|
|
||||||
return new Thickness(0);
|
|
||||||
|
|
||||||
return new Thickness(Length * item.GetDepth(), 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
|
||||||
{
|
|
||||||
throw new System.NotImplementedException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,38 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using System.Windows;
|
|
||||||
using System.Windows.Controls;
|
|
||||||
using System.Windows.Data;
|
|
||||||
using System.Windows.Media;
|
|
||||||
|
|
||||||
namespace DarkBlendTheme
|
|
||||||
{
|
|
||||||
public static class TreeViewItemExtensions
|
|
||||||
{
|
|
||||||
public static int GetDepth(this TreeViewItem item)
|
|
||||||
{
|
|
||||||
TreeViewItem parent;
|
|
||||||
while ((parent = GetParent(item)) != null)
|
|
||||||
{
|
|
||||||
return GetDepth(parent) + 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static TreeViewItem GetParent(TreeViewItem item)
|
|
||||||
{
|
|
||||||
var parent = VisualTreeHelper.GetParent(item);
|
|
||||||
|
|
||||||
while (!(parent is TreeViewItem || parent is TreeView))
|
|
||||||
{
|
|
||||||
if (parent == null) return null;
|
|
||||||
parent = VisualTreeHelper.GetParent(parent);
|
|
||||||
}
|
|
||||||
return parent as TreeViewItem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<packages>
|
|
||||||
<package id="Caliburn.Micro" version="3.2.0" targetFramework="net461" />
|
|
||||||
<package id="Caliburn.Micro.Core" version="3.2.0" targetFramework="net461" />
|
|
||||||
<package id="cef.redist.x64" version="103.0.12" targetFramework="net461" />
|
|
||||||
<package id="cef.redist.x86" version="103.0.12" targetFramework="net461" />
|
|
||||||
<package id="CefSharp.Common" version="103.0.120" targetFramework="net461" />
|
|
||||||
<package id="CefSharp.Wpf" version="103.0.120" targetFramework="net461" />
|
|
||||||
<package id="RestSharp" version="106.11.4" targetFramework="net461" />
|
|
||||||
<package id="WPFThemes.DarkBlend" version="1.0.8" targetFramework="net461" />
|
|
||||||
</packages>
|
|
||||||
@@ -1,58 +1,55 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DSharpPlus.CommandsNext;
|
|
||||||
using DSharpPlus.CommandsNext.Attributes;
|
|
||||||
using LaDOSE.DiscordBot.Service;
|
using LaDOSE.DiscordBot.Service;
|
||||||
using LaDOSE.DTO;
|
using LaDOSE.DTO;
|
||||||
|
|
||||||
namespace LaDOSE.DiscordBot.Command
|
namespace LaDOSE.DiscordBot.Command
|
||||||
{
|
{
|
||||||
public class BotEvent : BaseCommandModule
|
// public class BotEvent : BaseCommandModule
|
||||||
{
|
// {
|
||||||
private WebService dep;
|
// private WebService dep;
|
||||||
public BotEvent(WebService d)
|
// public BotEvent(WebService d)
|
||||||
{
|
// {
|
||||||
dep = d;
|
// dep = d;
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
[RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
|
// [Command("newevent")]
|
||||||
[Command("newevent")]
|
// public async Task NewEventAsync(CommandContext ctx, string command)
|
||||||
public async Task NewEventAsync(CommandContext ctx, string command)
|
// {
|
||||||
{
|
//
|
||||||
|
// await ctx.RespondAsync(dep.RestService.CreateBotEvent(command).ToString());
|
||||||
await ctx.RespondAsync(dep.RestService.CreateBotEvent(command).ToString());
|
// }
|
||||||
}
|
// [RequireRolesAttribute(RoleCheckMode.Any,"Staff")]
|
||||||
[RequireRolesAttribute(RoleCheckMode.Any,"Staff")]
|
// [Command("staffs")]
|
||||||
[Command("staffs")]
|
// public async Task StaffAsync(CommandContext ctx)
|
||||||
public async Task StaffAsync(CommandContext ctx)
|
// {
|
||||||
{
|
// BotEventDTO currentEvent = dep.RestService.GetLastBotEvent();
|
||||||
BotEventDTO currentEvent = dep.RestService.GetLastBotEvent();
|
// StringBuilder stringBuilder = new StringBuilder();
|
||||||
StringBuilder stringBuilder = new StringBuilder();
|
//
|
||||||
|
// var present = currentEvent.Results.Where(x => x.Result).ToList();
|
||||||
var present = currentEvent.Results.Where(x => x.Result).ToList();
|
// var absent = currentEvent.Results.Where(x => !x.Result).ToList();
|
||||||
var absent = currentEvent.Results.Where(x => !x.Result).ToList();
|
//
|
||||||
|
// stringBuilder.AppendLine($"Pour {currentEvent.Name} : ");
|
||||||
stringBuilder.AppendLine($"Pour {currentEvent.Name} : ");
|
// present.ForEach(x => stringBuilder.AppendLine($":white_check_mark: {x.Name}"));
|
||||||
present.ForEach(x => stringBuilder.AppendLine($":white_check_mark: {x.Name}"));
|
// absent.ForEach(x => stringBuilder.AppendLine($":x: {x.Name}"));
|
||||||
absent.ForEach(x => stringBuilder.AppendLine($":x: {x.Name}"));
|
//
|
||||||
|
// await ctx.RespondAsync(stringBuilder.ToString());
|
||||||
await ctx.RespondAsync(stringBuilder.ToString());
|
//
|
||||||
|
// }
|
||||||
}
|
// [RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
|
||||||
[RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
|
// [Command("present")]
|
||||||
[Command("present")]
|
// public async Task PresentAsync(CommandContext ctx)
|
||||||
public async Task PresentAsync(CommandContext ctx)
|
// {
|
||||||
{
|
// await ctx.RespondAsync(dep.RestService.ResultBotEvent(new DTO.BotEventSendDTO() { DiscordId = ctx.Member.Id.ToString(), DiscordName = ctx.Member.DisplayName, Present = true }).ToString());
|
||||||
await ctx.RespondAsync(dep.RestService.ResultBotEvent(new DTO.BotEventSendDTO() { DiscordId = ctx.Member.Id.ToString(), DiscordName = ctx.Member.DisplayName, Present = true }).ToString());
|
//
|
||||||
|
//
|
||||||
|
// }
|
||||||
}
|
// [RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
|
||||||
[RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
|
// [Command("absent")]
|
||||||
[Command("absent")]
|
// public async Task AbsentAsync(CommandContext ctx)
|
||||||
public async Task AbsentAsync(CommandContext ctx)
|
// {
|
||||||
{
|
// await ctx.RespondAsync(dep.RestService.ResultBotEvent(new DTO.BotEventSendDTO() { DiscordId = ctx.Member.Id.ToString(), DiscordName = ctx.Member.DisplayName, Present = false }).ToString());
|
||||||
await ctx.RespondAsync(dep.RestService.ResultBotEvent(new DTO.BotEventSendDTO() { DiscordId = ctx.Member.Id.ToString(), DiscordName = ctx.Member.DisplayName, Present = false }).ToString());
|
// }
|
||||||
}
|
// }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -2,14 +2,15 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DSharpPlus.CommandsNext;
|
using DSharpPlus.Commands;
|
||||||
using DSharpPlus.CommandsNext.Attributes;
|
using DSharpPlus.Commands.ArgumentModifiers;
|
||||||
|
using DSharpPlus.Commands.Processors.TextCommands;
|
||||||
using DSharpPlus.Entities;
|
using DSharpPlus.Entities;
|
||||||
|
|
||||||
namespace LaDOSE.DiscordBot.Command
|
namespace LaDOSE.DiscordBot.Command
|
||||||
{
|
{
|
||||||
|
|
||||||
public class Hokuto : BaseCommandModule
|
public class Hokuto
|
||||||
{
|
{
|
||||||
|
|
||||||
private static List<string> Games = new List<string> { "2X", "3.3", "Karnov" };
|
private static List<string> Games = new List<string> { "2X", "3.3", "Karnov" };
|
||||||
@@ -21,14 +22,21 @@ namespace LaDOSE.DiscordBot.Command
|
|||||||
|
|
||||||
|
|
||||||
[Command("hokuto")]
|
[Command("hokuto")]
|
||||||
public async Task HokutoUserAsync(CommandContext ctx, params DiscordMember[] user)
|
public async ValueTask HokutoUserAsync(TextCommandContext ctx)
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
||||||
var i = r.Next(0, 3);
|
var i = r.Next(0, 3);
|
||||||
if (user!=null && user.Length>0)
|
if (ctx.Message.MentionedUsers is { Count: 1 } )
|
||||||
{
|
{
|
||||||
await ctx.RespondAsync(ctx.User?.Mention + " vs " + user[0].Mention + " : " + Games[i].ToString());
|
foreach (var arg in ctx.Message.MentionedUsers)
|
||||||
|
{
|
||||||
|
if (arg is DiscordUser member)
|
||||||
|
{
|
||||||
|
await ctx.RespondAsync(ctx.User?.Mention + " vs " + member.Mention + " : " + Games[i].ToString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ using System.Globalization;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DSharpPlus.CommandsNext;
|
using DSharpPlus.Commands;
|
||||||
using DSharpPlus.CommandsNext.Attributes;
|
|
||||||
|
|
||||||
namespace LaDOSE.DiscordBot.Command
|
namespace LaDOSE.DiscordBot.Command
|
||||||
{
|
{
|
||||||
public class Public : BaseCommandModule
|
public class Public
|
||||||
{
|
{
|
||||||
|
|
||||||
private static List<string> Quotes { get; set; }
|
private static List<string> Quotes { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Builds the LaDOSE.DiscordBot image. Context is LaDOSE.Src, same as LaDOSE.Src/Dockerfile,
|
||||||
|
# so the two share LaDOSE.Src/.dockerignore — hence the LaDOSE.DiscordBot/ prefix on every
|
||||||
|
# COPY below. docker-compose.yml passes `dockerfile: LaDOSE.DiscordBot/Dockerfile`.
|
||||||
|
#
|
||||||
|
# The context has to be the whole of LaDOSE.Src, not this directory: the bot pulls in
|
||||||
|
# LaDOSE.REST -> LaDOSE.DTO by ProjectReference, and Libraries/ChallongeCSharpDriver.dll
|
||||||
|
# by HintPath.
|
||||||
|
ARG DOTNET_VERSION=9.0
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Release here, unlike the API image. Nothing in this project is gated on Debug — the
|
||||||
|
# #if DEBUG / Condition="'$(Configuration)' == 'Debug'" pairs that force the API to build
|
||||||
|
# Debug (OpenAPI, Scalar) have no equivalent in LaDOSE.DiscordBot.csproj.
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
|
||||||
|
# Project files first so this layer survives every .cs edit. Restore under the same
|
||||||
|
# Configuration as the publish below, matching LaDOSE.Src/Dockerfile.
|
||||||
|
COPY global.json ./
|
||||||
|
COPY LaDOSE.DiscordBot/LaDOSE.DiscordBot.csproj LaDOSE.DiscordBot/
|
||||||
|
COPY LaDOSE.REST/LaDOSE.REST.csproj LaDOSE.REST/
|
||||||
|
COPY LaDOSE.DTO/LaDOSE.DTO.csproj LaDOSE.DTO/
|
||||||
|
RUN dotnet restore LaDOSE.DiscordBot/LaDOSE.DiscordBot.csproj -p:Configuration=${BUILD_CONFIGURATION}
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN dotnet publish LaDOSE.DiscordBot/LaDOSE.DiscordBot.csproj -c ${BUILD_CONFIGURATION} --no-restore -o /app/out
|
||||||
|
|
||||||
|
# The file on disk is Quotes.txt; Command/Public.cs:114 opens "quotes.txt". Windows does
|
||||||
|
# not care, Linux does — without this the !Quote command throws FileNotFoundException
|
||||||
|
# (LoadQuote, unlike LoadCards, does not catch it). A copy rather than a rename, so the
|
||||||
|
# name the .csproj lists keeps working too.
|
||||||
|
RUN if [ -f /app/out/Quotes.txt ] && [ ! -f /app/out/quotes.txt ]; then \
|
||||||
|
cp /app/out/Quotes.txt /app/out/quotes.txt; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# runtime, not aspnet: this is a console app with no Kestrel and no listening port.
|
||||||
|
FROM mcr.microsoft.com/dotnet/runtime:${DOTNET_VERSION}
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=build /app/out/ ./
|
||||||
|
COPY LaDOSE.DiscordBot/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
|
# WORKDIR is load-bearing, not cosmetic: Program.cs points its ConfigurationBuilder at
|
||||||
|
# Directory.GetCurrentDirectory() for settings.json, and Public.cs reads questions.txt /
|
||||||
|
# answers.txt / quotes.txt by bare relative path. All four sit in /app.
|
||||||
|
#
|
||||||
|
# The entrypoint renders settings.json from the environment before handing over; see the
|
||||||
|
# script for why that indirection exists.
|
||||||
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
CMD ["dotnet", "LaDOSE.DiscordBot.dll"]
|
||||||
@@ -2,16 +2,18 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Platforms>AnyCPU;x64</Platforms>
|
<Platforms>AnyCPU;x64</Platforms>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="DSharpPlus" Version="4.2.0" />
|
<PackageReference Include="DSharpPlus" Version="5.0.0-alpha.5" />
|
||||||
<PackageReference Include="DSharpPlus.CommandsNext" Version="4.2.0" />
|
<PackageReference Include="DSharpPlus.Commands" Version="5.0.0-alpha.5" />
|
||||||
<PackageReference Include="DSharpPlus.Interactivity" Version="4.2.0" />
|
<PackageReference Include="DSharpPlus.Interactivity" Version="5.0.0-alpha.5" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.8" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.8" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ using System.IO;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DSharpPlus;
|
using DSharpPlus;
|
||||||
|
using DSharpPlus.Commands;
|
||||||
|
using DSharpPlus.Commands.Processors.SlashCommands;
|
||||||
|
using DSharpPlus.Commands.Processors.TextCommands;
|
||||||
|
using DSharpPlus.Commands.Processors.TextCommands.Parsing;
|
||||||
|
using DSharpPlus.Entities;
|
||||||
using DSharpPlus.Interactivity;
|
using DSharpPlus.Interactivity;
|
||||||
using DSharpPlus.CommandsNext;
|
|
||||||
using DSharpPlus.EventArgs;
|
using DSharpPlus.EventArgs;
|
||||||
using DSharpPlus.Interactivity.Extensions;
|
using DSharpPlus.Interactivity.Extensions;
|
||||||
//using DSharpPlus.SlashCommands;
|
//using DSharpPlus.SlashCommands;
|
||||||
@@ -20,7 +24,6 @@ namespace LaDOSE.DiscordBot
|
|||||||
{
|
{
|
||||||
class Program
|
class Program
|
||||||
{
|
{
|
||||||
static DiscordClient discord;
|
|
||||||
|
|
||||||
//static InteractivityModule Interactivity { get; set; }
|
//static InteractivityModule Interactivity { get; set; }
|
||||||
static void Main(string[] args)
|
static void Main(string[] args)
|
||||||
@@ -43,106 +46,36 @@ namespace LaDOSE.DiscordBot
|
|||||||
var restUser = builder["REST:User"].ToString();
|
var restUser = builder["REST:User"].ToString();
|
||||||
var restPassword = builder["REST:Password"].ToString();
|
var restPassword = builder["REST:Password"].ToString();
|
||||||
|
|
||||||
var service = new ServiceCollection()
|
|
||||||
.AddSingleton(typeof(WebService), new WebService(new Uri(restUrl), restUser, restPassword))
|
|
||||||
.BuildServiceProvider();
|
|
||||||
|
|
||||||
|
|
||||||
Console.WriteLine($"LaDOSE.Net Discord Bot");
|
Console.WriteLine($"LaDOSE.Net Discord Bot");
|
||||||
|
|
||||||
|
DiscordClientBuilder builder2 =
|
||||||
discord = new DiscordClient(new DiscordConfiguration
|
DiscordClientBuilder.CreateDefault(discordToken, DiscordIntents.AllUnprivileged | DiscordIntents.MessageContents | DiscordIntents.GuildMessages| TextCommandProcessor.RequiredIntents | SlashCommandProcessor.RequiredIntents);
|
||||||
{
|
|
||||||
Token = discordToken,
|
|
||||||
TokenType = TokenType.Bot,
|
|
||||||
//AutoReconnect = true,
|
|
||||||
//MinimumLogLevel = LogLevel.Debug,
|
|
||||||
//MessageCacheSize = 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
discord.UseInteractivity(new InteractivityConfiguration
|
|
||||||
{
|
|
||||||
|
|
||||||
// default pagination behaviour to just ignore the reactions
|
|
||||||
//PaginationBehaviour = TimeoutBehaviour.Ignore,
|
|
||||||
|
|
||||||
// default pagination timeout to 5 minutes
|
|
||||||
//PaginationTimeout = TimeSpan.FromMinutes(5),
|
|
||||||
|
|
||||||
// default timeout for other actions to 2 minutes
|
|
||||||
Timeout = TimeSpan.FromMinutes(2)
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
var cts = new CancellationTokenSource();
|
var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
var _cnext = discord.UseCommandsNext(new CommandsNextConfiguration()
|
// Setup the commands extension
|
||||||
|
builder2.UseCommands((IServiceProvider serviceProvider, CommandsExtension extension) =>
|
||||||
{
|
{
|
||||||
//CaseSensitive = false,
|
extension.AddCommands([typeof(Hokuto), typeof(Public)]);
|
||||||
//EnableDefaultHelp = true,
|
TextCommandProcessor textCommandProcessor = new();
|
||||||
//EnableDms = false,
|
extension.AddProcessor(textCommandProcessor);
|
||||||
//EnableMentionPrefix = true,
|
}, new CommandsConfiguration()
|
||||||
StringPrefixes = new List<string>() { "/", "!" },
|
{
|
||||||
//IgnoreExtraArguments = true,
|
// The default value is true, however it's shown here for clarity
|
||||||
Services = service
|
RegisterDefaultCommandProcessors = true,
|
||||||
|
UseDefaultCommandErrorHandler = false
|
||||||
|
// DebugGuildId = Environment.GetEnvironmentVariable("DEBUG_GUILD_ID") ?? 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
DiscordClient client = builder2.Build();
|
||||||
|
|
||||||
//var slashCommands = discord.UseSlashCommands(new SlashCommandsConfiguration() {Services = service});
|
// We can specify a status for our bot. Let's set it to "playing" and set the activity to "with fire".
|
||||||
//slashCommands.RegisterCommands<SlashCommand>(guildId:null);
|
DiscordActivity status = new("Street Fighter", DiscordActivityType.Playing);
|
||||||
|
await client.ConnectAsync(status,DiscordUserStatus.Online);
|
||||||
|
|
||||||
//_cnext.RegisterCommands<Result>();
|
|
||||||
_cnext.RegisterCommands<Public>();
|
|
||||||
//_cnext.RegisterCommands<Shutdown>();
|
|
||||||
//_cnext.RegisterCommands<Todo>();
|
|
||||||
_cnext.RegisterCommands<Hokuto>();
|
|
||||||
_cnext.RegisterCommands<BotEvent>();
|
|
||||||
|
|
||||||
foreach (var registeredCommandsKey in discord.GetCommandsNext().RegisteredCommands.Keys)
|
|
||||||
{
|
|
||||||
Console.WriteLine(registeredCommandsKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
discord.Ready += (sender, eventArgs) =>
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Bot READY.");
|
|
||||||
return Task.CompletedTask;
|
|
||||||
};
|
|
||||||
discord.GuildAvailable += (sender, eventArgs) =>
|
|
||||||
{
|
|
||||||
|
|
||||||
Console.WriteLine($"Joined Guild " + eventArgs.Guild.Name);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
};
|
|
||||||
|
|
||||||
await discord.ConnectAsync();
|
|
||||||
|
|
||||||
|
|
||||||
await Task.Delay(Timeout.Infinite);
|
await Task.Delay(Timeout.Infinite);
|
||||||
|
|
||||||
//while (!cts.IsCancellationRequested)
|
|
||||||
//{
|
|
||||||
// await Task.Delay(200);
|
|
||||||
// //if(discord.GetConnectionsAsync().Result.Count)
|
|
||||||
//}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//internal class SlashCommand : ApplicationCommandModule
|
|
||||||
//{
|
|
||||||
// [SlashCommand("test", "A slash command made to test the DSharpPlusSlashCommands library!")]
|
|
||||||
|
|
||||||
// public async Task TestCommand(InteractionContext ctx)
|
|
||||||
// {
|
|
||||||
|
|
||||||
// await ctx.CreateResponseAsync("Lol");
|
|
||||||
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -34,13 +34,6 @@ namespace LaDOSE.DiscordBot.Service
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CheckToken()
|
|
||||||
{
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public String GetInscrits()
|
public String GetInscrits()
|
||||||
{
|
{
|
||||||
|
|||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# LaDOSE.DiscordBot reads its configuration from ./settings.json and from nothing else:
|
||||||
|
# Program.cs builds a ConfigurationBuilder with AddJsonFile("settings.json") and no
|
||||||
|
# AddEnvironmentVariables(), so a LADOSE_* variable would be invisible to it. This script
|
||||||
|
# renders that file at container start instead — the same indirection LaDOSE.WebApp's
|
||||||
|
# entrypoint uses to turn LADOSE_API_BASE_URL into /config.js. One image, any token.
|
||||||
|
#
|
||||||
|
# It writes only when LADOSE_DISCORD_TOKEN is set. Left unset, whatever settings.json is
|
||||||
|
# already at /app wins: the placeholder baked in by the build, or a file bind-mounted over
|
||||||
|
# it. Doing both — mounting settings.json read-only *and* setting the variable — fails
|
||||||
|
# here with EROFS rather than silently ignoring one of them.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
settings_file=/app/settings.json
|
||||||
|
|
||||||
|
# Drop control characters (newlines included) so a value cannot break out of its string
|
||||||
|
# literal, then escape backslashes before double quotes. A token containing " or \ stays
|
||||||
|
# inert data.
|
||||||
|
json_string() {
|
||||||
|
printf '%s' "${1:-}" | tr -d '\001-\037' | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -n "${LADOSE_DISCORD_TOKEN:-}" ]; then
|
||||||
|
rest_url=${LADOSE_BOT_REST_URL:-http://api:5000}
|
||||||
|
|
||||||
|
# All five keys, always. Program.cs calls .ToString() on each of them at startup, so a
|
||||||
|
# missing one is a NullReferenceException before the bot ever reaches Discord.
|
||||||
|
cat >"$settings_file" <<EOF
|
||||||
|
{
|
||||||
|
"Discord": {
|
||||||
|
"Token": "$(json_string "${LADOSE_DISCORD_TOKEN}")"
|
||||||
|
},
|
||||||
|
"Challonge": {
|
||||||
|
"Token": "$(json_string "${LADOSE_CHALLONGE_API_KEY:-}")"
|
||||||
|
},
|
||||||
|
"REST": {
|
||||||
|
"Url": "$(json_string "${rest_url}")",
|
||||||
|
"User": "$(json_string "${LADOSE_BOT_REST_USER:-}")",
|
||||||
|
"Password": "$(json_string "${LADOSE_BOT_REST_PASSWORD:-}")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
# The token is deliberately not echoed.
|
||||||
|
echo "settings.json: rendered from the environment (REST:Url=$rest_url)" >&2
|
||||||
|
else
|
||||||
|
echo "settings.json: LADOSE_DISCORD_TOKEN unset, using the file already at $settings_file" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace LaDOSE.Entity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A role a user can hold, stored in the pre-existing <c>applicationrole</c> table.
|
||||||
|
/// Rows are reference data seeded by Sql/2026-08-05_roles.sql, not created at runtime.
|
||||||
|
/// </summary>
|
||||||
|
public class ApplicationRole
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string Name { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
|
||||||
@@ -14,6 +15,14 @@ namespace LaDOSE.Entity
|
|||||||
public string Password { get; set; }
|
public string Password { get; set; }
|
||||||
public byte[] PasswordHash { get; set; }
|
public byte[] PasswordHash { get; set; }
|
||||||
public byte[] PasswordSalt { get; set; }
|
public byte[] PasswordSalt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rows of the <c>applicationuserrole</c> join table for this user. Only populated
|
||||||
|
/// when the query asks for it — see UserService, which includes it (and the role
|
||||||
|
/// itself) everywhere the role matters; authorization reads this on every request.
|
||||||
|
/// Prefer the <see cref="Roles.Names"/> / <see cref="Roles.IsAdmin"/> helpers.
|
||||||
|
/// </summary>
|
||||||
|
public List<ApplicationUserRole> UserRoles { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace LaDOSE.Entity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>applicationuserrole</c> join table. Declared explicitly rather than left
|
||||||
|
/// implicit so its columns match the table that already exists in the database
|
||||||
|
/// (<c>userid</c>, <c>roleid</c>, no surrogate key).
|
||||||
|
/// </summary>
|
||||||
|
public class ApplicationUserRole
|
||||||
|
{
|
||||||
|
public int UserId { get; set; }
|
||||||
|
public ApplicationUser User { get; set; }
|
||||||
|
|
||||||
|
public int RoleId { get; set; }
|
||||||
|
public ApplicationRole Role { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ namespace LaDOSE.Entity.Challonge
|
|||||||
public List<Game> Games{ get; set; }
|
public List<Game> Games{ get; set; }
|
||||||
|
|
||||||
public List<Result> Results { get; set; }
|
public List<Result> Results { get; set; }
|
||||||
|
public string Slug { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Result
|
public class Result
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ namespace LaDOSE.Entity.Context
|
|||||||
{
|
{
|
||||||
public DbSet<Game> Game { get; set; }
|
public DbSet<Game> Game { get; set; }
|
||||||
public DbSet<ApplicationUser> ApplicationUser { get; set; }
|
public DbSet<ApplicationUser> ApplicationUser { get; set; }
|
||||||
|
public DbSet<ApplicationRole> ApplicationRole { get; set; }
|
||||||
|
|
||||||
public DbSet<Todo> Todo { get; set; }
|
public DbSet<Todo> Todo { get; set; }
|
||||||
|
|
||||||
@@ -40,6 +41,8 @@ namespace LaDOSE.Entity.Context
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
|
||||||
@@ -47,6 +50,27 @@ namespace LaDOSE.Entity.Context
|
|||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
|
||||||
|
#region Users and roles
|
||||||
|
|
||||||
|
// Maps onto the applicationrole / applicationuserrole tables that already exist
|
||||||
|
// in the schema. The join table is configured explicitly rather than as a
|
||||||
|
// many-to-many skip navigation so its columns are exactly userid + roleid,
|
||||||
|
// with the pair as the key and no surrogate id.
|
||||||
|
modelBuilder.Entity<ApplicationUserRole>(join =>
|
||||||
|
{
|
||||||
|
join.HasKey(ur => new { ur.UserId, ur.RoleId });
|
||||||
|
|
||||||
|
join.HasOne(ur => ur.User)
|
||||||
|
.WithMany(u => u.UserRoles)
|
||||||
|
.HasForeignKey(ur => ur.UserId);
|
||||||
|
|
||||||
|
join.HasOne(ur => ur.Role)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(ur => ur.RoleId);
|
||||||
|
});
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
modelBuilder.Entity<Event>()
|
modelBuilder.Entity<Event>()
|
||||||
.HasMany(s => s.Tournaments);
|
.HasMany(s => s.Tournaments);
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,18 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Platforms>AnyCPU;x64</Platforms>
|
<Platforms>AnyCPU;x64</Platforms>
|
||||||
<TargetFrameworks>net6.0;netcoreapp3.1</TargetFrameworks>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="3.1.2" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.12" />
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="Microsoft.EntityFrameworkCore">
|
||||||
|
<HintPath>..\..\..\..\.nuget\packages\microsoft.entityframeworkcore\8.0.11\lib\net8.0\Microsoft.EntityFrameworkCore.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace LaDOSE.Entity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The role names the API knows about. They must exist as rows in
|
||||||
|
/// <c>applicationrole</c> — see Sql/2026-08-05_roles.sql.
|
||||||
|
/// </summary>
|
||||||
|
public static class Roles
|
||||||
|
{
|
||||||
|
/// <summary>May manage user accounts.</summary>
|
||||||
|
public const string Admin = "Admin";
|
||||||
|
|
||||||
|
/// <summary>May use everything else. The absence of a role means the same thing.</summary>
|
||||||
|
public const string User = "User";
|
||||||
|
|
||||||
|
public static readonly string[] All = { Admin, User };
|
||||||
|
|
||||||
|
/// <summary>Case-insensitive, so 'admin' typed into the SQL seed still counts.</summary>
|
||||||
|
public static bool IsAdmin(this ApplicationUser user)
|
||||||
|
{
|
||||||
|
return user.Names().Any(name => Admin.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user's role names. Empty when the user holds no role, and also when the
|
||||||
|
/// query did not include them — callers that authorize must load them.
|
||||||
|
/// </summary>
|
||||||
|
public static List<string> Names(this ApplicationUser user)
|
||||||
|
{
|
||||||
|
return user?.UserRoles?
|
||||||
|
.Select(userRole => userRole.Role?.Name)
|
||||||
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
|
.OrderBy(name => name)
|
||||||
|
.ToList()
|
||||||
|
?? new List<string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LaDOSE.Entity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One spreadsheet write: the tables to put in it, and which spreadsheet.
|
||||||
|
/// Not an entity — never mapped by EF. It arrives as SheetExportRequestDTO and is
|
||||||
|
/// mapped by AutoMapper, same pattern as <see cref="MatchStats"/>, so property names
|
||||||
|
/// must stay identical to the DTO's.
|
||||||
|
///
|
||||||
|
/// The tables are built client-side by $lib/tournaments/cumulative.ts, which reuses the
|
||||||
|
/// very same buildRanking() that produces the CSV export. That is deliberate: the sheet
|
||||||
|
/// and the CSV cannot drift because one function produces both.
|
||||||
|
/// </summary>
|
||||||
|
public class SheetExportRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolved by SheetsExportService from configuration — never supplied by the caller,
|
||||||
|
/// so a client cannot aim the export at someone else's spreadsheet.
|
||||||
|
/// </summary>
|
||||||
|
public string SpreadsheetId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One tab per selected event, oldest first. Tab N holds events 1..N merged, so the
|
||||||
|
/// last tab is the full cumulative ranking for the scope.
|
||||||
|
/// </summary>
|
||||||
|
public List<SheetTable> Tabs { get; set; } = new List<SheetTable>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One tab: a players x games grid with a Total column.</summary>
|
||||||
|
public class SheetTable
|
||||||
|
{
|
||||||
|
/// <summary>Desired tab title, e.g. "Ranking #1301". Sanitised before use.</summary>
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provenance and logging only. Tabs are addressed by title, never by this — see the
|
||||||
|
/// note on tab identity in SheetsExportService.
|
||||||
|
/// </summary>
|
||||||
|
public int EventId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>["Players", <game names...>, "Total"].</summary>
|
||||||
|
public List<string> Header { get; set; } = new List<string>();
|
||||||
|
|
||||||
|
public List<SheetRow> Rows { get; set; } = new List<SheetRow>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Written below the grid, one line per entry, blank row in between. Carries which
|
||||||
|
/// events went into this tab so a stale or incomplete tab identifies itself.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Footer { get; set; } = new List<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SheetRow
|
||||||
|
{
|
||||||
|
public string Player { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Index-aligned with Header[1..^1] — one entry per game column.</summary>
|
||||||
|
public List<int> Points { get; set; } = new List<int>();
|
||||||
|
|
||||||
|
public int Total { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SheetExportResult
|
||||||
|
{
|
||||||
|
public string SpreadsheetId { get; set; }
|
||||||
|
public string SpreadsheetUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Which ISheetsWriter did the work — "ServiceAccount", "Logging", ...</summary>
|
||||||
|
public string Writer { get; set; }
|
||||||
|
|
||||||
|
public List<SheetTabResult> Tabs { get; set; } = new List<SheetTabResult>();
|
||||||
|
|
||||||
|
/// <summary>Anything the user should know but that did not stop the write, e.g. renames.</summary>
|
||||||
|
public List<string> Warnings { get; set; } = new List<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SheetTabResult
|
||||||
|
{
|
||||||
|
/// <summary>The title as asked for, before sanitisation.</summary>
|
||||||
|
public string RequestedName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The title actually written to.</summary>
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
public int Rows { get; set; }
|
||||||
|
public int Columns { get; set; }
|
||||||
|
|
||||||
|
/// <summary>False when an existing tab of that name was rewritten in place.</summary>
|
||||||
|
public bool Created { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What the UI needs to render the export panel. Never carries a secret.</summary>
|
||||||
|
public class SheetsConfig
|
||||||
|
{
|
||||||
|
public string Writer { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The writer has its credentials and a target spreadsheet is configured.</summary>
|
||||||
|
public bool Configured { get; set; }
|
||||||
|
|
||||||
|
public string SpreadsheetId { get; set; }
|
||||||
|
public int MaxTabs { get; set; }
|
||||||
|
public int MaxRowsPerTab { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side limits and the target spreadsheet, read from the GoogleSheets configuration
|
||||||
|
/// section. Constructor-injected rather than IOptions, matching how the Challonge and Smash
|
||||||
|
/// providers take their keys.
|
||||||
|
/// </summary>
|
||||||
|
public class SheetsSettings
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The spreadsheet everything is written to. Reset each year — see .env.example.
|
||||||
|
/// </summary>
|
||||||
|
public string SpreadsheetId { get; set; }
|
||||||
|
|
||||||
|
public int MaxTabs { get; set; } = 60;
|
||||||
|
public int MaxRowsPerTab { get; set; } = 5000;
|
||||||
|
|
||||||
|
/// <summary>Google caps a tab at 100 chars; this caps the grid width.</summary>
|
||||||
|
public int MaxColumns { get; set; } = 200;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LaDOSE.Entity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregate over the persisted <see cref="Set"/> rows of one or more Events.
|
||||||
|
/// Not an entity: it is never mapped by EF, it is computed in memory by
|
||||||
|
/// StatisticsService and mapped to MatchStatsDTO by AutoMapper (same pattern as
|
||||||
|
/// <see cref="Challonge.TournamentsResult"/>).
|
||||||
|
/// Property names must stay identical to the DTO's, the mapping is by convention.
|
||||||
|
/// </summary>
|
||||||
|
public class MatchStats
|
||||||
|
{
|
||||||
|
/// <summary>How much of the requested scope actually has set data behind it.</summary>
|
||||||
|
public MatchCoverage Coverage { get; set; } = new MatchCoverage();
|
||||||
|
|
||||||
|
public List<PlayerMatchStats> Players { get; set; } = new List<PlayerMatchStats>();
|
||||||
|
|
||||||
|
public List<HeadToHead> HeadToHead { get; set; } = new List<HeadToHead>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set coverage is sparse: Challonge-imported events have no sets at all, and events
|
||||||
|
/// imported before SmashProvider.GetSets existed were never backfilled. These counters
|
||||||
|
/// let a caller say "12 of 40 brackets have match data" instead of implying completeness.
|
||||||
|
/// </summary>
|
||||||
|
public class MatchCoverage
|
||||||
|
{
|
||||||
|
/// <summary>Requested event ids that actually exist.</summary>
|
||||||
|
public int Events { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Tournaments (brackets) belonging to those events.</summary>
|
||||||
|
public int Brackets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Of those brackets, how many have at least one set row.</summary>
|
||||||
|
public int BracketsWithSets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Total set rows in scope, including the ones skipped as unusable.</summary>
|
||||||
|
public int Sets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Sets with a determinable winner (unequal scores, two distinct real players).</summary>
|
||||||
|
public int DecidedSets { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlayerMatchStats
|
||||||
|
{
|
||||||
|
public int PlayerId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gamertag, falling back to Name, else "#<id>".</summary>
|
||||||
|
public string Player { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Decided sets only. Always equals Wins + Losses.</summary>
|
||||||
|
public int Sets { get; set; }
|
||||||
|
|
||||||
|
public int Wins { get; set; }
|
||||||
|
public int Losses { get; set; }
|
||||||
|
public int GamesWon { get; set; }
|
||||||
|
public int GamesLost { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row per unordered pair of players with at least one decided set.
|
||||||
|
/// A is always the lower PlayerId so WinsA / WinsB are unambiguous.
|
||||||
|
/// </summary>
|
||||||
|
public class HeadToHead
|
||||||
|
{
|
||||||
|
public int PlayerAId { get; set; }
|
||||||
|
public string PlayerA { get; set; }
|
||||||
|
public int PlayerBId { get; set; }
|
||||||
|
public string PlayerB { get; set; }
|
||||||
|
public int WinsA { get; set; }
|
||||||
|
public int WinsB { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LaDOSE.Entity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Every recorded meeting between two players, broken down per game.
|
||||||
|
/// Not an entity: computed in memory by StatisticsService and mapped to
|
||||||
|
/// PlayerVersusDTO by AutoMapper, same as <see cref="MatchStats"/>.
|
||||||
|
/// Property names must stay identical to the DTO's, the mapping is by convention.
|
||||||
|
///
|
||||||
|
/// A <see cref="Set"/> carries no game of its own — the game comes from the
|
||||||
|
/// <see cref="Tournament"/> the set belongs to, and that Tournament.GameId is
|
||||||
|
/// nullable. Sets behind a bracket with no game are counted in
|
||||||
|
/// <see cref="UnknownGameSets"/> and excluded from everything else, because a
|
||||||
|
/// per-game breakdown cannot say anything about them.
|
||||||
|
/// </summary>
|
||||||
|
public class PlayerVersus
|
||||||
|
{
|
||||||
|
public int PlayerAId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gamertag, falling back to Name, else "#<id>".</summary>
|
||||||
|
public string PlayerA { get; set; }
|
||||||
|
|
||||||
|
public int PlayerBId { get; set; }
|
||||||
|
|
||||||
|
public string PlayerB { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Meetings in a bracket whose game is known — the sum of the rows below.</summary>
|
||||||
|
public int Sets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Of those, the ones with a determinable winner (unequal scores).</summary>
|
||||||
|
public int DecidedSets { get; set; }
|
||||||
|
|
||||||
|
public int WinsA { get; set; }
|
||||||
|
public int WinsB { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Meetings dropped because the bracket has no game attached. Reported rather
|
||||||
|
/// than hidden: it is the difference between "they never met" and "we cannot
|
||||||
|
/// tell which game they met in".
|
||||||
|
/// </summary>
|
||||||
|
public int UnknownGameSets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>One row per game they actually met in, most-played first.</summary>
|
||||||
|
public List<VersusGameStats> Games { get; set; } = new List<VersusGameStats>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One game's slice of a <see cref="PlayerVersus"/>.</summary>
|
||||||
|
public class VersusGameStats
|
||||||
|
{
|
||||||
|
public int GameId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Game.Name, or "#<id>" when the game row is gone.</summary>
|
||||||
|
public string Game { get; set; }
|
||||||
|
|
||||||
|
public string GameLongName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Meetings in this game, decided or not.</summary>
|
||||||
|
public int Sets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Meetings with a determinable winner. Always equals WinsA + WinsB.</summary>
|
||||||
|
public int DecidedSets { get; set; }
|
||||||
|
|
||||||
|
public int WinsA { get; set; }
|
||||||
|
public int WinsB { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Individual games won inside the decided sets (a DQ, stored as -1, clamps to 0).</summary>
|
||||||
|
public int GamesWonA { get; set; }
|
||||||
|
|
||||||
|
public int GamesWonB { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A player who can be picked for a versus lookup, i.e. one with at least one set
|
||||||
|
/// in a bracket whose game is known. Offering anyone else would mean offering
|
||||||
|
/// players the breakdown must then report as empty.
|
||||||
|
/// </summary>
|
||||||
|
public class PlayerOption
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gamertag, falling back to Name, else "#<id>".</summary>
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Sets in a bracket with a known game, whoever the opponent was.</summary>
|
||||||
|
public int Sets { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Platforms>AnyCPU;x64</Platforms>
|
<Platforms>AnyCPU;x64</Platforms>
|
||||||
|
<LangVersion>12</LangVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="RestSharp" Version="106.11.4" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
|
<PackageReference Include="RestSharp" Version="112.1.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using LaDOSE.DTO;
|
using LaDOSE.DTO;
|
||||||
using LaDOSE.REST.Event;
|
using LaDOSE.REST.Event;
|
||||||
|
using Newtonsoft.Json;
|
||||||
using RestSharp;
|
using RestSharp;
|
||||||
using RestSharp.Authenticators;
|
using RestSharp.Authenticators;
|
||||||
using RestSharp.Serialization.Json;
|
|
||||||
|
|
||||||
namespace LaDOSE.REST
|
namespace LaDOSE.REST
|
||||||
{
|
{
|
||||||
@@ -23,39 +24,54 @@ namespace LaDOSE.REST
|
|||||||
|
|
||||||
public event EventHandler<UpdatedJwtEventHandler> UpdatedJwtEvent;
|
public event EventHandler<UpdatedJwtEventHandler> UpdatedJwtEvent;
|
||||||
|
|
||||||
public RestService() { }
|
|
||||||
|
public RestService()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
public RestService(Uri url, string user, string password)
|
||||||
|
{
|
||||||
|
Client = new RestClient(url);
|
||||||
|
this.username = user;
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public void Connect(Uri url, string user, string password)
|
public void Connect(Uri url, string user, string password)
|
||||||
{
|
{
|
||||||
Client = new RestClient(url);
|
// Client = new RestClient(url);
|
||||||
|
// this.username = user;
|
||||||
|
// this.password = password;
|
||||||
|
string token;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
token = GetToken(user, password);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Client = new RestClient(url, options =>
|
||||||
|
{
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
Client.Timeout = 999*1000;
|
options.MaxTimeout = Int32.MaxValue;
|
||||||
#endif
|
#endif
|
||||||
this.username = user;
|
options.Authenticator = new JwtAuthenticator(token);
|
||||||
this.password = password;
|
});
|
||||||
GetToken(user, password);
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void GetToken(string user, string password)
|
private string GetToken(string user, string password)
|
||||||
{
|
{
|
||||||
var restRequest = new RestRequest("users/auth", Method.POST);
|
var restRequest = new RestRequest("users/auth", Method.Post);
|
||||||
restRequest.AddJsonBody(new {username = user, password = password});
|
restRequest.AddJsonBody(new {username = user, password = password});
|
||||||
|
|
||||||
var response = Client.Post(restRequest);
|
var response = Client.Post<ApplicationUserDTO>(restRequest);
|
||||||
if (response.IsSuccessful)
|
//var applicationUser = JsonConvert.DeserializeObject<ApplicationUserDTO>(response.Content);
|
||||||
{
|
this.Auth = response;
|
||||||
JsonDeserializer d = new JsonDeserializer();
|
return response.Token;
|
||||||
var applicationUser = d.Deserialize<ApplicationUserDTO>(response);
|
|
||||||
this.Auth = applicationUser;
|
|
||||||
Client.Authenticator = new JwtAuthenticator($"{applicationUser.Token}");
|
|
||||||
RaiseUpdatedJwtEvent(new UpdatedJwtEventHandler(this.Auth));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
|
|
||||||
throw new Exception("unable to contact services");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RaiseUpdatedJwtEvent(UpdatedJwtEventHandler auth)
|
private void RaiseUpdatedJwtEvent(UpdatedJwtEventHandler auth)
|
||||||
@@ -69,126 +85,128 @@ namespace LaDOSE.REST
|
|||||||
|
|
||||||
private void CheckToken()
|
private void CheckToken()
|
||||||
{
|
{
|
||||||
if (this.Auth == null || this.Auth.Expire <= DateTime.Now)
|
if (this.Auth == null || this.Auth.Expire.ToUniversalTime() <= DateTime.Now.ToUniversalTime())
|
||||||
{
|
{
|
||||||
GetToken(this.username,this.password);
|
GetToken(this.username,this.password);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#region PostFix
|
// #region PostFix
|
||||||
|
//
|
||||||
private T Post<T>(string resource,T entity)
|
// private T Post<T>(string resource,T entity)
|
||||||
{
|
// {
|
||||||
var json = new RestSharp.Serialization.Json.JsonSerializer();
|
// var json = new RestSharp.Serialization.Json.JsonSerializer();
|
||||||
var jsonD = new RestSharp.Serialization.Json.JsonDeserializer();
|
// var jsonD = new RestSharp.Serialization.Json.JsonDeserializer();
|
||||||
var request = new RestRequest();
|
// var request = new RestRequest();
|
||||||
request.Method = Method.POST;
|
// request.Method = Method.Post;
|
||||||
request.Resource = resource;
|
// request.Resource = resource;
|
||||||
request.AddHeader("Accept", "application/json");
|
// request.AddHeader("Accept", "application/json");
|
||||||
request.AddHeader("Content-type", "application/json");
|
// request.AddHeader("Content-type", "application/json");
|
||||||
request.Parameters.Clear();
|
// request.Parameters.Clear();
|
||||||
request.AddParameter("application/json; charset=utf-8", json.Serialize(entity), ParameterType.RequestBody);
|
// request.AddParameter("application/json; charset=utf-8", json.Serialize(entity), ParameterType.RequestBody);
|
||||||
request.AddObject(entity);
|
|
||||||
var response = Client.Execute(request);
|
|
||||||
//var content = response.Content; // raw content as string
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return jsonD.Deserialize<T>(response);
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
return default(T);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
private R Post<P,R>(string resource, P entity)
|
|
||||||
{
|
|
||||||
var json = new RestSharp.Serialization.Json.JsonSerializer();
|
|
||||||
var jsonD = new RestSharp.Serialization.Json.JsonDeserializer();
|
|
||||||
var request = new RestRequest();
|
|
||||||
request.Method = Method.POST;
|
|
||||||
request.Resource = resource;
|
|
||||||
request.AddHeader("Accept", "application/json");
|
|
||||||
request.AddHeader("Content-type", "application/json");
|
|
||||||
request.Parameters.Clear();
|
|
||||||
request.AddParameter("application/json; charset=utf-8", json.Serialize(entity), ParameterType.RequestBody);
|
|
||||||
// request.AddObject(entity);
|
// request.AddObject(entity);
|
||||||
var response = Client.Execute(request);
|
// var response = Client.Execute(request);
|
||||||
//var content = response.Content; // raw content as string
|
// //var content = response.Content; // raw content as string
|
||||||
try
|
// try
|
||||||
{
|
// {
|
||||||
return jsonD.Deserialize<R>(response);
|
// return jsonD.Deserialize<T>(response);
|
||||||
}
|
// }
|
||||||
catch (Exception)
|
// catch (Exception)
|
||||||
{
|
// {
|
||||||
return default(R);
|
// return default(T);
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
|
//
|
||||||
}
|
// }
|
||||||
|
// private R Post<P,R>(string resource, P entity)
|
||||||
#endregion
|
// {
|
||||||
|
// var json = new RestSharp.Serialization.Json.JsonSerializer();
|
||||||
|
// var jsonD = new RestSharp.Serialization.Json.JsonDeserializer();
|
||||||
|
// var request = new RestRequest();
|
||||||
|
// request.Method = Method.Post;
|
||||||
|
// request.Resource = resource;
|
||||||
|
// request.AddHeader("Accept", "application/json");
|
||||||
|
// request.AddHeader("Content-type", "application/json");
|
||||||
|
// request.Parameters.Clear();
|
||||||
|
// request.AddParameter("application/json; charset=utf-8", json.Serialize(entity), ParameterType.RequestBody);
|
||||||
|
// //request.AddObject(entity);
|
||||||
|
// var response = Client.Execute(request);
|
||||||
|
// //var content = response.Content; // raw content as string
|
||||||
|
// try
|
||||||
|
// {
|
||||||
|
// return jsonD.Deserialize<R>(response);
|
||||||
|
// }
|
||||||
|
// catch (Exception)
|
||||||
|
// {
|
||||||
|
// return default(R);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// #endregion
|
||||||
|
|
||||||
#region WordPress
|
#region WordPress
|
||||||
public List<WPEventDTO> GetEvents()
|
public List<WPEventDTO> GetEvents()
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
var restRequest = new RestRequest("/api/wordpress/WPEvent", Method.GET);
|
var restRequest = new RestRequest("/api/wordpress/WPEvent", Method.Get);
|
||||||
var restResponse = Client.Get<List<WPEventDTO>>(restRequest);
|
var restResponse = Client.Get<List<WPEventDTO>>(restRequest);
|
||||||
return restResponse.Data;
|
return restResponse;
|
||||||
}
|
}
|
||||||
public WPEventDTO GetNextEvent()
|
public WPEventDTO GetNextEvent()
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
var restRequest = new RestRequest("/api/wordpress/NextEvent", Method.GET);
|
var restRequest = new RestRequest("/api/wordpress/NextEvent", Method.Get);
|
||||||
var restResponse = Client.Get<WPEventDTO>(restRequest);
|
var restResponse = Client.Get<WPEventDTO>(restRequest);
|
||||||
return restResponse.Data;
|
return restResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public string GetLastChallonge()
|
public string GetLastChallonge()
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
var restRequest = new RestRequest($"/api/wordpress/GetLastChallonge/", Method.GET);
|
var restRequest = new RestRequest($"/api/wordpress/GetLastChallonge/", Method.Get);
|
||||||
var restResponse = Client.Get(restRequest);
|
var restResponse = Client.Get(restRequest);
|
||||||
return restResponse.Content;
|
return restResponse.Content;
|
||||||
}
|
}
|
||||||
public string CreateChallonge(int gameId, int eventId)
|
public string CreateChallonge(int gameId, int eventId)
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
var restRequest = new RestRequest($"/api/wordpress/CreateChallonge/{gameId}/{eventId}", Method.GET);
|
var restRequest = new RestRequest($"/api/wordpress/CreateChallonge/{gameId}/{eventId}", Method.Get);
|
||||||
var restResponse = Client.Get(restRequest);
|
var restResponse = Client.Get(restRequest);
|
||||||
return restResponse.Content;
|
return restResponse.Content;
|
||||||
}
|
}
|
||||||
public string CreateChallonge2(int gameId, int eventId, List<WPUserDTO> optionalPlayers)
|
public string CreateChallonge2(int gameId, int eventId, List<WPUserDTO> optionalPlayers)
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
var restResponse = Post<List<WPUserDTO>,string>($"/api/wordpress/CreateChallonge/{gameId}/{eventId}",optionalPlayers);
|
RestRequest r =
|
||||||
|
new RestRequest($"/api/wordpress/CreateChallonge/{gameId}/{eventId}").AddJsonBody(optionalPlayers);
|
||||||
|
var restResponse = Client.Post<string>(r);
|
||||||
return restResponse;
|
return restResponse;
|
||||||
}
|
}
|
||||||
public bool RefreshDb()
|
public bool RefreshDb()
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
var restRequest = new RestRequest("/api/Wordpress/UpdateDb", Method.GET);
|
var restRequest = new RestRequest("/api/Wordpress/UpdateDb", Method.Get);
|
||||||
var restResponse = Client.Get<bool>(restRequest);
|
var restResponse = Client.Get<bool>(restRequest);
|
||||||
return restResponse.Data;
|
return restResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<WPUserDTO> GetUsers(int wpEventId, int gameId)
|
public List<WPUserDTO> GetUsers(int wpEventId, int gameId)
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
var restRequest = new RestRequest($"/api/Wordpress/GetUsers/{wpEventId}/{gameId}", Method.GET);
|
var restRequest = new RestRequest($"/api/Wordpress/GetUsers/{wpEventId}/{gameId}", Method.Get);
|
||||||
var restResponse = Client.Get<List<WPUserDTO>>(restRequest);
|
var restResponse = Client.Get<List<WPUserDTO>>(restRequest);
|
||||||
return restResponse.Data;
|
return restResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<WPUserDTO> GetUsersOptions(int wpEventId, int gameId)
|
public List<WPUserDTO> GetUsersOptions(int wpEventId, int gameId)
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
var restRequest = new RestRequest($"/api/Wordpress/GetUsersOptions/{wpEventId}/{gameId}", Method.GET);
|
var restRequest = new RestRequest($"/api/Wordpress/GetUsersOptions/{wpEventId}/{gameId}", Method.Get);
|
||||||
var restResponse = Client.Get<List<WPUserDTO>>(restRequest);
|
var restResponse = Client.Get<List<WPUserDTO>>(restRequest);
|
||||||
return restResponse.Data;
|
return restResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -198,20 +216,29 @@ namespace LaDOSE.REST
|
|||||||
public List<GameDTO> GetGames()
|
public List<GameDTO> GetGames()
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
var restRequest = new RestRequest("/api/Game", Method.GET);
|
var restRequest = new RestRequest("/api/Game", Method.Get);
|
||||||
var restResponse = Client.Get<List<GameDTO>>(restRequest);
|
var restResponse = Client.Get<List<GameDTO>>(restRequest);
|
||||||
return restResponse.Data;
|
return restResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<GameDTO> GetSmashGames(string name)
|
||||||
|
{
|
||||||
|
CheckToken();
|
||||||
|
var restRequest = new RestRequest($"/api/Game/Smash/{name}", Method.Get);
|
||||||
|
var restResponse = Client.Get<List<GameDTO>>(restRequest);
|
||||||
|
return restResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
public GameDTO UpdateGame(GameDTO game)
|
public GameDTO UpdateGame(GameDTO game)
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
return Post("Api/Game", game);
|
RestRequest r = new RestRequest("Api/Game").AddJsonBody(game);
|
||||||
|
return Client.Post<GameDTO>(r);
|
||||||
}
|
}
|
||||||
public bool DeleteGame(int gameId)
|
public bool DeleteGame(int gameId)
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
var restRequest = new RestRequest($"/api/Game/{gameId}", Method.DELETE);
|
var restRequest = new RestRequest($"/api/Game/{gameId}", Method.Delete);
|
||||||
var restResponse = Client.Execute(restRequest);
|
var restResponse = Client.Execute(restRequest);
|
||||||
return restResponse.IsSuccessful;
|
return restResponse.IsSuccessful;
|
||||||
}
|
}
|
||||||
@@ -227,26 +254,27 @@ namespace LaDOSE.REST
|
|||||||
public List<TodoDTO> GetTodos()
|
public List<TodoDTO> GetTodos()
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
var restRequest = new RestRequest("/api/Todo", Method.GET);
|
var restRequest = new RestRequest("/api/Todo", Method.Get);
|
||||||
var restResponse = Client.Get<List<TodoDTO>>(restRequest);
|
var restResponse = Client.Get<List<TodoDTO>>(restRequest);
|
||||||
return restResponse.Data;
|
return restResponse;
|
||||||
}
|
}
|
||||||
public TodoDTO GetTodoById(int id)
|
public TodoDTO GetTodoById(int id)
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
var restRequest = new RestRequest($"/api/Todo/{id}", Method.GET);
|
var restRequest = new RestRequest($"/api/Todo/{id}", Method.Get);
|
||||||
var restResponse = Client.Get<TodoDTO>(restRequest);
|
var restResponse = Client.Get<TodoDTO>(restRequest);
|
||||||
return restResponse.Data;
|
return restResponse;
|
||||||
}
|
}
|
||||||
public TodoDTO UpdateTodo(TodoDTO Todo)
|
public TodoDTO UpdateTodo(TodoDTO Todo)
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
return Post("Api/Todo", Todo);
|
var restRequest = new RestRequest($"/api/Todo/", Method.Post).AddJsonBody(Todo);
|
||||||
|
return Client.Post<TodoDTO>(restRequest);
|
||||||
}
|
}
|
||||||
public bool DeleteTodo(int todoId)
|
public bool DeleteTodo(int todoId)
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
var restRequest = new RestRequest($"/api/Todo/{todoId}", Method.DELETE);
|
var restRequest = new RestRequest($"/api/Todo/{todoId}", Method.Delete);
|
||||||
var restResponse = Client.Execute(restRequest);
|
var restResponse = Client.Execute(restRequest);
|
||||||
return restResponse.IsSuccessful;
|
return restResponse.IsSuccessful;
|
||||||
}
|
}
|
||||||
@@ -258,9 +286,9 @@ namespace LaDOSE.REST
|
|||||||
public TournamentsResultDTO Test(string test)
|
public TournamentsResultDTO Test(string test)
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
var restRequest = new RestRequest($"Api/Test/Test/{test}", Method.GET);
|
var restRequest = new RestRequest($"Api/Test/Test/{test}", Method.Get);
|
||||||
var restResponse = Client.Get<TournamentsResultDTO>(restRequest);
|
var restResponse = Client.Get<TournamentsResultDTO>(restRequest);
|
||||||
return restResponse.Data;
|
return restResponse;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,14 +298,17 @@ namespace LaDOSE.REST
|
|||||||
public List<TournamentDTO> GetTournaments(TimeRangeDTO timeRange)
|
public List<TournamentDTO> GetTournaments(TimeRangeDTO timeRange)
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
List<TournamentDTO> tournamentDtos = Post<TimeRangeDTO, List<TournamentDTO>>("/api/Tournament/GetTournaments",timeRange);
|
RestRequest r = new RestRequest("/api/Tournament/GetTournaments").AddJsonBody(timeRange);
|
||||||
|
List<TournamentDTO> tournamentDtos = Client.Post<List<TournamentDTO>>(r);
|
||||||
return tournamentDtos;
|
return tournamentDtos;
|
||||||
}
|
}
|
||||||
|
|
||||||
public TournamentsResultDTO GetResults(List<int> ids)
|
public TournamentsResultDTO GetResults(List<int> ids)
|
||||||
{
|
{
|
||||||
|
|
||||||
CheckToken();
|
CheckToken();
|
||||||
return Post<List<int>,TournamentsResultDTO>("Api/Tournament/GetResults", ids);
|
var restRequest = new RestRequest("Api/Tournament/GetResults", Method.Post).AddJsonBody(ids);
|
||||||
|
return Client.Post<TournamentsResultDTO>(restRequest);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,33 +316,34 @@ namespace LaDOSE.REST
|
|||||||
public bool ParseSmash(string slug)
|
public bool ParseSmash(string slug)
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
var restRequest = new RestRequest($"Api/Tournament/ParseSmash/{slug}", Method.GET);
|
var restRequest = new RestRequest($"Api/Tournament/ParseSmash/{slug}", Method.Get);
|
||||||
var restResponse = Client.Get<bool>(restRequest);
|
var restResponse = Client.Get<bool>(restRequest);
|
||||||
return restResponse.Data;
|
return restResponse;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool ParseChallonge(List<int> ids)
|
public bool ParseChallonge(List<int> ids)
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
return Post<List<int>, bool>("Api/Tournament/ParseChallonge", ids);
|
var restRequest = new RestRequest("Api/Tournament/ParseChallonge", Method.Post).AddJsonBody(ids);
|
||||||
|
return Client.Post<bool>(restRequest);
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
#region Tournamenet Event / Player
|
#region Tournamenet Event / Player
|
||||||
public List<EventDTO> GetAllEvents()
|
public List<EventDTO> GetAllEvents()
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
var restRequest = new RestRequest("/api/Event", Method.GET);
|
var restRequest = new RestRequest("/api/Event", Method.Get);
|
||||||
var restResponse = Client.Get<List<EventDTO>>(restRequest);
|
var restResponse = Client.Get<List<EventDTO>>(restRequest);
|
||||||
return restResponse.Data;
|
return restResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<string> GetPlayers(string slug)
|
public List<string> GetPlayers(string slug)
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
var restRequest = new RestRequest($"/api/Tournament/GetPLayers/{slug}", Method.GET);
|
var restRequest = new RestRequest($"/api/Tournament/GetPLayers/{slug}", Method.Get);
|
||||||
var restResponse = Client.Get<List<string>>(restRequest);
|
var restResponse = Client.Get<List<string>>(restRequest);
|
||||||
return restResponse.Data;
|
return restResponse;
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -321,23 +353,24 @@ namespace LaDOSE.REST
|
|||||||
public bool CreateBotEvent(string eventName)
|
public bool CreateBotEvent(string eventName)
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
var restRequest = new RestRequest($"/api/BotEvent/CreateBotEvent/{eventName}", Method.GET);
|
var restRequest = new RestRequest($"/api/BotEvent/CreateBotEvent/{eventName}", Method.Get);
|
||||||
var restResponse = Client.Get<bool>(restRequest);
|
var restResponse = Client.Get<bool>(restRequest);
|
||||||
return restResponse.Data;
|
return restResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
public BotEventDTO GetLastBotEvent()
|
public BotEventDTO GetLastBotEvent()
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
var restRequest = new RestRequest($"/api/BotEvent/GetLastBotEvent/", Method.GET);
|
var restRequest = new RestRequest($"/api/BotEvent/GetLastBotEvent/", Method.Get);
|
||||||
var restResponse = Client.Post<BotEventDTO>(restRequest);
|
var restResponse = Client.Post<BotEventDTO>(restRequest);
|
||||||
return restResponse.Data;
|
return restResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool ResultBotEvent(BotEventSendDTO result)
|
public bool ResultBotEvent(BotEventSendDTO result)
|
||||||
{
|
{
|
||||||
CheckToken();
|
CheckToken();
|
||||||
return Post<BotEventSendDTO,bool>("/api/BotEvent/ResultBotEvent", result);
|
var restRequest = new RestRequest("/api/BotEvent/ResultBotEvent", Method.Post).AddJsonBody(result);
|
||||||
|
return Client.Post<bool>(restRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ namespace LaDOSE.Business.Interface
|
|||||||
{
|
{
|
||||||
Task<List<ChallongeTournament>> GetTournaments(DateTime? start, DateTime? end);
|
Task<List<ChallongeTournament>> GetTournaments(DateTime? start, DateTime? end);
|
||||||
Task<Event> ParseSmash(string tournamentSlug);
|
Task<Event> ParseSmash(string tournamentSlug);
|
||||||
|
|
||||||
|
Task<List<Game>> GetSmashGame(string name);
|
||||||
|
|
||||||
//Task<List<Event>> ParseChallonge(List<int> ids);
|
//Task<List<Event>> ParseChallonge(List<int> ids);
|
||||||
|
|
||||||
//Task<TournamentsResult> GetChallongeTournamentsResult(List<int> ids);
|
//Task<TournamentsResult> GetChallongeTournamentsResult(List<int> ids);
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LaDOSE.Entity;
|
||||||
|
|
||||||
|
namespace LaDOSE.Business.Interface
|
||||||
|
{
|
||||||
|
public interface ISheetsExportService
|
||||||
|
{
|
||||||
|
/// <summary>What the export panel needs to render. Never includes a secret.</summary>
|
||||||
|
SheetsConfig GetConfig();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the target spreadsheet from configuration, validates the tables, sanitises
|
||||||
|
/// and de-duplicates the tab titles, then hands off to the configured
|
||||||
|
/// <see cref="ISheetsWriter"/>. Throws <see cref="SheetsExportException"/> for anything
|
||||||
|
/// the caller can act on.
|
||||||
|
/// </summary>
|
||||||
|
Task<SheetExportResult> ExportAsync(SheetExportRequest request, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A failure with a message meant for the person who pressed the button, plus the status
|
||||||
|
/// the API should answer with. The API has no exception middleware, so anything that escapes
|
||||||
|
/// reaches the browser as an HTML developer page the client can only report as a bare status
|
||||||
|
/// — hence every foreseeable failure is raised as one of these instead.
|
||||||
|
/// </summary>
|
||||||
|
public class SheetsExportException : Exception
|
||||||
|
{
|
||||||
|
public SheetsExportException(int statusCode, string message) : base(message)
|
||||||
|
{
|
||||||
|
StatusCode = statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SheetsExportException(int statusCode, string message, Exception inner) : base(message, inner)
|
||||||
|
{
|
||||||
|
StatusCode = statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int StatusCode { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LaDOSE.Entity;
|
||||||
|
|
||||||
|
namespace LaDOSE.Business.Interface
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Writes tabular data into a Google Spreadsheet. Startup picks one implementation from
|
||||||
|
/// GoogleSheets:Writer, so nothing above this interface knows how Google is reached.
|
||||||
|
///
|
||||||
|
/// Implementations must be idempotent: writing the same request twice leaves the same
|
||||||
|
/// spreadsheet. They must also leave tabs they were not asked about completely alone —
|
||||||
|
/// the spreadsheet holds hand-made summaries and charts, and deletion is irreversible
|
||||||
|
/// through the API.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISheetsWriter
|
||||||
|
{
|
||||||
|
/// <summary>Reported to the UI so the panel can say what it is talking to.</summary>
|
||||||
|
string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>False when credentials are missing, so the API can answer 503 with a message.</summary>
|
||||||
|
bool IsConfigured { get; }
|
||||||
|
|
||||||
|
Task<SheetExportResult> WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ namespace LaDOSE.Business.Interface
|
|||||||
|
|
||||||
Task<TournamentResponse> GetNames(string slug);
|
Task<TournamentResponse> GetNames(string slug);
|
||||||
|
|
||||||
|
Task<List<Game>> GetGames(string name);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LaDOSE.Entity;
|
||||||
|
|
||||||
|
namespace LaDOSE.Business.Interface
|
||||||
|
{
|
||||||
|
public interface IStatisticsService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregate the persisted sets of the given Events into per-player records,
|
||||||
|
/// head-to-head records and a coverage report.
|
||||||
|
/// A null or empty id list yields a well-formed, zeroed <see cref="MatchStats"/>.
|
||||||
|
/// </summary>
|
||||||
|
Task<MatchStats> GetMatchStats(List<int> eventIds);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The players a versus lookup can say something about: those with at least one
|
||||||
|
/// set in a bracket whose game is known, by display name.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<PlayerOption>> GetVersusPlayers();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every recorded meeting between two players, all events, broken down per game.
|
||||||
|
/// Sets whose bracket has no game are excluded and only counted in
|
||||||
|
/// <see cref="PlayerVersus.UnknownGameSets"/>.
|
||||||
|
/// Missing, equal or unknown ids yield a well-formed empty <see cref="PlayerVersus"/>.
|
||||||
|
/// </summary>
|
||||||
|
Task<PlayerVersus> GetVersus(int playerAId, int playerBId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,11 @@ namespace LaDOSE.Business.Interface
|
|||||||
ApplicationUser Authenticate(string username, string password);
|
ApplicationUser Authenticate(string username, string password);
|
||||||
IEnumerable<ApplicationUser> GetAll();
|
IEnumerable<ApplicationUser> GetAll();
|
||||||
ApplicationUser GetById(int id);
|
ApplicationUser GetById(int id);
|
||||||
ApplicationUser Create(ApplicationUser user, string password);
|
ApplicationUser Create(ApplicationUser user, string password, IEnumerable<string> roleNames = null);
|
||||||
void Update(ApplicationUser user, string password = null);
|
void Update(ApplicationUser user, string password = null);
|
||||||
void Delete(int id);
|
void Delete(int id);
|
||||||
|
|
||||||
|
/// <summary>The roles that exist in the database — reference data, not created at runtime.</summary>
|
||||||
|
IEnumerable<ApplicationRole> GetAllRoles();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,6 @@ namespace LaDOSE.Business.Interface
|
|||||||
List<WPEvent> GetWpEvent();
|
List<WPEvent> GetWpEvent();
|
||||||
List<WPUser> GetBooking(int wpEventId, Game game);
|
List<WPUser> GetBooking(int wpEventId, Game game);
|
||||||
List<WPUser> GetBookingOptions(int wpEventId, Game game);
|
List<WPUser> GetBookingOptions(int wpEventId, Game game);
|
||||||
bool UpdateBooking();
|
|
||||||
string CreateChallonge(int gameId, int wpEventId, IList<WPUser> additionPlayers);
|
string CreateChallonge(int gameId, int wpEventId, IList<WPUser> additionPlayers);
|
||||||
|
|
||||||
Task<string> GetLastChallonge();
|
Task<string> GetLastChallonge();
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<AssemblyName>LaDOSE.Business</AssemblyName>
|
<AssemblyName>LaDOSE.Business</AssemblyName>
|
||||||
<RootNamespace>LaDOSE.Business</RootNamespace>
|
<RootNamespace>LaDOSE.Business</RootNamespace>
|
||||||
<Platforms>AnyCPU;x64</Platforms>
|
<Platforms>AnyCPU;x64</Platforms>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="GraphQL.Client" Version="4.0.2" />
|
<PackageReference Include="Google.Apis.Sheets.v4" Version="1.75.0.4178" />
|
||||||
<PackageReference Include="GraphQL.Client.Serializer.Newtonsoft" Version="4.0.2" />
|
<PackageReference Include="GraphQL.Client" Version="6.1.0" />
|
||||||
|
<PackageReference Include="GraphQL.Client.Serializer.Newtonsoft" Version="6.1.0" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.12" />
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LaDOSE.Business.Interface;
|
||||||
|
using LaDOSE.Entity;
|
||||||
|
|
||||||
|
namespace LaDOSE.Business.Provider.SheetsProvider
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The default when GoogleSheets:Writer names nothing usable. Reports itself as
|
||||||
|
/// unconfigured so the API answers 503 with a message the user can act on, rather than
|
||||||
|
/// throwing a NullReferenceException that reaches the browser as an HTML developer page.
|
||||||
|
/// </summary>
|
||||||
|
public class DisabledSheetsWriter : ISheetsWriter
|
||||||
|
{
|
||||||
|
public string Name => "Disabled";
|
||||||
|
|
||||||
|
public bool IsConfigured => false;
|
||||||
|
|
||||||
|
public Task<SheetExportResult> WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
throw new SheetsExportException(503,
|
||||||
|
"Google Sheets export is not configured on the server. Set GoogleSheets:Writer.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Google;
|
||||||
|
using Google.Apis.Auth.OAuth2;
|
||||||
|
using Google.Apis.Services;
|
||||||
|
using Google.Apis.Sheets.v4;
|
||||||
|
using Google.Apis.Sheets.v4.Data;
|
||||||
|
using LaDOSE.Business.Interface;
|
||||||
|
using LaDOSE.Entity;
|
||||||
|
|
||||||
|
namespace LaDOSE.Business.Provider.SheetsProvider
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Writes to Google Sheets as a service account.
|
||||||
|
///
|
||||||
|
/// Two HTTP calls: read the existing tab list, then one batchUpdate. That second call is
|
||||||
|
/// atomic — either every tab lands or none does — so a failure can never leave the ranking
|
||||||
|
/// half updated.
|
||||||
|
///
|
||||||
|
/// Setup, once: create a service account, download its JSON key, point
|
||||||
|
/// GoogleSheets:ServiceAccount:CredentialsPath at it, and share the spreadsheet with the
|
||||||
|
/// account's ...iam.gserviceaccount.com address as Editor. That last step is the one people
|
||||||
|
/// forget, so a 403 says so explicitly and names the address.
|
||||||
|
/// </summary>
|
||||||
|
public class GoogleApiSheetsWriter : ISheetsWriter
|
||||||
|
{
|
||||||
|
private readonly string _credentialsPath;
|
||||||
|
|
||||||
|
public GoogleApiSheetsWriter(string credentialsPath)
|
||||||
|
{
|
||||||
|
_credentialsPath = credentialsPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name => "ServiceAccount";
|
||||||
|
|
||||||
|
public bool IsConfigured =>
|
||||||
|
!string.IsNullOrWhiteSpace(_credentialsPath) && File.Exists(_credentialsPath);
|
||||||
|
|
||||||
|
public async Task<SheetExportResult> WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!IsConfigured)
|
||||||
|
{
|
||||||
|
throw new SheetsExportException(503,
|
||||||
|
$"Service-account key not found at '{_credentialsPath}'. " +
|
||||||
|
"Check GoogleSheets:ServiceAccount:CredentialsPath and that the file is mounted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var service = CreateService(out var accountEmail);
|
||||||
|
|
||||||
|
var tabs = request.Tabs ?? new List<SheetTable>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 1. What is already in the spreadsheet: titles, ids, and current grid sizes.
|
||||||
|
var get = service.Spreadsheets.Get(request.SpreadsheetId);
|
||||||
|
get.Fields = "spreadsheetId,spreadsheetUrl,sheets.properties(sheetId,title,index,gridProperties)";
|
||||||
|
var spreadsheet = await get.ExecuteAsync(ct);
|
||||||
|
|
||||||
|
var existing = (spreadsheet.Sheets ?? new List<Sheet>())
|
||||||
|
.Select(sheet => sheet.Properties)
|
||||||
|
.Where(properties => properties != null)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// 2. One atomic batch for the whole export.
|
||||||
|
var batch = BuildBatch(existing, tabs, out var results);
|
||||||
|
if (batch.Requests.Count > 0)
|
||||||
|
{
|
||||||
|
await service.Spreadsheets.BatchUpdate(batch, request.SpreadsheetId).ExecuteAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SheetExportResult
|
||||||
|
{
|
||||||
|
SpreadsheetId = spreadsheet.SpreadsheetId ?? request.SpreadsheetId,
|
||||||
|
SpreadsheetUrl = spreadsheet.SpreadsheetUrl ?? LoggingSheetsWriter.SheetUrl(request.SpreadsheetId),
|
||||||
|
Writer = Name,
|
||||||
|
Tabs = results,
|
||||||
|
Warnings = new List<string>()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
throw new SheetsExportException(502,
|
||||||
|
$"Google refused access to spreadsheet '{request.SpreadsheetId}'. " +
|
||||||
|
$"Share it as Editor with {accountEmail ?? "the service account address"}.", ex);
|
||||||
|
}
|
||||||
|
catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
throw new SheetsExportException(502,
|
||||||
|
$"No spreadsheet with id '{request.SpreadsheetId}'. " +
|
||||||
|
"Check GoogleSheets:SpreadsheetId — it is the id from the sheet URL, not the whole URL.", ex);
|
||||||
|
}
|
||||||
|
catch (GoogleApiException ex)
|
||||||
|
{
|
||||||
|
throw new SheetsExportException(502, $"Google rejected the write: {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the credential from the key file explicitly. GoogleCredential.FromFile and
|
||||||
|
/// .FromJson are both deprecated, and ServiceAccountCredential is already an HTTP client
|
||||||
|
/// initializer, so there is nothing for GoogleCredential to add here. Doing it this way
|
||||||
|
/// also hands us the account's own address for the "share the sheet with…" message.
|
||||||
|
/// </summary>
|
||||||
|
private SheetsService CreateService(out string accountEmail)
|
||||||
|
{
|
||||||
|
ServiceAccountCredential credential;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(_credentialsPath);
|
||||||
|
var parameters = Google.Apis.Json.NewtonsoftJsonSerializer.Instance
|
||||||
|
.Deserialize<JsonCredentialParameters>(json);
|
||||||
|
|
||||||
|
if (parameters?.Type != JsonCredentialParameters.ServiceAccountCredentialType
|
||||||
|
|| string.IsNullOrEmpty(parameters.ClientEmail)
|
||||||
|
|| string.IsNullOrEmpty(parameters.PrivateKey))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"not a service-account key (expected \"type\": \"service_account\" with client_email and private_key)");
|
||||||
|
}
|
||||||
|
|
||||||
|
accountEmail = parameters.ClientEmail;
|
||||||
|
credential = new ServiceAccountCredential(
|
||||||
|
new ServiceAccountCredential.Initializer(parameters.ClientEmail)
|
||||||
|
{
|
||||||
|
ProjectId = parameters.ProjectId,
|
||||||
|
KeyId = parameters.PrivateKeyId,
|
||||||
|
Scopes = new[] { SheetsService.Scope.Spreadsheets }
|
||||||
|
}.FromPrivateKey(parameters.PrivateKey));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new SheetsExportException(503,
|
||||||
|
$"Could not read the service-account key at '{_credentialsPath}': {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SheetsService(new BaseClientService.Initializer
|
||||||
|
{
|
||||||
|
HttpClientInitializer = credential,
|
||||||
|
ApplicationName = "LaDOSE"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Batch construction
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns "what the spreadsheet has" plus "what we want" into one ordered request list.
|
||||||
|
/// Deliberately static and free of I/O so the whole batch can be asserted in a test with
|
||||||
|
/// no network — the same split StatisticsService uses for its pure Aggregate.
|
||||||
|
///
|
||||||
|
/// Order matters: create missing tabs, resize, reindex, then per tab clear and write.
|
||||||
|
/// No tab is ever deleted — unlisted tabs hold hand-made summaries and charts.
|
||||||
|
/// </summary>
|
||||||
|
public static BatchUpdateSpreadsheetRequest BuildBatch(
|
||||||
|
List<SheetProperties> existing,
|
||||||
|
List<SheetTable> tabs,
|
||||||
|
out List<SheetTabResult> results)
|
||||||
|
{
|
||||||
|
var requests = new List<Request>();
|
||||||
|
results = new List<SheetTabResult>();
|
||||||
|
|
||||||
|
var byTitle = (existing ?? new List<SheetProperties>())
|
||||||
|
.Where(properties => properties.Title != null)
|
||||||
|
.GroupBy(properties => properties.Title, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Ids for tabs that do not exist yet are only known after the batch runs, so the
|
||||||
|
// clear and write for those address the sheet by title through a placeholder id.
|
||||||
|
// AddSheet with an explicit SheetId avoids that entirely: we pick the ids ourselves.
|
||||||
|
var nextId = NextFreeSheetId(byTitle.Values);
|
||||||
|
|
||||||
|
for (var index = 0; index < tabs.Count; index++)
|
||||||
|
{
|
||||||
|
var tab = tabs[index];
|
||||||
|
var values = ToRowData(tab);
|
||||||
|
var rowCount = values.Count;
|
||||||
|
var columnCount = tab.Header?.Count ?? 0;
|
||||||
|
|
||||||
|
byTitle.TryGetValue(tab.Name, out var properties);
|
||||||
|
var created = properties == null;
|
||||||
|
|
||||||
|
int sheetId;
|
||||||
|
if (created)
|
||||||
|
{
|
||||||
|
sheetId = nextId++;
|
||||||
|
requests.Add(new Request
|
||||||
|
{
|
||||||
|
AddSheet = new AddSheetRequest
|
||||||
|
{
|
||||||
|
Properties = new SheetProperties
|
||||||
|
{
|
||||||
|
SheetId = sheetId,
|
||||||
|
Title = tab.Name,
|
||||||
|
Index = index,
|
||||||
|
GridProperties = new GridProperties
|
||||||
|
{
|
||||||
|
RowCount = Math.Max(rowCount, 1),
|
||||||
|
ColumnCount = Math.Max(columnCount, 1),
|
||||||
|
FrozenRowCount = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sheetId = properties.SheetId ?? 0;
|
||||||
|
|
||||||
|
// Grow before writing: the API silently drops cells outside the grid, and a
|
||||||
|
// sheet created by hand defaults to 1000 x 26.
|
||||||
|
var haveRows = properties.GridProperties?.RowCount ?? 0;
|
||||||
|
var haveColumns = properties.GridProperties?.ColumnCount ?? 0;
|
||||||
|
if (haveRows < rowCount || haveColumns < columnCount)
|
||||||
|
{
|
||||||
|
requests.Add(new Request
|
||||||
|
{
|
||||||
|
UpdateSheetProperties = new UpdateSheetPropertiesRequest
|
||||||
|
{
|
||||||
|
Fields = "gridProperties.rowCount,gridProperties.columnCount",
|
||||||
|
Properties = new SheetProperties
|
||||||
|
{
|
||||||
|
SheetId = sheetId,
|
||||||
|
GridProperties = new GridProperties
|
||||||
|
{
|
||||||
|
RowCount = Math.Max(haveRows, rowCount),
|
||||||
|
ColumnCount = Math.Max(haveColumns, columnCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oldest event leftmost, so the tabs read in season order.
|
||||||
|
if (properties.Index != index)
|
||||||
|
{
|
||||||
|
requests.Add(new Request
|
||||||
|
{
|
||||||
|
UpdateSheetProperties = new UpdateSheetPropertiesRequest
|
||||||
|
{
|
||||||
|
Fields = "index",
|
||||||
|
Properties = new SheetProperties { SheetId = sheetId, Index = index }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the whole sheet first, then write. Fields = "userEnteredValue" is the
|
||||||
|
// equivalent of clearContents(): colours, notes and conditional formatting the
|
||||||
|
// user set up by hand all survive. Without the clear, cells beyond the new
|
||||||
|
// extent would linger from a previous, longer export.
|
||||||
|
requests.Add(new Request
|
||||||
|
{
|
||||||
|
UpdateCells = new UpdateCellsRequest
|
||||||
|
{
|
||||||
|
Range = new GridRange { SheetId = sheetId },
|
||||||
|
Fields = "userEnteredValue"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
requests.Add(new Request
|
||||||
|
{
|
||||||
|
UpdateCells = new UpdateCellsRequest
|
||||||
|
{
|
||||||
|
Start = new GridCoordinate { SheetId = sheetId, RowIndex = 0, ColumnIndex = 0 },
|
||||||
|
Rows = values,
|
||||||
|
Fields = "userEnteredValue"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
results.Add(new SheetTabResult
|
||||||
|
{
|
||||||
|
RequestedName = tab.Name,
|
||||||
|
Name = tab.Name,
|
||||||
|
Rows = rowCount,
|
||||||
|
Columns = columnCount,
|
||||||
|
Created = created
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BatchUpdateSpreadsheetRequest { Requests = requests };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sheet ids must be unique within the spreadsheet and are ours to choose.</summary>
|
||||||
|
private static int NextFreeSheetId(IEnumerable<SheetProperties> existing)
|
||||||
|
{
|
||||||
|
var used = existing.Select(properties => properties.SheetId ?? 0).DefaultIfEmpty(0).Max();
|
||||||
|
return Math.Max(used + 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Header, then one row per player, then the footer after a blank line. Points and totals
|
||||||
|
/// go in as numbers rather than text so the user's formulas keep working.
|
||||||
|
/// </summary>
|
||||||
|
private static List<RowData> ToRowData(SheetTable tab)
|
||||||
|
{
|
||||||
|
var header = tab.Header ?? new List<string>();
|
||||||
|
var width = header.Count;
|
||||||
|
var rows = new List<RowData>
|
||||||
|
{
|
||||||
|
new RowData { Values = header.Select(Text).ToList() }
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var row in tab.Rows ?? new List<SheetRow>())
|
||||||
|
{
|
||||||
|
var cells = new List<CellData> { Text(row.Player) };
|
||||||
|
cells.AddRange((row.Points ?? new List<int>()).Select(Number));
|
||||||
|
cells.Add(Number(row.Total));
|
||||||
|
rows.Add(new RowData { Values = cells });
|
||||||
|
}
|
||||||
|
|
||||||
|
var footer = (tab.Footer ?? new List<string>()).Where(line => line != null).ToList();
|
||||||
|
if (footer.Count > 0)
|
||||||
|
{
|
||||||
|
rows.Add(new RowData { Values = Blank(width) });
|
||||||
|
foreach (var line in footer)
|
||||||
|
{
|
||||||
|
var cells = Blank(width);
|
||||||
|
if (cells.Count > 0) cells[0] = Text(line);
|
||||||
|
else cells.Add(Text(line));
|
||||||
|
rows.Add(new RowData { Values = cells });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<CellData> Blank(int width)
|
||||||
|
{
|
||||||
|
return Enumerable.Range(0, Math.Max(width, 0)).Select(_ => Text(string.Empty)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CellData Text(string value)
|
||||||
|
{
|
||||||
|
return new CellData { UserEnteredValue = new ExtendedValue { StringValue = value ?? string.Empty } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CellData Number(int value)
|
||||||
|
{
|
||||||
|
return new CellData { UserEnteredValue = new ExtendedValue { NumberValue = value } };
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LaDOSE.Business.Interface;
|
||||||
|
using LaDOSE.Entity;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace LaDOSE.Business.Provider.SheetsProvider
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the export to the log instead of to Google, and reports success.
|
||||||
|
///
|
||||||
|
/// This exists so the whole feature — button, ordering, cumulative prefixes, payload shape —
|
||||||
|
/// can be exercised end to end before any Google account, key or sharing exists. Set
|
||||||
|
/// GoogleSheets:Writer to "Logging" and read the API log. It is also the safe way to try a
|
||||||
|
/// selection without touching the real spreadsheet.
|
||||||
|
/// </summary>
|
||||||
|
public class LoggingSheetsWriter : ISheetsWriter
|
||||||
|
{
|
||||||
|
private readonly ILogger<LoggingSheetsWriter> _logger;
|
||||||
|
|
||||||
|
public LoggingSheetsWriter(ILogger<LoggingSheetsWriter> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name => "Logging";
|
||||||
|
|
||||||
|
public bool IsConfigured => true;
|
||||||
|
|
||||||
|
public Task<SheetExportResult> WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var tabs = request.Tabs ?? new List<SheetTable>();
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Sheets export (Logging writer): spreadsheet {SpreadsheetId}, {TabCount} tabs",
|
||||||
|
request.SpreadsheetId, tabs.Count);
|
||||||
|
|
||||||
|
foreach (var tab in tabs)
|
||||||
|
{
|
||||||
|
var header = tab.Header ?? new List<string>();
|
||||||
|
var rows = tab.Rows ?? new List<SheetRow>();
|
||||||
|
|
||||||
|
_logger.LogInformation(" [{Name}] event {EventId}: {RowCount} rows x {ColumnCount} columns | {Header}",
|
||||||
|
tab.Name, tab.EventId, rows.Count, header.Count, string.Join(" | ", header));
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(" {Player} | {Points} | {Total}",
|
||||||
|
row.Player, string.Join(" | ", row.Points ?? new List<int>()), row.Total);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var line in tab.Footer ?? new List<string>())
|
||||||
|
{
|
||||||
|
_logger.LogInformation(" -- {Line}", line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(new SheetExportResult
|
||||||
|
{
|
||||||
|
SpreadsheetId = request.SpreadsheetId,
|
||||||
|
SpreadsheetUrl = SheetUrl(request.SpreadsheetId),
|
||||||
|
Writer = Name,
|
||||||
|
Tabs = tabs.Select(tab => new SheetTabResult
|
||||||
|
{
|
||||||
|
RequestedName = tab.Name,
|
||||||
|
Name = tab.Name,
|
||||||
|
Rows = (tab.Rows?.Count ?? 0) + 1,
|
||||||
|
Columns = tab.Header?.Count ?? 0,
|
||||||
|
// Nothing was inspected, so this is a claim rather than an observation.
|
||||||
|
Created = true
|
||||||
|
}).ToList(),
|
||||||
|
Warnings = new List<string>
|
||||||
|
{
|
||||||
|
"Writer is 'Logging': nothing was written to Google. The payload is in the API log."
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string SheetUrl(string spreadsheetId)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(spreadsheetId)
|
||||||
|
? null
|
||||||
|
: $"https://docs.google.com/spreadsheets/d/{spreadsheetId}/edit";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ namespace LaDOSE.Business.Provider.SmashProvider
|
|||||||
{
|
{
|
||||||
public class SmashProvider : ISmashProvider
|
public class SmashProvider : ISmashProvider
|
||||||
{
|
{
|
||||||
|
private static string API_FQDN = "api.start.gg";
|
||||||
public string ApiKey { get; set; }
|
public string ApiKey { get; set; }
|
||||||
//public SmashProvider(string apiKey)
|
//public SmashProvider(string apiKey)
|
||||||
//{
|
//{
|
||||||
@@ -34,7 +35,7 @@ namespace LaDOSE.Business.Provider.SmashProvider
|
|||||||
|
|
||||||
private async Task<T> QuerySmash<T>(GraphQLRequest req)
|
private async Task<T> QuerySmash<T>(GraphQLRequest req)
|
||||||
{
|
{
|
||||||
var graphQLClient = new GraphQLHttpClient("https://api.smash.gg/gql/alpha", new NewtonsoftJsonSerializer());
|
var graphQLClient = new GraphQLHttpClient($"https://{API_FQDN}/gql/alpha", new NewtonsoftJsonSerializer());
|
||||||
graphQLClient.HttpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}");
|
graphQLClient.HttpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}");
|
||||||
|
|
||||||
var graphQLResponse = await graphQLClient.SendQueryAsync<T>(req);
|
var graphQLResponse = await graphQLClient.SendQueryAsync<T>(req);
|
||||||
@@ -47,6 +48,37 @@ namespace LaDOSE.Business.Provider.SmashProvider
|
|||||||
return graphQLResponse.Data;
|
return graphQLResponse.Data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<Game>> GetGames(string game)
|
||||||
|
{
|
||||||
|
var query = new GraphQLRequest()
|
||||||
|
{
|
||||||
|
Query = @"
|
||||||
|
query VideogameQuery($name:String) {
|
||||||
|
videogames(query: { filter: { name: $name }, perPage: 5 }) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
",
|
||||||
|
OperationName = "VideogameQuery",
|
||||||
|
Variables = new
|
||||||
|
{
|
||||||
|
name = game,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
VideoGamesResponse querySmash = await QuerySmash<VideoGamesResponse>(query);
|
||||||
|
if (querySmash.videogames != null)
|
||||||
|
{
|
||||||
|
return querySmash.videogames.nodes.Select(e => new Game() { Id = e.id, Name = e.Name }).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new List<Game>();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Event> GetEvent(string slug)
|
public async Task<Event> GetEvent(string slug)
|
||||||
{
|
{
|
||||||
|
|
||||||
@@ -315,7 +347,7 @@ namespace LaDOSE.Business.Provider.SmashProvider
|
|||||||
public async Task<TournamentResponse> GetNames(string slug)
|
public async Task<TournamentResponse> GetNames(string slug)
|
||||||
{
|
{
|
||||||
|
|
||||||
var graphQLClient = new GraphQLHttpClient("https://api.smash.gg/gql/alpha", new NewtonsoftJsonSerializer());
|
var graphQLClient = new GraphQLHttpClient($"https://{API_FQDN}/gql/alpha", new NewtonsoftJsonSerializer());
|
||||||
graphQLClient.HttpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}");
|
graphQLClient.HttpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}");
|
||||||
var Event = new GraphQLRequest
|
var Event = new GraphQLRequest
|
||||||
{
|
{
|
||||||
@@ -381,7 +413,7 @@ namespace LaDOSE.Business.Provider.SmashProvider
|
|||||||
public async Task<TournamentResponse> GetTournament(string slug)
|
public async Task<TournamentResponse> GetTournament(string slug)
|
||||||
{
|
{
|
||||||
|
|
||||||
var graphQLClient = new GraphQLHttpClient("https://api.smash.gg/gql/alpha", new NewtonsoftJsonSerializer());
|
var graphQLClient = new GraphQLHttpClient($"https://{API_FQDN}/gql/alpha", new NewtonsoftJsonSerializer());
|
||||||
graphQLClient.HttpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}");
|
graphQLClient.HttpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}");
|
||||||
var Event = new GraphQLRequest
|
var Event = new GraphQLRequest
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
#nullable enable
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Converters;
|
using Newtonsoft.Json.Converters;
|
||||||
@@ -11,62 +12,62 @@ namespace LaDOSE.Business.Provider.SmashProvider
|
|||||||
public int totalPages { get; set; }
|
public int totalPages { get; set; }
|
||||||
public int page { get; set; }
|
public int page { get; set; }
|
||||||
public int perPage { get; set; }
|
public int perPage { get; set; }
|
||||||
public string sortBy { get; set; }
|
public string? sortBy { get; set; }
|
||||||
public string filter { get; set; }
|
public string? filter { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TournamentType
|
public class TournamentType
|
||||||
{
|
{
|
||||||
public int id { get; set; }
|
public int id { get; set; }
|
||||||
|
|
||||||
public string Name { get; set; }
|
public string? Name { get; set; }
|
||||||
|
|
||||||
[JsonConverter(typeof(UnixDateTimeConverter))]
|
[JsonConverter(typeof(UnixDateTimeConverter))]
|
||||||
public DateTime startAt { get; set; }
|
public DateTime startAt { get; set; }
|
||||||
public List<EventType> Events { get; set; }
|
public List<EventType>? Events { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
public class VideoGameType
|
public class VideoGameType
|
||||||
{
|
{
|
||||||
public int id { get; set; }
|
public int id { get; set; }
|
||||||
public string Name { get; set; }
|
public string? Name { get; set; }
|
||||||
}
|
}
|
||||||
public class ScoreType
|
public class ScoreType
|
||||||
{
|
{
|
||||||
public string label { get; set; }
|
public string? label { get; set; }
|
||||||
public int? value { get; set; }
|
public int? value { get; set; }
|
||||||
public string displayValue { get; set; }
|
public string? displayValue { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
public class StatType
|
public class StatType
|
||||||
{
|
{
|
||||||
public ScoreType score { get; set; }
|
public ScoreType? score { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class StandingType
|
public class StandingType
|
||||||
{
|
{
|
||||||
public string id { get; set; }
|
public string? id { get; set; }
|
||||||
|
|
||||||
public int placement { get; set; }
|
public int placement { get; set; }
|
||||||
|
|
||||||
public ParticipantType player { get; set; }
|
public ParticipantType? player { get; set; }
|
||||||
|
|
||||||
public StatType stats { get; set; }
|
public StatType? stats { get; set; }
|
||||||
|
|
||||||
public EntrantType entrant { get; set; }
|
public EntrantType? entrant { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ParticipantType
|
public class ParticipantType
|
||||||
{
|
{
|
||||||
public int id { get; set; }
|
public int id { get; set; }
|
||||||
public string gamerTag { get; set; }
|
public string? gamerTag { get; set; }
|
||||||
public UserType? user { get; set; }
|
public UserType? user { get; set; }
|
||||||
}
|
}
|
||||||
public class UserType
|
public class UserType
|
||||||
{
|
{
|
||||||
public int id { get; set; }
|
public int id { get; set; }
|
||||||
public string name { get; set; }
|
public string? name { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,68 +75,71 @@ namespace LaDOSE.Business.Provider.SmashProvider
|
|||||||
{
|
{
|
||||||
public int id { get; set; }
|
public int id { get; set; }
|
||||||
|
|
||||||
public string name { get; set; }
|
public string? name { get; set; }
|
||||||
public string state { get; set; }
|
public string? state { get; set; }
|
||||||
|
|
||||||
public VideoGameType videogame { get; set; }
|
public VideoGameType? videogame { get; set; }
|
||||||
public Node<StandingType> standings { get; set; }
|
public Node<StandingType>? standings { get; set; }
|
||||||
public Node<SetType> sets { get; set; }
|
public Node<SetType>? sets { get; set; }
|
||||||
|
|
||||||
public Node<EntrantType> entrants { get; set; }
|
public Node<EntrantType>? entrants { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class EntrantType
|
public class EntrantType
|
||||||
{
|
{
|
||||||
public int id { get; set; }
|
public int id { get; set; }
|
||||||
public string name { get; set; }
|
public string? name { get; set; }
|
||||||
public bool? isDisqualified { get; set; }
|
public bool? isDisqualified { get; set; }
|
||||||
public List<ParticipantType> participants { get; set; }
|
public List<ParticipantType>? participants { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
public class SlotType
|
public class SlotType
|
||||||
{
|
{
|
||||||
public string id { get; set; }
|
public string? id { get; set; }
|
||||||
public int slotIndex { get; set; }
|
public int slotIndex { get; set; }
|
||||||
|
|
||||||
public StandingType standing { get; set; }
|
public StandingType? standing { get; set; }
|
||||||
|
|
||||||
public EntrantType entrant { get; set; }
|
public EntrantType? entrant { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
public class SetType
|
public class SetType
|
||||||
{
|
{
|
||||||
public string id { get; set; }
|
public string? id { get; set; }
|
||||||
public int? lPlacement { get; set; }
|
public int? lPlacement { get; set; }
|
||||||
public int? wPlacement { get; set; }
|
public int? wPlacement { get; set; }
|
||||||
public int? round { get; set; }
|
public int? round { get; set; }
|
||||||
public List<SlotType> slots { get; set; }
|
public List<SlotType>? slots { get; set; }
|
||||||
public string identifier { get; set; }
|
public string? identifier { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
public class Node<T>
|
public class Node<T>
|
||||||
{
|
{
|
||||||
public PageInfoType pageInfo { get; set; }
|
public PageInfoType? pageInfo { get; set; }
|
||||||
public List<T> nodes { get; set; }
|
public List<T>? nodes { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class VideoGamesResponse
|
||||||
|
{
|
||||||
|
public Node<VideoGameType>? videogames {get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public class TournamentResponse
|
public class TournamentResponse
|
||||||
{
|
{
|
||||||
public TournamentType Tournament { get; set; }
|
public TournamentType? Tournament { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class EventResponse
|
public class EventResponse
|
||||||
{
|
{
|
||||||
public EventType Event { get; set; }
|
public EventType? Event { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
public class SetsResponse
|
public class SetsResponse
|
||||||
{
|
{
|
||||||
public EventType Event { get; set; }
|
public EventType? Event { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using LaDOSE.Entity.BotEvent;
|
|||||||
using LaDOSE.Entity.Context;
|
using LaDOSE.Entity.Context;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
|
||||||
namespace LaDOSE.Business.Service
|
namespace LaDOSE.Business.Service
|
||||||
{
|
{
|
||||||
public class BotEventService : BaseService<BotEvent>, IBotEventService
|
public class BotEventService : BaseService<BotEvent>, IBotEventService
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ namespace LaDOSE.Business.Service
|
|||||||
//}
|
//}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<Game>> GetSmashGames(string name)
|
||||||
|
{
|
||||||
|
return await _smashProvider.GetGames(name);
|
||||||
|
}
|
||||||
public async Task<Event> ParseSmash(string tournamentSlug)
|
public async Task<Event> ParseSmash(string tournamentSlug)
|
||||||
{
|
{
|
||||||
Event eventExist = GetBySlug(tournamentSlug);
|
Event eventExist = GetBySlug(tournamentSlug);
|
||||||
@@ -96,7 +100,7 @@ namespace LaDOSE.Business.Service
|
|||||||
//POKEMON.
|
//POKEMON.
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
throw new Exception("FUCK !");
|
throw new Exception($"FUCK ! {e.Message}");
|
||||||
}
|
}
|
||||||
return currentEvent;
|
return currentEvent;
|
||||||
}
|
}
|
||||||
@@ -109,6 +113,11 @@ namespace LaDOSE.Business.Service
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<List<Game>> GetSmashGame(string name)
|
||||||
|
{
|
||||||
|
return _smashProvider.GetGames(name);
|
||||||
|
}
|
||||||
|
|
||||||
private Event GetBySlug(string tournamentSlug)
|
private Event GetBySlug(string tournamentSlug)
|
||||||
{
|
{
|
||||||
return _context.Event.FirstOrDefault(e => e.SmashSlug == tournamentSlug);
|
return _context.Event.FirstOrDefault(e => e.SmashSlug == tournamentSlug);
|
||||||
@@ -143,6 +152,10 @@ namespace LaDOSE.Business.Service
|
|||||||
var games = _context.Game.ToList();
|
var games = _context.Game.ToList();
|
||||||
|
|
||||||
TournamentsResult result = new TournamentsResult();
|
TournamentsResult result = new TournamentsResult();
|
||||||
|
if (id.Count == 1)
|
||||||
|
{
|
||||||
|
result.Slug = _context.Event.Where(e=> e.Id == id.First()).First().SmashSlug;
|
||||||
|
}
|
||||||
result.Results = new List<Result>();
|
result.Results = new List<Result>();
|
||||||
result.Games = new List<Game>();
|
result.Games = new List<Game>();
|
||||||
result.Participents = new List<ChallongeParticipent>();
|
result.Participents = new List<ChallongeParticipent>();
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LaDOSE.Business.Interface;
|
||||||
|
using LaDOSE.Entity;
|
||||||
|
|
||||||
|
namespace LaDOSE.Business.Service
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Everything about a spreadsheet export that is not Google-specific: resolving the target,
|
||||||
|
/// validating the tables, and making the tab titles legal and unique. Kept out of the writers
|
||||||
|
/// so the rules are stated once and can be tested without a network, the same way
|
||||||
|
/// StatisticsService keeps its aggregation in a pure static method.
|
||||||
|
/// </summary>
|
||||||
|
public class SheetsExportService : ISheetsExportService
|
||||||
|
{
|
||||||
|
private readonly SheetsSettings _settings;
|
||||||
|
private readonly ISheetsWriter _writer;
|
||||||
|
|
||||||
|
public SheetsExportService(SheetsSettings settings, ISheetsWriter writer)
|
||||||
|
{
|
||||||
|
_settings = settings ?? new SheetsSettings();
|
||||||
|
_writer = writer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SheetsConfig GetConfig()
|
||||||
|
{
|
||||||
|
return new SheetsConfig
|
||||||
|
{
|
||||||
|
Writer = _writer?.Name ?? "Disabled",
|
||||||
|
Configured = _writer != null
|
||||||
|
&& _writer.IsConfigured
|
||||||
|
&& !string.IsNullOrWhiteSpace(_settings.SpreadsheetId),
|
||||||
|
SpreadsheetId = _settings.SpreadsheetId ?? string.Empty,
|
||||||
|
MaxTabs = _settings.MaxTabs,
|
||||||
|
MaxRowsPerTab = _settings.MaxRowsPerTab
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SheetExportResult> ExportAsync(SheetExportRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (_writer == null || !_writer.IsConfigured)
|
||||||
|
{
|
||||||
|
throw new SheetsExportException(503,
|
||||||
|
"Google Sheets export is not configured on the server.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_settings.SpreadsheetId))
|
||||||
|
{
|
||||||
|
throw new SheetsExportException(400,
|
||||||
|
"No spreadsheet configured on the server. Set GoogleSheets:SpreadsheetId.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var tabs = request?.Tabs?.Where(tab => tab != null).ToList() ?? new List<SheetTable>();
|
||||||
|
if (tabs.Count == 0)
|
||||||
|
{
|
||||||
|
throw new SheetsExportException(400, "No table to write.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Validate(tabs);
|
||||||
|
|
||||||
|
var warnings = new List<string>();
|
||||||
|
NameTabs(tabs, warnings);
|
||||||
|
|
||||||
|
// The caller never names the spreadsheet; it is resolved here, from configuration.
|
||||||
|
var resolved = new SheetExportRequest
|
||||||
|
{
|
||||||
|
SpreadsheetId = _settings.SpreadsheetId.Trim(),
|
||||||
|
Tabs = tabs
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _writer.WriteTablesAsync(resolved, ct);
|
||||||
|
result.Writer = _writer.Name;
|
||||||
|
result.Warnings = (result.Warnings ?? new List<string>()).Concat(warnings).ToList();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Validation
|
||||||
|
|
||||||
|
private void Validate(List<SheetTable> tabs)
|
||||||
|
{
|
||||||
|
if (tabs.Count > _settings.MaxTabs)
|
||||||
|
{
|
||||||
|
throw new SheetsExportException(400,
|
||||||
|
$"{tabs.Count} tabs requested, the limit is {_settings.MaxTabs}. Narrow the selection.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var tab in tabs)
|
||||||
|
{
|
||||||
|
var header = tab.Header ?? new List<string>();
|
||||||
|
|
||||||
|
// "Players", at least one game, "Total".
|
||||||
|
if (header.Count < 3)
|
||||||
|
{
|
||||||
|
throw new SheetsExportException(400,
|
||||||
|
$"Tab '{tab.Name}' has no game column — nothing was scored for it.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (header.Count > _settings.MaxColumns)
|
||||||
|
{
|
||||||
|
throw new SheetsExportException(400,
|
||||||
|
$"Tab '{tab.Name}' has {header.Count} columns, the limit is {_settings.MaxColumns}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = tab.Rows ?? new List<SheetRow>();
|
||||||
|
if (rows.Count > _settings.MaxRowsPerTab)
|
||||||
|
{
|
||||||
|
throw new SheetsExportException(400,
|
||||||
|
$"Tab '{tab.Name}' has {rows.Count} rows, the limit is {_settings.MaxRowsPerTab}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var expected = header.Count - 2;
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var points = row?.Points?.Count ?? 0;
|
||||||
|
if (points != expected)
|
||||||
|
{
|
||||||
|
throw new SheetsExportException(400,
|
||||||
|
$"Tab '{tab.Name}' row '{row?.Player}' has {points} point columns, " +
|
||||||
|
$"header declares {expected}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Tab naming
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Google rejects these in a tab title. Replaced rather than stripped so "Ranking 13/14"
|
||||||
|
/// stays readable as "Ranking 13-14".
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Regex Forbidden = new Regex(@"[:\\/?*\[\]]", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private static readonly Regex Whitespace = new Regex(@"\s+", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private const int MaxTitleLength = 100;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Makes every title legal and unique, in place, recording each change. Tab identity is
|
||||||
|
/// the title, so a rename means the next export writes somewhere else — which is exactly
|
||||||
|
/// why every rename is reported rather than applied quietly.
|
||||||
|
/// </summary>
|
||||||
|
private static void NameTabs(List<SheetTable> tabs, List<string> warnings)
|
||||||
|
{
|
||||||
|
var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var tab in tabs)
|
||||||
|
{
|
||||||
|
var requested = tab.Name ?? string.Empty;
|
||||||
|
var name = Sanitise(requested, tab.EventId);
|
||||||
|
|
||||||
|
if (!taken.Add(name))
|
||||||
|
{
|
||||||
|
var suffix = 2;
|
||||||
|
string candidate;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
candidate = Truncate($"{name} ({suffix})");
|
||||||
|
suffix++;
|
||||||
|
} while (!taken.Add(candidate));
|
||||||
|
|
||||||
|
name = candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(name, requested, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
warnings.Add($"Tab renamed: '{requested}' -> '{name}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
tab.Name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trim, replace what Google forbids, collapse runs of whitespace, drop leading and
|
||||||
|
/// trailing apostrophes (Sheets uses them to quote a title), cap the length, and fall
|
||||||
|
/// back to the event id when nothing usable survives.
|
||||||
|
/// </summary>
|
||||||
|
public static string Sanitise(string requested, int eventId)
|
||||||
|
{
|
||||||
|
var name = (requested ?? string.Empty).Trim();
|
||||||
|
name = Forbidden.Replace(name, "-");
|
||||||
|
name = Whitespace.Replace(name, " ").Trim();
|
||||||
|
name = name.Trim('\'');
|
||||||
|
name = Truncate(name).Trim();
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(name) ? $"Event {eventId}" : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string value)
|
||||||
|
{
|
||||||
|
return value.Length <= MaxTitleLength ? value : value.Substring(0, MaxTitleLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LaDOSE.Business.Interface;
|
||||||
|
using LaDOSE.Entity;
|
||||||
|
using LaDOSE.Entity.Context;
|
||||||
|
|
||||||
|
namespace LaDOSE.Business.Service
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Match statistics over the persisted Set rows.
|
||||||
|
///
|
||||||
|
/// Two things to know about the data:
|
||||||
|
/// - There is no Set -> Player relationship in the EF model (the navigation properties
|
||||||
|
/// and their configuration are commented out), so player names are resolved with a
|
||||||
|
/// separate query plus an in-memory dictionary. Nothing here Includes a player.
|
||||||
|
/// - There is no winner column. The winner is inferred from the scores, and start.gg
|
||||||
|
/// encodes a DQ as -1, so games are clamped at 0.
|
||||||
|
///
|
||||||
|
/// - A Set has no game either. The game belongs to the <see cref="Tournament"/> the set
|
||||||
|
/// was played in, and Tournament.GameId is nullable, so any per-game breakdown has
|
||||||
|
/// to decide what to do with brackets that have none. <see cref="GetVersus"/> drops
|
||||||
|
/// them and reports how many it dropped.
|
||||||
|
///
|
||||||
|
/// The database work and the aggregation are deliberately separated: <see cref="GetMatchStats"/>
|
||||||
|
/// and <see cref="GetVersus"/> issue one query per table and hand the loaded lists to the pure
|
||||||
|
/// static <see cref="Aggregate"/> / <see cref="AggregateVersus"/>, which are unit-testable
|
||||||
|
/// without a database.
|
||||||
|
/// </summary>
|
||||||
|
public class StatisticsService : IStatisticsService
|
||||||
|
{
|
||||||
|
protected LaDOSEDbContext _context;
|
||||||
|
|
||||||
|
public StatisticsService(LaDOSEDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MatchStats> GetMatchStats(List<int> eventIds)
|
||||||
|
{
|
||||||
|
var requested = (eventIds ?? new List<int>()).Distinct().ToList();
|
||||||
|
if (requested.Count == 0)
|
||||||
|
{
|
||||||
|
// Well-formed and empty, never null and never a 500.
|
||||||
|
return Task.FromResult(new MatchStats());
|
||||||
|
}
|
||||||
|
|
||||||
|
// One query per table. No query per set and no query per player.
|
||||||
|
var events = _context.Event
|
||||||
|
.Where(e => requested.Contains(e.Id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var existingEventIds = events.Select(e => e.Id).ToList();
|
||||||
|
|
||||||
|
var tournaments = existingEventIds.Count == 0
|
||||||
|
? new List<Tournament>()
|
||||||
|
: _context.Tournament
|
||||||
|
.Where(t => existingEventIds.Contains(t.EventId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var tournamentIds = tournaments.Select(t => t.Id).Distinct().ToList();
|
||||||
|
|
||||||
|
// _context.Set is the DbSet<Set> property, not DbContext.Set<T>().
|
||||||
|
var sets = tournamentIds.Count == 0
|
||||||
|
? new List<Set>()
|
||||||
|
: _context.Set
|
||||||
|
.Where(s => tournamentIds.Contains(s.TournamentId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var playerIds = sets.Select(s => s.Player1Id)
|
||||||
|
.Concat(sets.Select(s => s.Player2Id))
|
||||||
|
.Where(id => id != 0)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var players = playerIds.Count == 0
|
||||||
|
? new List<Player>()
|
||||||
|
: _context.Player
|
||||||
|
.Where(p => playerIds.Contains(p.Id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Task.FromResult(Aggregate(requested, events, tournaments, sets, players));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<PlayerOption>> GetVersusPlayers()
|
||||||
|
{
|
||||||
|
// Same filter GetVersus applies, so the picker cannot offer a player whose
|
||||||
|
// every meeting would then be dropped as "game unknown". Self-sets are
|
||||||
|
// excluded here too, otherwise they would be counted in both slots.
|
||||||
|
var usable = from s in _context.Set
|
||||||
|
join t in _context.Tournament on s.TournamentId equals t.Id
|
||||||
|
where t.GameId != null && s.Player1Id != 0 && s.Player2Id != 0
|
||||||
|
&& s.Player1Id != s.Player2Id
|
||||||
|
select s;
|
||||||
|
|
||||||
|
// Counted in the database, one group-by per slot: the set table is the
|
||||||
|
// largest one here and there is no reason to pull it into memory.
|
||||||
|
var asPlayer1 = usable
|
||||||
|
.GroupBy(s => s.Player1Id)
|
||||||
|
.Select(g => new { PlayerId = g.Key, Sets = g.Count() })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var asPlayer2 = usable
|
||||||
|
.GroupBy(s => s.Player2Id)
|
||||||
|
.Select(g => new { PlayerId = g.Key, Sets = g.Count() })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var setsByPlayer = new Dictionary<int, int>();
|
||||||
|
foreach (var row in asPlayer1.Concat(asPlayer2))
|
||||||
|
{
|
||||||
|
setsByPlayer.TryGetValue(row.PlayerId, out var running);
|
||||||
|
setsByPlayer[row.PlayerId] = running + row.Sets;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setsByPlayer.Count == 0)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new List<PlayerOption>());
|
||||||
|
}
|
||||||
|
|
||||||
|
var playerIds = setsByPlayer.Keys.ToList();
|
||||||
|
var nameById = _context.Player
|
||||||
|
.Where(p => playerIds.Contains(p.Id))
|
||||||
|
.ToList()
|
||||||
|
.ToDictionary(p => p.Id, DisplayName);
|
||||||
|
|
||||||
|
// A set can reference a player row that no longer exists; keep it as "#id"
|
||||||
|
// rather than hiding a real opponent from the picker.
|
||||||
|
var options = setsByPlayer
|
||||||
|
.Select(pair => new PlayerOption
|
||||||
|
{
|
||||||
|
Id = pair.Key,
|
||||||
|
Name = ResolveName(pair.Key, nameById),
|
||||||
|
Sets = pair.Value
|
||||||
|
})
|
||||||
|
.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(p => p.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Task.FromResult(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<PlayerVersus> GetVersus(int playerAId, int playerBId)
|
||||||
|
{
|
||||||
|
// Nothing to look up, and nothing exceptional either: a caller that has not
|
||||||
|
// picked two distinct players gets an empty breakdown, not a 500.
|
||||||
|
if (playerAId == 0 || playerBId == 0 || playerAId == playerBId)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new PlayerVersus
|
||||||
|
{
|
||||||
|
PlayerAId = playerAId,
|
||||||
|
PlayerBId = playerBId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Either seating: the set rows record whoever start.gg listed first.
|
||||||
|
var sets = _context.Set
|
||||||
|
.Where(s => (s.Player1Id == playerAId && s.Player2Id == playerBId)
|
||||||
|
|| (s.Player1Id == playerBId && s.Player2Id == playerAId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var tournamentIds = sets.Select(s => s.TournamentId).Distinct().ToList();
|
||||||
|
var tournaments = tournamentIds.Count == 0
|
||||||
|
? new List<Tournament>()
|
||||||
|
: _context.Tournament
|
||||||
|
.Where(t => tournamentIds.Contains(t.Id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var gameIds = tournaments
|
||||||
|
.Where(t => t.GameId.HasValue)
|
||||||
|
.Select(t => t.GameId.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var games = gameIds.Count == 0
|
||||||
|
? new List<Game>()
|
||||||
|
: _context.Game
|
||||||
|
.Where(g => gameIds.Contains(g.Id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var players = _context.Player
|
||||||
|
.Where(p => p.Id == playerAId || p.Id == playerBId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Task.FromResult(AggregateVersus(playerAId, playerBId, sets, tournaments, games, players));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure aggregation over already-loaded rows. No database, no I/O, deterministic.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="requestedEventIds">The event ids the caller asked for.</param>
|
||||||
|
/// <param name="events">Events that exist (used for the coverage count).</param>
|
||||||
|
/// <param name="tournaments">Candidate tournaments; filtered here on EventId.</param>
|
||||||
|
/// <param name="sets">Candidate sets; filtered here on TournamentId.</param>
|
||||||
|
/// <param name="players">Players used to resolve display names; may be incomplete.</param>
|
||||||
|
public static MatchStats Aggregate(
|
||||||
|
IEnumerable<int> requestedEventIds,
|
||||||
|
IEnumerable<Event> events,
|
||||||
|
IEnumerable<Tournament> tournaments,
|
||||||
|
IEnumerable<Set> sets,
|
||||||
|
IEnumerable<Player> players)
|
||||||
|
{
|
||||||
|
var result = new MatchStats();
|
||||||
|
|
||||||
|
var requested = new HashSet<int>((requestedEventIds ?? Enumerable.Empty<int>()));
|
||||||
|
if (requested.Count == 0)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coverage: events -------------------------------------------------------
|
||||||
|
var existingEventIds = new HashSet<int>((events ?? Enumerable.Empty<Event>())
|
||||||
|
.Where(e => e != null && requested.Contains(e.Id))
|
||||||
|
.Select(e => e.Id));
|
||||||
|
result.Coverage.Events = existingEventIds.Count;
|
||||||
|
|
||||||
|
// Coverage: brackets ----------------------------------------------------
|
||||||
|
var scopedTournamentIds = new HashSet<int>((tournaments ?? Enumerable.Empty<Tournament>())
|
||||||
|
.Where(t => t != null && existingEventIds.Contains(t.EventId))
|
||||||
|
.Select(t => t.Id));
|
||||||
|
result.Coverage.Brackets = scopedTournamentIds.Count;
|
||||||
|
|
||||||
|
var scopedSets = (sets ?? Enumerable.Empty<Set>())
|
||||||
|
.Where(s => s != null && scopedTournamentIds.Contains(s.TournamentId))
|
||||||
|
.ToList();
|
||||||
|
result.Coverage.Sets = scopedSets.Count;
|
||||||
|
result.Coverage.BracketsWithSets = scopedSets.Select(s => s.TournamentId).Distinct().Count();
|
||||||
|
|
||||||
|
// Name resolution -------------------------------------------------------
|
||||||
|
var nameById = new Dictionary<int, string>();
|
||||||
|
foreach (var player in (players ?? Enumerable.Empty<Player>()).Where(p => p != null))
|
||||||
|
{
|
||||||
|
nameById[player.Id] = DisplayName(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats = new Dictionary<int, PlayerMatchStats>();
|
||||||
|
var h2h = new Dictionary<(int, int), HeadToHead>();
|
||||||
|
|
||||||
|
foreach (var set in scopedSets)
|
||||||
|
{
|
||||||
|
// Unusable rows still count in Coverage.Sets but nowhere else.
|
||||||
|
if (set.Player1Id == 0 || set.Player2Id == 0 || set.Player1Id == set.Player2Id)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No winner column: equal scores (including 0-0 and -1 / -1) are undecided.
|
||||||
|
if (set.Player1Score == set.Player2Score)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Coverage.DecidedSets++;
|
||||||
|
|
||||||
|
var p1 = set.Player1Id;
|
||||||
|
var p2 = set.Player2Id;
|
||||||
|
|
||||||
|
// A DQ is stored as -1. Clamp so it never produces negative games.
|
||||||
|
var games1 = Math.Max(0, set.Player1Score);
|
||||||
|
var games2 = Math.Max(0, set.Player2Score);
|
||||||
|
|
||||||
|
var stat1 = GetOrAdd(stats, p1, nameById);
|
||||||
|
var stat2 = GetOrAdd(stats, p2, nameById);
|
||||||
|
|
||||||
|
stat1.GamesWon += games1;
|
||||||
|
stat1.GamesLost += games2;
|
||||||
|
stat2.GamesWon += games2;
|
||||||
|
stat2.GamesLost += games1;
|
||||||
|
|
||||||
|
var winnerId = set.Player1Score > set.Player2Score ? p1 : p2;
|
||||||
|
if (winnerId == p1)
|
||||||
|
{
|
||||||
|
stat1.Wins++;
|
||||||
|
stat2.Losses++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stat2.Wins++;
|
||||||
|
stat1.Losses++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lower PlayerId is always A, so WinsA / WinsB are unambiguous.
|
||||||
|
var aId = Math.Min(p1, p2);
|
||||||
|
var bId = Math.Max(p1, p2);
|
||||||
|
var key = (aId, bId);
|
||||||
|
if (!h2h.TryGetValue(key, out var pair))
|
||||||
|
{
|
||||||
|
pair = new HeadToHead
|
||||||
|
{
|
||||||
|
PlayerAId = aId,
|
||||||
|
PlayerA = ResolveName(aId, nameById),
|
||||||
|
PlayerBId = bId,
|
||||||
|
PlayerB = ResolveName(bId, nameById)
|
||||||
|
};
|
||||||
|
h2h[key] = pair;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (winnerId == aId)
|
||||||
|
{
|
||||||
|
pair.WinsA++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pair.WinsB++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets is derived, so Sets == Wins + Losses cannot drift.
|
||||||
|
foreach (var stat in stats.Values)
|
||||||
|
{
|
||||||
|
stat.Sets = stat.Wins + stat.Losses;
|
||||||
|
Debug.Assert(stat.Sets == stat.Wins + stat.Losses, "Sets must equal Wins + Losses");
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Players = stats.Values
|
||||||
|
.OrderByDescending(p => p.Wins)
|
||||||
|
.ThenByDescending(p => p.Sets)
|
||||||
|
.ThenBy(p => p.Player, StringComparer.Ordinal)
|
||||||
|
.ThenBy(p => p.PlayerId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Not capped: bounded by the number of sets in scope.
|
||||||
|
result.HeadToHead = h2h.Values
|
||||||
|
.OrderByDescending(p => p.WinsA + p.WinsB)
|
||||||
|
.ThenBy(p => p.PlayerA, StringComparer.Ordinal)
|
||||||
|
.ThenBy(p => p.PlayerB, StringComparer.Ordinal)
|
||||||
|
.ThenBy(p => p.PlayerAId)
|
||||||
|
.ThenBy(p => p.PlayerBId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure aggregation of the meetings between two players, per game. No database,
|
||||||
|
/// no I/O, deterministic.
|
||||||
|
///
|
||||||
|
/// Winners are inferred from the scores exactly as in <see cref="Aggregate"/>: equal
|
||||||
|
/// scores mean undecided. Undecided meetings still count in Sets — they happened —
|
||||||
|
/// but contribute to no win and no game count.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="playerAId">Left-hand player; WinsA is always their side.</param>
|
||||||
|
/// <param name="playerBId">Right-hand player.</param>
|
||||||
|
/// <param name="sets">Candidate sets; the pairing is re-checked here.</param>
|
||||||
|
/// <param name="tournaments">Tournaments of those sets, for Tournament.GameId.</param>
|
||||||
|
/// <param name="games">Games used to resolve names; may be incomplete.</param>
|
||||||
|
/// <param name="players">Players used to resolve display names; may be incomplete.</param>
|
||||||
|
public static PlayerVersus AggregateVersus(
|
||||||
|
int playerAId,
|
||||||
|
int playerBId,
|
||||||
|
IEnumerable<Set> sets,
|
||||||
|
IEnumerable<Tournament> tournaments,
|
||||||
|
IEnumerable<Game> games,
|
||||||
|
IEnumerable<Player> players)
|
||||||
|
{
|
||||||
|
var result = new PlayerVersus
|
||||||
|
{
|
||||||
|
PlayerAId = playerAId,
|
||||||
|
PlayerBId = playerBId
|
||||||
|
};
|
||||||
|
|
||||||
|
if (playerAId == 0 || playerBId == 0 || playerAId == playerBId)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nameById = new Dictionary<int, string>();
|
||||||
|
foreach (var player in (players ?? Enumerable.Empty<Player>()).Where(p => p != null))
|
||||||
|
{
|
||||||
|
nameById[player.Id] = DisplayName(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.PlayerA = ResolveName(playerAId, nameById);
|
||||||
|
result.PlayerB = ResolveName(playerBId, nameById);
|
||||||
|
|
||||||
|
var gameIdByTournament = new Dictionary<int, int?>();
|
||||||
|
foreach (var tournament in (tournaments ?? Enumerable.Empty<Tournament>()).Where(t => t != null))
|
||||||
|
{
|
||||||
|
gameIdByTournament[tournament.Id] = tournament.GameId;
|
||||||
|
}
|
||||||
|
|
||||||
|
var gameById = new Dictionary<int, Game>();
|
||||||
|
foreach (var game in (games ?? Enumerable.Empty<Game>()).Where(g => g != null))
|
||||||
|
{
|
||||||
|
gameById[game.Id] = game;
|
||||||
|
}
|
||||||
|
|
||||||
|
var perGame = new Dictionary<int, VersusGameStats>();
|
||||||
|
|
||||||
|
foreach (var set in (sets ?? Enumerable.Empty<Set>()).Where(s => s != null))
|
||||||
|
{
|
||||||
|
// The query already restricts the pairing; checking again keeps this
|
||||||
|
// method correct on its own, which is the point of it being pure.
|
||||||
|
var aIsPlayer1 = set.Player1Id == playerAId && set.Player2Id == playerBId;
|
||||||
|
var bIsPlayer1 = set.Player1Id == playerBId && set.Player2Id == playerAId;
|
||||||
|
if (!aIsPlayer1 && !bIsPlayer1)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No game on the bracket, nothing to file this meeting under. Counted so
|
||||||
|
// the caller can say "3 meetings we cannot attribute" instead of losing them.
|
||||||
|
if (!gameIdByTournament.TryGetValue(set.TournamentId, out var gameId) || !gameId.HasValue)
|
||||||
|
{
|
||||||
|
result.UnknownGameSets++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var row = GetOrAddGame(perGame, gameId.Value, gameById);
|
||||||
|
row.Sets++;
|
||||||
|
|
||||||
|
var scoreA = aIsPlayer1 ? set.Player1Score : set.Player2Score;
|
||||||
|
var scoreB = aIsPlayer1 ? set.Player2Score : set.Player1Score;
|
||||||
|
|
||||||
|
// No winner column: equal scores (including 0-0 and -1 / -1) are undecided.
|
||||||
|
if (scoreA == scoreB)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
row.DecidedSets++;
|
||||||
|
// A DQ is stored as -1. Clamp so it never produces negative games.
|
||||||
|
row.GamesWonA += Math.Max(0, scoreA);
|
||||||
|
row.GamesWonB += Math.Max(0, scoreB);
|
||||||
|
if (scoreA > scoreB)
|
||||||
|
{
|
||||||
|
row.WinsA++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
row.WinsB++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Games = perGame.Values
|
||||||
|
.OrderByDescending(g => g.Sets)
|
||||||
|
.ThenByDescending(g => g.DecidedSets)
|
||||||
|
.ThenBy(g => g.Game, StringComparer.Ordinal)
|
||||||
|
.ThenBy(g => g.GameId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Totals are derived from the rows, so the header cannot disagree with the table.
|
||||||
|
result.Sets = result.Games.Sum(g => g.Sets);
|
||||||
|
result.DecidedSets = result.Games.Sum(g => g.DecidedSets);
|
||||||
|
result.WinsA = result.Games.Sum(g => g.WinsA);
|
||||||
|
result.WinsB = result.Games.Sum(g => g.WinsB);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VersusGameStats GetOrAddGame(Dictionary<int, VersusGameStats> perGame, int gameId,
|
||||||
|
Dictionary<int, Game> gameById)
|
||||||
|
{
|
||||||
|
if (!perGame.TryGetValue(gameId, out var row))
|
||||||
|
{
|
||||||
|
gameById.TryGetValue(gameId, out var game);
|
||||||
|
var name = string.IsNullOrWhiteSpace(game?.Name) ? $"#{gameId}" : game.Name;
|
||||||
|
row = new VersusGameStats
|
||||||
|
{
|
||||||
|
GameId = gameId,
|
||||||
|
Game = name,
|
||||||
|
GameLongName = string.IsNullOrWhiteSpace(game?.LongName) ? name : game.LongName
|
||||||
|
};
|
||||||
|
perGame[gameId] = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayerMatchStats GetOrAdd(Dictionary<int, PlayerMatchStats> stats, int playerId,
|
||||||
|
Dictionary<int, string> nameById)
|
||||||
|
{
|
||||||
|
if (!stats.TryGetValue(playerId, out var stat))
|
||||||
|
{
|
||||||
|
stat = new PlayerMatchStats
|
||||||
|
{
|
||||||
|
PlayerId = playerId,
|
||||||
|
Player = ResolveName(playerId, nameById)
|
||||||
|
};
|
||||||
|
stats[playerId] = stat;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gamertag, falling back to Name, else "#<id>" (a set can reference a player row
|
||||||
|
/// that no longer exists, and Gamertag is nullable in the database).
|
||||||
|
/// </summary>
|
||||||
|
private static string ResolveName(int playerId, Dictionary<int, string> nameById)
|
||||||
|
{
|
||||||
|
return nameById.TryGetValue(playerId, out var name) ? name : $"#{playerId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DisplayName(Player player)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(player.Gamertag))
|
||||||
|
{
|
||||||
|
return player.Gamertag;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(player.Name))
|
||||||
|
{
|
||||||
|
return player.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"#{player.Id}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using System.Linq;
|
|||||||
using LaDOSE.Business.Interface;
|
using LaDOSE.Business.Interface;
|
||||||
using LaDOSE.Entity;
|
using LaDOSE.Entity;
|
||||||
using LaDOSE.Entity.Context;
|
using LaDOSE.Entity.Context;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace LaDOSE.Business.Service
|
namespace LaDOSE.Business.Service
|
||||||
{
|
{
|
||||||
@@ -20,7 +21,9 @@ namespace LaDOSE.Business.Service
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
|
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
|
||||||
return null;
|
return null;
|
||||||
var user = _context.ApplicationUser.SingleOrDefault(x => x.Username == username);
|
var user = _context.ApplicationUser
|
||||||
|
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
|
||||||
|
.SingleOrDefault(x => x.Username == username);
|
||||||
|
|
||||||
// check if username exists
|
// check if username exists
|
||||||
if (user == null)
|
if (user == null)
|
||||||
@@ -36,23 +39,44 @@ namespace LaDOSE.Business.Service
|
|||||||
|
|
||||||
public IEnumerable<ApplicationUser> GetAll()
|
public IEnumerable<ApplicationUser> GetAll()
|
||||||
{
|
{
|
||||||
return _context.ApplicationUser;
|
return _context.ApplicationUser
|
||||||
|
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Roles are included because authorization reads them on every authenticated
|
||||||
|
/// request (see the OnTokenValidated handler in Startup).
|
||||||
|
/// </summary>
|
||||||
public ApplicationUser GetById(int id)
|
public ApplicationUser GetById(int id)
|
||||||
{
|
{
|
||||||
return _context.ApplicationUser.Find(id);
|
return _context.ApplicationUser
|
||||||
|
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
|
||||||
|
.SingleOrDefault(x => x.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ApplicationUser Create(ApplicationUser user, string password)
|
public IEnumerable<ApplicationRole> GetAllRoles()
|
||||||
|
{
|
||||||
|
return _context.ApplicationRole.OrderBy(x => x.Name).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApplicationUser Create(ApplicationUser user, string password, IEnumerable<string> roleNames = null)
|
||||||
{
|
{
|
||||||
// validation
|
// validation
|
||||||
|
if (string.IsNullOrWhiteSpace(user?.Username))
|
||||||
|
throw new Exception("Username is required");
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
throw new Exception("Password is required");
|
throw new Exception("Password is required");
|
||||||
|
|
||||||
if (_context.ApplicationUser.Any(x => x.Username == user.Username))
|
if (_context.ApplicationUser.Any(x => x.Username == user.Username))
|
||||||
throw new Exception("Username \"" + user.Username + "\" is already taken");
|
throw new Exception("Username \"" + user.Username + "\" is already taken");
|
||||||
|
|
||||||
|
// EF fills userid/roleid on the join rows from these navigations when it saves.
|
||||||
|
user.UserRoles = ResolveRoles(roleNames)
|
||||||
|
.Select(role => new ApplicationUserRole { Role = role })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
byte[] passwordHash, passwordSalt;
|
byte[] passwordHash, passwordSalt;
|
||||||
CreatePasswordHash(password, out passwordHash, out passwordSalt);
|
CreatePasswordHash(password, out passwordHash, out passwordSalt);
|
||||||
|
|
||||||
@@ -65,6 +89,35 @@ namespace LaDOSE.Business.Service
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns role names into the existing rows of <c>applicationrole</c>. An unknown
|
||||||
|
/// name is an error rather than a new role, so a typo cannot quietly produce an
|
||||||
|
/// account with no privileges — or, worse, a second spelling of "Admin".
|
||||||
|
/// </summary>
|
||||||
|
private List<ApplicationRole> ResolveRoles(IEnumerable<string> roleNames)
|
||||||
|
{
|
||||||
|
var wanted = (roleNames ?? Enumerable.Empty<string>())
|
||||||
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
|
.Select(name => name.Trim())
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (wanted.Count == 0)
|
||||||
|
return new List<ApplicationRole>();
|
||||||
|
|
||||||
|
var known = _context.ApplicationRole.ToList();
|
||||||
|
var resolved = new List<ApplicationRole>();
|
||||||
|
foreach (var name in wanted)
|
||||||
|
{
|
||||||
|
var role = known.FirstOrDefault(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (role == null)
|
||||||
|
throw new Exception($"Unknown role \"{name}\"");
|
||||||
|
resolved.Add(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
public void Update(ApplicationUser userParam, string password = null)
|
public void Update(ApplicationUser userParam, string password = null)
|
||||||
{
|
{
|
||||||
var user = _context.ApplicationUser.Find(userParam.Id);
|
var user = _context.ApplicationUser.Find(userParam.Id);
|
||||||
@@ -100,13 +153,19 @@ namespace LaDOSE.Business.Service
|
|||||||
|
|
||||||
public void Delete(int id)
|
public void Delete(int id)
|
||||||
{
|
{
|
||||||
var user = _context.ApplicationUser.Find(id);
|
// applicationuserrole's foreign keys are ON DELETE RESTRICT, so the join rows
|
||||||
if (user != null)
|
// have to go first — clearing the collection makes EF delete them.
|
||||||
{
|
var user = _context.ApplicationUser
|
||||||
|
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
|
||||||
|
.SingleOrDefault(x => x.Id == id);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
user.UserRoles?.Clear();
|
||||||
_context.ApplicationUser.Remove(user);
|
_context.ApplicationUser.Remove(user);
|
||||||
_context.SaveChanges();
|
_context.SaveChanges();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// private helper methods
|
// private helper methods
|
||||||
|
|
||||||
|
|||||||
@@ -43,13 +43,6 @@ namespace LaDOSE.Business.Service
|
|||||||
return wpEvents;
|
return wpEvents;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool UpdateBooking()
|
|
||||||
{
|
|
||||||
_context.Database.SetCommandTimeout(60);
|
|
||||||
_context.Database.ExecuteSqlCommand("call ladoseapi.ImportEvent();");
|
|
||||||
_context.Database.SetCommandTimeout(30);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
public List<WPUser> GetBooking(int wpEventId, Game game)
|
public List<WPUser> GetBooking(int wpEventId, Game game)
|
||||||
{
|
{
|
||||||
var selectedGameWpId = game.WordPressTag.Split(';');
|
var selectedGameWpId = game.WordPressTag.Split(';');
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Keep the build context small and free of local state.
|
||||||
|
node_modules
|
||||||
|
build
|
||||||
|
.svelte-kit
|
||||||
|
.git
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
# Real URLs live here and must never reach the image. .env.example is documentation.
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Consumed by `podman build` itself, never copied in.
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Local noise
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
lts/*
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Build the SPA. adapter-static writes /app/build (pages == assets).
|
||||||
|
FROM node:24-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Lockfile first: this layer is reused until the dependencies actually change.
|
||||||
|
# Every build tool lives in devDependencies, so no --omit=dev here.
|
||||||
|
#
|
||||||
|
# --legacy-peer-deps works around a conflict that predates this Dockerfile:
|
||||||
|
# openapi-typescript@7.13.0 peer-requires typescript@^5.x while the project is on ^6.0.3,
|
||||||
|
# so plain `npm ci` fails ERESOLVE on every npm version tested (10.8, 10.9, 11.17).
|
||||||
|
# The flag only skips peer *validation* — the tree installed is still exactly
|
||||||
|
# package-lock.json (verified: typescript 6.0.3, lockfile unmodified).
|
||||||
|
# Drop the flag once package.json resolves that conflict.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --legacy-peer-deps
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Optional baked-in default. Precedence at runtime is
|
||||||
|
# /config.js > VITE_API_BASE_URL > the 'http://localhost:5000' in src/lib/api/client.ts.
|
||||||
|
# Declared after `npm ci` so passing it does not invalidate the dependency layer.
|
||||||
|
# client.ts uses `??`, so an empty string would beat its default: unset instead of exporting "".
|
||||||
|
ARG VITE_API_BASE_URL
|
||||||
|
|
||||||
|
# Same gate as the README's documented `npm run check`: a type regression fails the image.
|
||||||
|
RUN npm run check
|
||||||
|
RUN if [ -z "${VITE_API_BASE_URL:-}" ]; then unset VITE_API_BASE_URL; fi; npm run build
|
||||||
|
|
||||||
|
# Serve it. openapi.json / schema.d.ts are committed, so nothing here touches the live API.
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
WORKDIR /usr/share/nginx/html
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
|
COPY --from=build /app/build/ ./
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
# LaDOSE.WebApp
|
||||||
|
|
||||||
|
Svelte 5 + SvelteKit front-end for `LaDOSE.Api`, styled with Tailwind CSS v4.
|
||||||
|
It ships as a static SPA (`@sveltejs/adapter-static`) and talks to the API over
|
||||||
|
JWT bearer auth, so it can be served from any static host.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Node ≥ 22.12 (the toolchain uses Vite 8). An `.nvmrc` is checked in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nvm use # resolves lts/*
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
The API must be up — it serves on `http://localhost:5000` in development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ../LaDOSE.Api && dotnet run # terminal 1
|
||||||
|
npm run dev # terminal 2 -> http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Point the app at a different API with `VITE_API_BASE_URL` (see `.env.example`).
|
||||||
|
`LaDOSE.Api` already allows any origin with credentials, so no dev proxy is needed.
|
||||||
|
|
||||||
|
## Typed API access
|
||||||
|
|
||||||
|
`src/lib/api/schema.d.ts` is **generated** from the API's OpenAPI document — never
|
||||||
|
edit it by hand. Regenerate whenever a C# controller or DTO changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run api:sync # fetch openapi.json from the running API, then re-emit types
|
||||||
|
```
|
||||||
|
|
||||||
|
That is `api:fetch` (curl `/openapi/v1.json`, override the host with `LADOSE_API_URL`)
|
||||||
|
followed by `api:types` (`openapi-typescript`). `openapi.json` is committed so the
|
||||||
|
types can be rebuilt without a running API.
|
||||||
|
|
||||||
|
Because paths and DTOs come from the generated types, a renamed route or a changed
|
||||||
|
DTO field surfaces as a TypeScript error rather than a runtime 404.
|
||||||
|
|
||||||
|
| Module | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/lib/api/schema.d.ts` | Generated types — all 31 API paths and every DTO |
|
||||||
|
| `src/lib/api/schema-helpers.ts` | Friendly aliases (`ApplicationUserDTO`, `LoginRequest`, …) |
|
||||||
|
| `src/lib/api/client.ts` | `apiRequest` — bearer auth, JSON, `ApiError`; paths constrained to real routes |
|
||||||
|
| `src/lib/api/users.ts` | `login` against `/Users/auth` — the one unauthenticated call |
|
||||||
|
| `src/lib/api/errors.ts` | `toErrorMessage` — message to show, or redirect to `/login` on a 401 |
|
||||||
|
| `src/lib/api/tournaments.ts` | `listEvents` / `importSmashTournament` / `getResults`, authenticated from the session |
|
||||||
|
| `src/lib/api/games.ts` | `listGames` / `saveGame` / `deleteGame` / `searchSmashGames` |
|
||||||
|
| `src/lib/api/admin-users.ts` | `listUsers` / `listRoles` / `addUser` / `deleteUser` — all Admin-only |
|
||||||
|
| `src/lib/api/statistics.ts` | `getMatchStats` (set-level win/loss and head to head), `listVersusPlayers` / `getVersus` (one pairing, per game) |
|
||||||
|
| `src/lib/api/sheets.ts` | `getSheetsConfig` / `exportToSheets` — writes a ranking table into the club's Google Spreadsheet |
|
||||||
|
| `src/lib/tournaments/results.ts` | Pure reshaping of `TournamentsResultDTO`: ranking grid, per-game placements, WordPress HTML, CSV |
|
||||||
|
| `src/lib/tournaments/sheet.ts` | Pure: ranking grid → one spreadsheet tab, plus the tab title a selection suggests |
|
||||||
|
| `src/lib/statistics/aggregate.ts` | Pure aggregation for `/statistiques`: standings, per-game and per-event summaries, CSV |
|
||||||
|
| `src/lib/statistics/load.ts` | `loadEventResults` — per-event `GetResults` fan-out with progress, partial failure and abort |
|
||||||
|
| `src/lib/ui/PlayerPicker.svelte` | Filterable player list used twice on `/statistiques/players` |
|
||||||
|
| `src/lib/games/draft.ts` | `GameDTO` ⇄ editor form, including the blank-to-NULL rules |
|
||||||
|
| `src/lib/ui/classes.ts` | The Tailwind class strings shared by the pages |
|
||||||
|
| `src/lib/stores/session.svelte.ts` | Signed-in user, persisted to `localStorage`, drops expired JWTs |
|
||||||
|
|
||||||
|
Calling another endpoint takes one line, and the path is checked at compile time:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { apiRequest, buildPath, session } from '$lib';
|
||||||
|
import type { GameDTO } from '$lib';
|
||||||
|
|
||||||
|
const games = await apiRequest<GameDTO[]>('/api/Game', { token: session.token });
|
||||||
|
const one = await apiRequest<GameDTO>(buildPath('/api/Game/{id}', { id: 3 }));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
- `/login` — username + password, posts to `POST /Users/auth`, stores the returned JWT
|
||||||
|
- `/` — guarded; greets the signed-in user with **Hello, \<name\>.** and offers sign-out
|
||||||
|
- `/tournaments` — guarded; the start.gg half of the old Avalonia `TournamentResultView`,
|
||||||
|
and the one-click push of the generated ranking table into the club's Google Spreadsheet
|
||||||
|
- `/statistiques` — redirects to `/statistiques/rankings` (the path the navbar used before
|
||||||
|
the section became two pages)
|
||||||
|
- `/statistiques/rankings` — guarded; standings, attendance and match statistics over a
|
||||||
|
chosen scope
|
||||||
|
- `/statistiques/players` — guarded; two players, every game they met in, all events
|
||||||
|
- `/games` — guarded; the game catalogue editor, from the Avalonia `GamesView`
|
||||||
|
- `/users` — **Admin only**; add and remove accounts
|
||||||
|
|
||||||
|
`session.displayName` prefers `firstName lastName` and falls back to `username`.
|
||||||
|
|
||||||
|
### `/tournaments`
|
||||||
|
|
||||||
|
Ports the Smash.gg (start.gg) column of `LaDOSE.DesktopApp.Avalonia`:
|
||||||
|
|
||||||
|
1. **Import** — a slug (`start.gg/tournament/<slug>`) is sent to
|
||||||
|
`GET /api/Tournament/ParseSmash/{slug}`, which pulls the brackets, placements and
|
||||||
|
sets into the database. Every bracket must be `COMPLETED` or the API throws.
|
||||||
|
2. **Events** — `GET /api/Event`, newest first. Tick one event for a single
|
||||||
|
tournament, or several to aggregate a ranking season. The regex box replaces the
|
||||||
|
selection with every matching event name (e.g. `Ranking #13\d{2}`).
|
||||||
|
3. **Generate results** — `POST /api/Tournament/GetResults` with the selected ids;
|
||||||
|
the API applies the point rules in `ExternalProviderService`. Three views:
|
||||||
|
- *Ranking* — players × games with totals, highest first, plus CSV export and the
|
||||||
|
Google Sheets push below
|
||||||
|
- *By game* — placements and points for one game
|
||||||
|
- *HTML* — the podium table for the WordPress recap, with copy-to-clipboard
|
||||||
|
|
||||||
|
The Challonge half of the Avalonia view (date range, Challonge tournament list,
|
||||||
|
`ParseChallonge`) is deliberately not ported.
|
||||||
|
|
||||||
|
#### Google Sheets export
|
||||||
|
|
||||||
|
Next to **Export CSV** on the *Ranking* view: **Push to Google Sheets** writes that same
|
||||||
|
table straight into the club's spreadsheet, as one tab. It replaces the
|
||||||
|
download-then-import-by-hand step — the tab is the CSV, because `rankingToSheetTable`
|
||||||
|
reads the very same `RankingTable` that `buildCsv` does, so the two cannot drift.
|
||||||
|
|
||||||
|
Because `GetResults` merges everything it is given, selecting `Ranking #1301`, `#1302` and
|
||||||
|
`#1303` produces the cumulative table for that ranking day — which is why the tab title
|
||||||
|
defaults to the **latest** event in the selection (`Ranking #1303`). Override it in the
|
||||||
|
**Tab** box; leaving it blank uses the suggestion shown as the placeholder.
|
||||||
|
|
||||||
|
- The tab is **cleared and rewritten in place**. Formatting, notes and conditional
|
||||||
|
formatting survive (the write only sets `userEnteredValue`), but anything typed into it
|
||||||
|
by hand is lost — keep hand analysis in its own tab.
|
||||||
|
- Tabs the export does not name are **never touched or deleted**.
|
||||||
|
- Re-running changes nothing but the provenance footer's timestamp.
|
||||||
|
- Points and totals are written as **numbers**, so formulas over them keep working.
|
||||||
|
- Titles are sanitised server-side (Google forbids `: \ / ? * [ ]`, caps at 100 chars);
|
||||||
|
any rename is reported back in the success banner.
|
||||||
|
- Tab identity is the **title**, so renaming an event makes the next push write a new tab
|
||||||
|
beside the old one.
|
||||||
|
- The provenance footer, below the grid, names every event scored into the tab — so a
|
||||||
|
stale tab says so itself.
|
||||||
|
|
||||||
|
Server configuration lives in the `GoogleSheets` section — writer, target spreadsheet and
|
||||||
|
limits. The target is config-only because the sheet is replaced each year; see
|
||||||
|
`.env.example` for the one-time Google setup and the yearly swap. Set
|
||||||
|
`GoogleSheets:Writer` to `Logging` to see the exact payload in the API log without
|
||||||
|
touching a spreadsheet.
|
||||||
|
|
||||||
|
### `/statistiques/rankings`
|
||||||
|
|
||||||
|
**Rankings Statistiques.** Pick a scope — everything, the last 6/12 events, or a regex over event names — then
|
||||||
|
**Compute statistics**. Two independent sources feed the page, and they are kept apart
|
||||||
|
on purpose:
|
||||||
|
|
||||||
|
- **Points, placements, attendance** come from `POST /api/Tournament/GetResults`, called
|
||||||
|
**one event at a time**. The endpoint merges everything it is given and never says
|
||||||
|
which event a row came from, so per-event calls are the only way to get a time series
|
||||||
|
— and they contain the damage, because it throws a 500 on any bracket missing a rank-1
|
||||||
|
or rank-2 row. One broken import is reported as a skipped event instead of taking the
|
||||||
|
whole scope down with it.
|
||||||
|
- **Set-level win rates and head to head** come from `POST /api/Statistics/Matches`, in
|
||||||
|
a single call for the whole scope.
|
||||||
|
|
||||||
|
The **Matches** tab leads with its coverage line, and it matters: brackets imported
|
||||||
|
before set rows were persisted contribute placements but no matches, so those figures
|
||||||
|
can describe a fraction of the scope while the standings above cover all of it. Win
|
||||||
|
rates count **decided sets only**, so a player with no resolvable set is left out rather
|
||||||
|
than shown at 0%.
|
||||||
|
|
||||||
|
Other things worth knowing:
|
||||||
|
|
||||||
|
- Rank `999` is the service's "unplaced" sentinel (the participation bucket in
|
||||||
|
`ExternalProviderService`), so it never counts as a placement or a podium, and
|
||||||
|
**Best** shows `—`.
|
||||||
|
- Player names are merged case-insensitively, as everywhere else in the app.
|
||||||
|
- Brackets are counted per event, so a bracket name reused every month counts once
|
||||||
|
per event rather than once overall.
|
||||||
|
- Undated events sort last in the chart and the Events tab — `GET /api/Event` returns
|
||||||
|
newest first, which is the least misleading place to put them.
|
||||||
|
- `aggregate.ts` is pure, so it can be exercised under plain Node with fixtures, the
|
||||||
|
same way `src/lib/tournaments/results.ts` is.
|
||||||
|
|
||||||
|
### `/statistiques/players`
|
||||||
|
|
||||||
|
**Players Statistiques.** Pick two players and the page answers one question: how often
|
||||||
|
did they meet, and in which games. There is no event scope — the point of a pairing is
|
||||||
|
its whole history, and slicing it by season would only be the Rankings page again.
|
||||||
|
|
||||||
|
- The pickers come from `GET /api/Statistics/Players`, which lists **tournament
|
||||||
|
players** (the rows `set` points at), not the application accounts of `/users`. Only
|
||||||
|
players with at least one set in a bracket whose game is known are offered, so the
|
||||||
|
list can never suggest a player the breakdown must then report as empty.
|
||||||
|
- The breakdown comes from `GET /api/Statistics/Versus/{a}/{b}`, one request per
|
||||||
|
complete pairing, aborted and re-issued when either side changes. `winsA` is always
|
||||||
|
the first id's side.
|
||||||
|
- **A set carries no game.** The game belongs to the `Tournament` the set was played
|
||||||
|
in, and `Tournament.GameId` is nullable, so meetings in a bracket with no game cannot
|
||||||
|
be filed under one. The API excludes them from `games` and from the totals and counts
|
||||||
|
them in `unknownGameSets`; the page reports that number instead of hiding it — it is
|
||||||
|
the difference between "they never met" and "we cannot tell what they played".
|
||||||
|
- **Meetings** counts every recorded set, **Decided** only those with unequal scores.
|
||||||
|
A set with equal scores (including 0-0, and the `-1 / -1` a double DQ leaves behind)
|
||||||
|
happened, but nobody won it, so it is in neither record nor game counts. A single DQ
|
||||||
|
is stored as `-1` and clamps to zero games.
|
||||||
|
- `StatisticsService.AggregateVersus` is a pure static method over already-loaded rows,
|
||||||
|
like `Aggregate` beside it, so the win inference is testable without a database.
|
||||||
|
|
||||||
|
### `/games`
|
||||||
|
|
||||||
|
Ports `GamesView`: the list on the left (ordered by `Order`), an editor on the right.
|
||||||
|
|
||||||
|
- **Save** — `POST /api/Game`. `AddOrUpdate` inserts when `id` is 0 and otherwise
|
||||||
|
**replaces every column**, so the form always sends a complete `GameDTO`; blank
|
||||||
|
text fields are sent as `null`.
|
||||||
|
- **New game** — starts an empty draft with `id` 0 and the next free `Order`. The
|
||||||
|
desktop app instead posted a blank row immediately and let you fill it in after.
|
||||||
|
- **Delete** — `DELETE /api/Game/{id}`, behind a confirm.
|
||||||
|
- **Find on start.gg** — `GET /api/Game/smash/{name}` searches start.gg's videogame
|
||||||
|
catalogue using the long name (falling back to the name); picking a match fills
|
||||||
|
`smashId`. The ids listed are **start.gg** videogame ids, not LaDOSE game ids.
|
||||||
|
|
||||||
|
`smashId` is what bracket imports match on: a game without one collects its results
|
||||||
|
under a synthetic "GAME NOT FOUND" entry.
|
||||||
|
|
||||||
|
Unlike the desktop form, `imgUrl` is editable here — it is part of `GameDTO` and was
|
||||||
|
otherwise only reachable through the database.
|
||||||
|
|
||||||
|
### `/users` (Admin only)
|
||||||
|
|
||||||
|
Lists every account with its roles, creates accounts, and deletes them.
|
||||||
|
|
||||||
|
`POST /Users/register` used to be `[AllowAnonymous]` so that the first account could be
|
||||||
|
created. That route is gone; account creation is now `POST /Users/AddUser` and requires
|
||||||
|
the **Admin** role, so before this
|
||||||
|
page is reachable at all, one account has to be promoted directly in the database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# edit the username in the file first, then:
|
||||||
|
psql "$LADOSE_DB" -v ON_ERROR_STOP=1 -f ../../Sql/2026-08-05_roles.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
How roles work:
|
||||||
|
|
||||||
|
- They live in the pre-existing `applicationrole` / `applicationuserrole` tables. The
|
||||||
|
script seeds `Admin` and `User`; no schema change was needed.
|
||||||
|
- Only user management checks a role. **Every other endpoint is unchanged** — a plain
|
||||||
|
or role-less account can still use tournaments, games and the rest.
|
||||||
|
- The JWT carries only the user id. Roles are read from the database on every request
|
||||||
|
(`OnTokenValidated` in `Startup.cs`), so granting or revoking Admin applies to the
|
||||||
|
caller's next request instead of whenever their 16-minute token expires.
|
||||||
|
- `session.isAdmin` hides the link and the page, but that is cosmetic — the API is
|
||||||
|
what enforces access, and a non-admin calling these endpoints gets a 403.
|
||||||
|
- The API refuses to delete the caller's own account. Since only an admin can reach the
|
||||||
|
endpoint, that is what guarantees at least one admin always remains.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The API issues 16-minute tokens. A lapsed token is treated as signed out on load;
|
||||||
|
there is no refresh flow yet, so long sessions will need a re-login. The guarded
|
||||||
|
pages turn a 401 into a redirect back to `/login` (see `toErrorMessage`).
|
||||||
|
- The API has no exception middleware, so an unhandled server error arrives as an
|
||||||
|
HTML developer page. `ApiError` then carries only the status, which is why each
|
||||||
|
call site supplies its own fallback message.
|
||||||
|
- `GetResults` only fills `slug` when **one** event id is requested, so the
|
||||||
|
"Voir le Bracket" links appear only for a single-event export.
|
||||||
|
- The Sheets export posts a 7-deep body, so `Startup.cs` sets Newtonsoft's `MaxDepth` to
|
||||||
|
32. `MaxDepth` governs *reading*; at the previous value of 4 the request was rejected
|
||||||
|
before it reached the controller.
|
||||||
|
- Player names are merged case-insensitively across brackets, matching the desktop app.
|
||||||
|
- `src/routes/+layout.ts` sets `ssr = false`: the JWT lives in the browser, so there
|
||||||
|
is nothing meaningful to render on the server.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check # svelte-check (types + template diagnostics)
|
||||||
|
npm run lint # eslint, including type-aware rules
|
||||||
|
npm run test # vitest over the pure modules in $lib
|
||||||
|
npm run build # static build into ./build
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run test` covers `$lib/statistics`, `$lib/tournaments`, `$lib/games/draft` and the
|
||||||
|
small shared helpers beside them (`csv`, `events`, `format`). Those modules are pure by
|
||||||
|
design, so they run under plain Node with no API and no database.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Vite inlines import.meta.env.VITE_* at build time, so runtime configuration cannot
|
||||||
|
# come from an env var the app reads directly. Instead we write /config.js here, which
|
||||||
|
# src/app.html loads synchronously ahead of the bundle. One image, any environment.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
config_file=/usr/share/nginx/html/config.js
|
||||||
|
|
||||||
|
# Drop control characters (newlines included) so the value cannot break out of the
|
||||||
|
# string literal, then escape backslashes before double quotes. A URL containing
|
||||||
|
# & ? " or a trailing slash stays inert data.
|
||||||
|
value=$(printf '%s' "${LADOSE_API_BASE_URL:-}" | tr -d '\001-\037')
|
||||||
|
|
||||||
|
if [ -z "$value" ]; then
|
||||||
|
# Empty object, not an empty string: lets the app keep its baked-in default.
|
||||||
|
printf 'window.__LADOSE_CONFIG__ = {};\n' >"$config_file"
|
||||||
|
echo "config.js: LADOSE_API_BASE_URL unset, deferring to the built-in default" >&2
|
||||||
|
else
|
||||||
|
escaped=$(printf '%s' "$value" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')
|
||||||
|
printf 'window.__LADOSE_CONFIG__ = { apiBaseUrl: "%s" };\n' "$escaped" >"$config_file"
|
||||||
|
echo "config.js: apiBaseUrl=$value" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user