mirror of
https://github.com/XFox111/bonch-calendar.git
synced 2026-06-30 10:52:41 +03:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1adeff73e4 | |||
| bb6f9d493d | |||
| d5f5f54eb7 | |||
| cebd38698f | |||
| a3458b825e | |||
| 7f88891429 | |||
| 6a2b6980f9 | |||
| 4d4c3adde6 | |||
| 734c43548a | |||
| b03a05b89f | |||
| d765ab0269 | |||
| 462dab9e3e | |||
| b03ff5c61c | |||
| e33acd4fc4 | |||
| 916c7bcb22 | |||
| 882e196ea8 | |||
| fa39e8d26c | |||
| f4d1d4e983 | |||
| c0e6ced376 | |||
| 452e6d51b2 | |||
| 9b74bb63c5 | |||
| ea6dbf2d8f | |||
| c6d91d7020 | |||
| 610b7909cd | |||
| 2560f124f1 | |||
| f1324ce1d1 | |||
| 97751cf20b | |||
| 14f1b4e7b5 | |||
| b932b49b65 | |||
| 52d980534e | |||
| 9d0f6a31d8 |
@@ -6,7 +6,7 @@
|
||||
"image": "mcr.microsoft.com/devcontainers/dotnet:1-10.0",
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"ghcr.io/devcontainers/features/node:2": {
|
||||
"version": "lts",
|
||||
"pnpmVersion": "none",
|
||||
"nvmVersion": "latest"
|
||||
|
||||
+16
-4
@@ -48,7 +48,10 @@ updates:
|
||||
schedule:
|
||||
interval: monthly
|
||||
rebase-strategy: disabled
|
||||
open-pull-requests-limit: 20
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
@@ -58,7 +61,10 @@ updates:
|
||||
schedule:
|
||||
interval: monthly
|
||||
rebase-strategy: disabled
|
||||
open-pull-requests-limit: 20
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "devcontainers"
|
||||
directory: "/"
|
||||
@@ -68,7 +74,10 @@ updates:
|
||||
schedule:
|
||||
interval: monthly
|
||||
rebase-strategy: disabled
|
||||
open-pull-requests-limit: 20
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directories:
|
||||
@@ -80,4 +89,7 @@ updates:
|
||||
schedule:
|
||||
interval: monthly
|
||||
rebase-strategy: disabled
|
||||
open-pull-requests-limit: 20
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
@@ -31,16 +31,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./api
|
||||
tags: ${{ github.repository }}-api:ci
|
||||
|
||||
- run: docker save ${{ github.repository }}:ci | gzip > api_image.tar.gz
|
||||
|
||||
- uses: actions/upload-artifact@v5
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: api-image
|
||||
path: api_image.tar.gz
|
||||
@@ -49,16 +49,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./app
|
||||
tags: ${{ github.repository }}-app:ci
|
||||
|
||||
- run: docker save ${{ github.repository }}:ci | gzip > app_image.tar.gz
|
||||
|
||||
- uses: actions/upload-artifact@v5
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: app-image
|
||||
path: app_image.tar.gz
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
container: node:latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- run: npm install
|
||||
working-directory: ./app
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
- run: npm audit --audit-level=moderate --json > audit_report.json
|
||||
working-directory: ./app
|
||||
|
||||
- uses: actions/upload-artifact@v5
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: app-audit-report
|
||||
path: ./app/audit_report.json
|
||||
|
||||
@@ -13,9 +13,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: docker/metadata-action@v5
|
||||
- uses: docker/metadata-action@v6
|
||||
id: meta
|
||||
with:
|
||||
images: |
|
||||
@@ -26,19 +26,19 @@ jobs:
|
||||
${{ github.ref_name }}
|
||||
|
||||
- name: "Login to Docker Hub"
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: "Login to GitHub Container Registry"
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./api
|
||||
push: true
|
||||
@@ -48,9 +48,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: docker/metadata-action@v5
|
||||
- uses: docker/metadata-action@v6
|
||||
id: meta
|
||||
with:
|
||||
images: |
|
||||
@@ -61,19 +61,19 @@ jobs:
|
||||
${{ github.ref_name }}
|
||||
|
||||
- name: "Login to Docker Hub"
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: "Login to GitHub Container Registry"
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./app
|
||||
push: true
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- run: npm install
|
||||
working-directory: ./app
|
||||
@@ -104,12 +104,12 @@ jobs:
|
||||
working-directory: ./app
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
- uses: actions/upload-pages-artifact@v4
|
||||
- uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: "./app/dist"
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Eugene Fox
|
||||
Copyright (c) 2026 Eugene Fox
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -14,7 +14,7 @@ This is my farewell gift to the university.
|
||||
|
||||
## Demo
|
||||
|
||||
<!-- TODO: put demo here -->
|
||||

|
||||
|
||||
## Q&A
|
||||
|
||||
@@ -45,4 +45,4 @@ If you're interested in becoming a maintainer, please reach out to me via email
|
||||
[](https://github.com/xfox111)
|
||||
[](https://buymeacoffee.com/xfox111)
|
||||
|
||||
> ©2025 Eugene Fox. Licensed under [MIT license](https://github.com/XFox111/bonch-calendar/blob/main/LICENSE)
|
||||
> ©2026 Eugene Fox. Licensed under [MIT license](https://github.com/XFox111/bonch-calendar/blob/main/LICENSE)
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
##
|
||||
## Get latest from `dotnet new gitignore`
|
||||
|
||||
# Solution files
|
||||
*.sln
|
||||
*.slnx
|
||||
|
||||
# dotenv files
|
||||
.env
|
||||
|
||||
|
||||
@@ -480,3 +480,5 @@ $RECYCLE.BIN/
|
||||
|
||||
# Vim temporary swap files
|
||||
*.swp
|
||||
|
||||
core
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using BonchCalendar.Health;
|
||||
|
||||
namespace BonchCalendar;
|
||||
|
||||
/// <summary>
|
||||
/// Custom JSON serializer context for static serialization/deserialization of requests and responses
|
||||
/// </summary>
|
||||
[JsonSerializable(typeof(string))]
|
||||
[JsonSerializable(typeof(string[]))]
|
||||
[JsonSerializable(typeof(int))]
|
||||
[JsonSerializable(typeof(bool))]
|
||||
[JsonSerializable(typeof(StatsResponse))]
|
||||
[JsonSerializable(typeof(Dictionary<int, string>))]
|
||||
[JsonSerializable(typeof(HealthResponse))]
|
||||
internal partial class AppJsonSerializerContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -4,13 +4,13 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<PublishAot>true</PublishAot>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
|
||||
<PackageReference Include="Ical.Net" Version="5.1.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageReference Include="Ical.Net" Version="5.2.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+12
-1
@@ -5,6 +5,11 @@ Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
GET {{Host}}/stats
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
GET {{Host}}/faculties
|
||||
Accept: application/json
|
||||
|
||||
@@ -12,7 +17,7 @@ Accept: application/json
|
||||
|
||||
GET {{Host}}/groups
|
||||
?facultyId=56682
|
||||
&course=2
|
||||
&year=2
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -20,4 +25,10 @@ Accept: application/json
|
||||
@groupId = 56606
|
||||
@facultyId = 50029
|
||||
GET {{Host}}/timetable/{{facultyId}}/{{groupId}}
|
||||
?id=download
|
||||
Accept: text/calendar
|
||||
|
||||
# id parameter changes behavior for the calendar:
|
||||
# - If set to "download", nothing changes
|
||||
# - If not present, an additional event will be appended to the calendar (see Program.cs)
|
||||
# - If set to any other value, this request will be counted in active users stats (only once per ID until the next restart)
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BonchCalendar", "BonchCalendar.csproj", "{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Release|x64.Build.0 = Release|Any CPU
|
||||
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,3 @@
|
||||
<Solution>
|
||||
<Project Path="BonchCalendar.csproj" />
|
||||
</Solution>
|
||||
+15
-9
@@ -1,15 +1,21 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine-aot AS build
|
||||
WORKDIR /build
|
||||
|
||||
ADD *.csproj .
|
||||
RUN dotnet restore
|
||||
ADD --link . .
|
||||
RUN --mount=type=cache,target=/root/.nuget \
|
||||
--mount=type=cache,target=/source/bin \
|
||||
--mount=type=cache,target=/source/obj \
|
||||
dotnet publish --output /out BonchCalendar.csproj \
|
||||
&& rm /out/*.dbg /out/*.Development.json
|
||||
|
||||
ADD . ./
|
||||
RUN dotnet publish --no-restore --configuration Release --output /out
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS prod
|
||||
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine AS prod
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /out/* .
|
||||
COPY --link --from=build /out/* .
|
||||
USER $APP_UID
|
||||
|
||||
ENTRYPOINT [ "dotnet", "BonchCalendar.dll" ]
|
||||
HEALTHCHECK --interval=60s --retries=3 --start-period=5s --timeout=10s \
|
||||
CMD wget --no-verbose --tries 1 --spider http://localhost:8080/health || exit 1
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT [ "./BonchCalendar" ]
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// This file is used by Code Analysis to maintain SuppressMessage
|
||||
// attributes that are applied to this project.
|
||||
// Project-level suppressions either have no target or are given
|
||||
// a specific target and scoped to a namespace, type, member, etc.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
[assembly: SuppressMessage("Performance", "CA1873:Avoid potentially expensive logging", Justification = "The cost is negligible on current setup.")]
|
||||
@@ -3,24 +3,24 @@ using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
namespace BonchCalendar.Health;
|
||||
|
||||
public class ApiHealthCheck(ApiService groupService) : IHealthCheck
|
||||
/// <summary>
|
||||
/// Healthcheck service for sut.ru API.
|
||||
/// </summary>
|
||||
public class ApiHealthCheck(IssueTrackingService trackingService) : IHealthCheck
|
||||
{
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context, CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
try
|
||||
{
|
||||
Dictionary<int, string> faculties = await groupService.GetFacultiesListAsync();
|
||||
Dictionary<string, object> report = trackingService.GetReport();
|
||||
|
||||
if (faculties.Count > 0)
|
||||
return HealthCheckResult.Healthy();
|
||||
// We deem service "unhealthy" if any of the last requests to the API were unsuccessful.
|
||||
if (report.Count > 0)
|
||||
return HealthCheckResult.Unhealthy(
|
||||
description: "We're having issues with fetching data from the timetable website.",
|
||||
data: report
|
||||
);
|
||||
|
||||
return HealthCheckResult.Degraded(description: "Timetable website looks to be up, but returned an empty list of faculties.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy(description: "Timetable website appears to be down.", exception: ex);
|
||||
}
|
||||
return HealthCheckResult.Healthy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
namespace BonchCalendar.Health;
|
||||
|
||||
public static class HealthCheckWriter
|
||||
{
|
||||
private static readonly byte[] _emptyResponse = [ (byte)'{', (byte)'}' ];
|
||||
private static readonly JsonSerializerContext _jsonContext = CreateSerializerContext();
|
||||
|
||||
public static async Task WriteHealthCheckResponse(HttpContext context, HealthReport report)
|
||||
{
|
||||
if (report is null)
|
||||
{
|
||||
// Just in case, but this should not ever happen.
|
||||
await context.Response.BodyWriter.WriteAsync(_emptyResponse).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create DTO from the report.
|
||||
HealthResponse response = new(
|
||||
Status: report.Status,
|
||||
TotalDuration: report.TotalDuration,
|
||||
Entries: report.Entries.ToDictionary(
|
||||
e => e.Key,
|
||||
e => new HealthResponseEntry(
|
||||
Status: e.Value.Status,
|
||||
Description: e.Value.Description,
|
||||
Duration: e.Value.Duration,
|
||||
Data: e.Value.Data
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Write DTO to the response body.
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
await JsonSerializer.SerializeAsync(context.Response.Body, response, typeof(HealthResponse), _jsonContext).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static AppJsonSerializerContext CreateSerializerContext()
|
||||
{
|
||||
JsonSerializerOptions options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
options.Converters.Add(new JsonStringEnumConverter<HealthStatus>(options.PropertyNamingPolicy));
|
||||
options.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
|
||||
|
||||
return new AppJsonSerializerContext(options);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response body for /health endpoint.
|
||||
/// </summary>
|
||||
/// <param name="Status">Overall status of the application.</param>
|
||||
/// <param name="TotalDuration">Total time it took to complete the health check.</param>
|
||||
/// <param name="Entries">List of subcomponent healthcheck reports.</param>
|
||||
public record HealthResponse(
|
||||
HealthStatus Status,
|
||||
TimeSpan TotalDuration,
|
||||
IReadOnlyDictionary<string, HealthResponseEntry> Entries
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Healthcheck report for a subcomponent.
|
||||
/// </summary>
|
||||
/// <param name="Status">Status of the subcomponent.</param>
|
||||
/// <param name="Description">Report remarks.</param>
|
||||
/// <param name="Duration">Time it took to complete health check for this subcomponent.</param>
|
||||
/// <param name="Data">Addtional report data.</param>
|
||||
public record HealthResponseEntry(
|
||||
HealthStatus Status,
|
||||
string? Description,
|
||||
TimeSpan Duration,
|
||||
IReadOnlyDictionary<string, object> Data
|
||||
);
|
||||
+163
-58
@@ -4,19 +4,23 @@ using BonchCalendar;
|
||||
using BonchCalendar.Health;
|
||||
using BonchCalendar.Services;
|
||||
using BonchCalendar.Utils;
|
||||
using HealthChecks.UI.Client;
|
||||
using Ical.Net;
|
||||
using Ical.Net.CalendarComponents;
|
||||
using Ical.Net.Serialization;
|
||||
using Ical.Net.DataTypes;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args);
|
||||
|
||||
// Adding static JSON serializer since we're running in Native AOT
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default)
|
||||
);
|
||||
|
||||
// Add services to the container.
|
||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddValidation();
|
||||
builder.Services.AddOpenApi(); // OpenAPI specification generator
|
||||
builder.Services.AddValidation(); // Request validation
|
||||
|
||||
// Customizing non-200 responses to include trace identifier and a no-as-a-service reason
|
||||
builder.Services.AddProblemDetails(configure =>
|
||||
{
|
||||
configure.CustomizeProblemDetails = context =>
|
||||
@@ -27,104 +31,205 @@ builder.Services.AddProblemDetails(configure =>
|
||||
});
|
||||
|
||||
builder.Services
|
||||
.AddScoped<ApiService>()
|
||||
.AddScoped<ParsingService>();
|
||||
.AddSingleton<IssueTrackingService>() // Service for tracking latest API request results
|
||||
.AddScoped<TimetableService>() // Service for generating a timetable
|
||||
.AddScoped<ApiService>() // Service for making API calls to sut.ru API
|
||||
.AddScoped<ParsingService>(); // Service for parsing timetable from sut.ru
|
||||
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddCheck<ApiHealthCheck>("timetable_website");
|
||||
.AddCheck<ApiHealthCheck>("timetable_website"); // Healthcheck service
|
||||
|
||||
// Get ORIGIN_DOMAIN environmental variable
|
||||
string? corsDomain = Environment.GetEnvironmentVariable("ORIGIN_DOMAIN");
|
||||
|
||||
// Configure defautl CORS policy
|
||||
builder.Services.AddCors(options =>
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
// Allow only GET requests with any headers
|
||||
policy
|
||||
.WithMethods(["GET"])
|
||||
.AllowAnyOrigin()
|
||||
.AllowAnyHeader()
|
||||
)
|
||||
.AllowAnyHeader();
|
||||
|
||||
// If ORIGIN_DOMAIN environmental variable is set, allow request only from this domain,
|
||||
// otherwise allow request from any domains.
|
||||
if (string.IsNullOrWhiteSpace(corsDomain))
|
||||
policy.AllowAnyOrigin();
|
||||
else
|
||||
policy.WithOrigins(corsDomain);
|
||||
})
|
||||
);
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
app.UseCors();
|
||||
app.UseStatusCodePages();
|
||||
app.MapOpenApi();
|
||||
// Configure the HTTP request pipeline
|
||||
app.UseCors(); // Enable CORS
|
||||
app.UseStatusCodePages(); // Enable default JSON response body for non-200 responses.
|
||||
app.MapOpenApi(); // Map OpenAPI sepcification. Available at /openapi/v1.json
|
||||
|
||||
// Map healthcheck endpoint with custom response writer
|
||||
// Remark: /health and /openapi/v1.json endpoints are not present in OpenAPI specification.
|
||||
app.MapHealthChecks("/health", new HealthCheckOptions
|
||||
{
|
||||
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
|
||||
ResponseWriter = HealthCheckWriter.WriteHealthCheckResponse
|
||||
});
|
||||
|
||||
// Request singleton services which will be used in subsequent endpoints
|
||||
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||
IssueTrackingService tracker = app.Services.GetRequiredService<IssueTrackingService>();
|
||||
|
||||
// List of identifiers for tracking unique visits to /timetable endpoint (essentially, for unique user counting).
|
||||
List<string> ids = [];
|
||||
|
||||
// Statistics endpoint. Shows number of active users.
|
||||
app.MapGet("/stats", () => Results.Ok(new StatsResponse(ids.Count)))
|
||||
.WithName("GetStats")
|
||||
.WithDescription("Get basic usage statistics.")
|
||||
.Produces<StatsResponse>(StatusCodes.Status200OK);
|
||||
|
||||
// Retrieve list of faculties and their IDs from sut.ru API
|
||||
app.MapGet("/faculties", async ([FromServices] ApiService apiService) =>
|
||||
{
|
||||
logger.LogInformation("Fetching faculties list.");
|
||||
Dictionary<int, string> faculties = await apiService.GetFacultiesListAsync();
|
||||
return Results.Ok(faculties);
|
||||
try
|
||||
{
|
||||
Dictionary<int, string> faculties = await apiService.GetFacultiesListAsync();
|
||||
logger.LogInformation("Fetched {Count} faculties.", faculties.Count);
|
||||
tracker.TrackFacultyFetch(true); // Record that last attempt to retrieve faculties was successful
|
||||
return Results.Ok(faculties);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to fetch faculties list.");
|
||||
tracker.TrackFacultyFetch(false); // Record that last attempt to retrieve faculties was unsuccessful
|
||||
return Results.Problem("Failed to fetch faculties list.", statusCode: StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
})
|
||||
.WithName("GetFaculties")
|
||||
.WithDescription("Gets the list of faculties.")
|
||||
.ProducesProblem(StatusCodes.Status500InternalServerError)
|
||||
.Produces<Dictionary<int, string>>(StatusCodes.Status200OK);
|
||||
|
||||
app.MapGet("/groups", async ([FromServices] ApiService apiService, int facultyId, [Range(1, 5)] int course) =>
|
||||
// Retrieve list of groups for a chosen faculty and year.
|
||||
// Year can be in range from 1 to 5.
|
||||
// If year is not specified, all groups for chosen faculty will be retrieved.
|
||||
app.MapGet("/groups", async ([FromServices] ApiService apiService, int facultyId, [Range(1, 5)] int? year) =>
|
||||
{
|
||||
logger.LogInformation("Fetching groups list for faculty {FacultyId} and course {Course}.", facultyId, course);
|
||||
Dictionary<int, string> groups = await apiService.GetGroupsListAsync(facultyId, course);
|
||||
return Results.Ok(groups);
|
||||
year ??= 0; // Setting year to 0 (show all groups) if not specified in the request.
|
||||
|
||||
try
|
||||
{
|
||||
Dictionary<int, string> groups = await apiService.GetGroupsListAsync(facultyId, year.Value);
|
||||
logger.LogInformation("Fetched {Count} groups (facultyId: {FacultyId}, year: {Year}).", groups.Count, facultyId, year);
|
||||
tracker.TrackGroupFetch(facultyId, year.Value, true); // Track whether retrieving groups for this specific faculty and year was successul or not.
|
||||
return Results.Ok(groups);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to fetch groups list (facultyId: {FacultyId}, year: {Year}).", facultyId, year);
|
||||
tracker.TrackGroupFetch(facultyId, year.Value, false); // Track whether retrieving groups for this specific faculty and year was successul or not.
|
||||
return Results.Problem(
|
||||
"Failed to fetch groups list.",
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["facultyId"] = facultyId,
|
||||
["year"] = year
|
||||
}
|
||||
);
|
||||
}
|
||||
})
|
||||
.WithName("GetGroups")
|
||||
.WithDescription("Gets the list of groups for the specified faculty and course.")
|
||||
.Produces<Dictionary<int, string>>(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status500InternalServerError)
|
||||
.ProducesValidationProblem();
|
||||
|
||||
// Retrieve timetable for specified group in form of iCal.
|
||||
app.MapGet("/timetable/{facultyId}/{groupId}", async (
|
||||
int facultyId, int groupId,
|
||||
[FromServices] ApiService apiService,
|
||||
[FromServices] ParsingService parsingService
|
||||
int facultyId, int groupId, string? id,
|
||||
[FromServices] TimetableService timetableService
|
||||
) =>
|
||||
{
|
||||
logger.LogInformation("Generating timetable for group {GroupId} of faculty {FacultyId}.", groupId, facultyId);
|
||||
string cacheFile = Path.Combine(Path.GetTempPath(), $"bonch_cal_{groupId}.ics");
|
||||
string? content = await timetableService.TryGetTimetableFromCacheAsync(groupId);
|
||||
bool hasId = !string.IsNullOrEmpty(id);
|
||||
|
||||
if (File.Exists(cacheFile) && (DateTime.UtcNow - File.GetLastWriteTimeUtc(cacheFile)).TotalHours < 6)
|
||||
// If this is the first request with given id, we record it.
|
||||
// "download" is a special "ID" that is used solely for downloading timetable as file.
|
||||
if (hasId && id is not "download" && !ids.Contains(id!))
|
||||
ids.Add(id!);
|
||||
|
||||
// If we have a valid cache, we serve it, instead of retrieving new timetable from sut.ru API
|
||||
// We don't serve cache for requests with no ID, since they have a special behavior.
|
||||
if (content is not null && hasId)
|
||||
{
|
||||
if (args.Contains("--no-cache"))
|
||||
logger.LogWarning("Cache disabled via --no-cache, regenerating timetable for group {GroupId}.", groupId);
|
||||
logger.LogInformation("Serving timetable for {FacultyId}/{GroupId} from cache.", facultyId, groupId);
|
||||
return Results.Text(content, contentType: "text/calendar");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
logger.LogInformation("Begin generating timetable for {FacultyId}/{GroupId}.", facultyId, groupId);
|
||||
|
||||
if (hasId)
|
||||
content = await timetableService.GetTimetableAsync(facultyId, groupId, saveToCache: true);
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Serving timetable for group {GroupId} from cache.", groupId);
|
||||
return Results.Text(await File.ReadAllTextAsync(cacheFile), contentType: "text/calendar");
|
||||
// For requests with no ID we append an event at 7pm that asks users to update their calendar URL,
|
||||
// since now all /timetable requests must have one.
|
||||
// This is a temporary behavior that will be changed to just sending 4xx response instead later.
|
||||
content = await timetableService.GetTimetableAsync(facultyId, groupId, saveToCache: false, transform: calendar =>
|
||||
calendar.Events.Add(new()
|
||||
{
|
||||
Summary = "Важно: обновите календарь расписания",
|
||||
Description = """
|
||||
Ваша ссылка на календарь устарела. Пожалуйста, обновите ее чтобы продолжить пользоватся сервисом.
|
||||
|
||||
Новая ссылка позволит нам собирать статистику о количестве активных пользователей. Важно: мы НЕ собираем какие-либо персональные данные! Новая ссылка лишь позволит нам узнать точное количесво пользователей, что очень важно для продолжения работы сервиса.
|
||||
|
||||
Для того чтобы обновить ссылку:
|
||||
1. Перейдите на сайт https://bonch.xfox111.net/
|
||||
2. Повторите все действия что и при создании календаря
|
||||
3. Удалите старый календарь
|
||||
|
||||
Просим прощения за доставленные неудобства.
|
||||
|
||||
Если возникнут вопросы – обращайтесь на почту feedback@xfox111.net
|
||||
|
||||
Это событие будет появляться каждый день в 19:00 до тех пор, пока ссылка не будет обновлена.
|
||||
""",
|
||||
Location = "https://bonch.xfox111.net",
|
||||
Start = new CalDateTime((DateTime.Today + TimeSpan.FromHours(16)).ToUniversalTime()),
|
||||
End = new CalDateTime((DateTime.Today + TimeSpan.FromHours(16) + TimeSpan.FromMinutes(15)).ToUniversalTime()),
|
||||
})
|
||||
);
|
||||
logger.LogInformation("Deprecation notice appended to calendar {FacultyId}/{GroupId}.", facultyId, groupId);
|
||||
}
|
||||
|
||||
logger.LogInformation("Fetched timetable for {FacultyId}/{GroupId}.", facultyId, groupId);
|
||||
tracker.TrackTimetableFetch(facultyId, groupId, true); // Track whether retrieving timetable for this specific group was successul or not.
|
||||
return Results.Text(content, contentType: "text/calendar");
|
||||
}
|
||||
|
||||
DateTime semesterStartDate = await apiService.GetSemesterStartDateAsync(groupId);
|
||||
string groupName = (await apiService.GetGroupsListAsync(facultyId, 0))[groupId];
|
||||
|
||||
string classesRaw = await apiService.GetScheduleDocumentAsync(groupId, TimetableType.Classes);
|
||||
List<CalendarEvent> timetable = [.. parsingService.ParseGeneralTimetable(classesRaw, semesterStartDate, groupName)];
|
||||
|
||||
TimetableType[] types = [TimetableType.Attestations, TimetableType.Exams, TimetableType.ExamsForExtramural];
|
||||
foreach (TimetableType type in types)
|
||||
catch (Exception ex)
|
||||
{
|
||||
classesRaw = await apiService.GetScheduleDocumentAsync(groupId, type);
|
||||
timetable.AddRange(parsingService.ParseExamTimetable(classesRaw, groupName));
|
||||
logger.LogError(ex, "Failed to generate timetable for {FacultyId}/{GroupId}.", facultyId, groupId);
|
||||
tracker.TrackTimetableFetch(facultyId, groupId, false); // Track whether retrieving timetable for this specific group was successul or not.
|
||||
|
||||
return Results.Problem(
|
||||
"Failed to fetch timetable",
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["facultyId"] = facultyId,
|
||||
["groupId"] = groupId
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Calendar calendar = new();
|
||||
calendar.Properties.Add(new CalendarProperty("X-WR-CALNAME", groupName));
|
||||
calendar.Properties.Add(new CalendarProperty("X-WR-TIMEZONE", "Europe/Moscow"));
|
||||
calendar.Properties.Add(new CalendarProperty("REFRESH-INTERVAL;VALUE=DURATION", "PT6H"));
|
||||
calendar.Events.AddRange(timetable);
|
||||
calendar.AddTimeZone(new VTimeZone("Europe/Moscow"));
|
||||
string serialized = new CalendarSerializer().SerializeToString(calendar)!;
|
||||
|
||||
await File.WriteAllTextAsync(cacheFile, serialized);
|
||||
logger.LogInformation("Cached timetable for group {GroupId} to {CacheFile}.", groupId, cacheFile);
|
||||
return Results.Text(serialized, contentType: "text/calendar");
|
||||
})
|
||||
.WithName("GetTimetable")
|
||||
.WithDescription("Gets the iCal timetable for the specified group.")
|
||||
.Produces<string>(StatusCodes.Status200OK, "text/calendar")
|
||||
.ProducesProblem(StatusCodes.Status500InternalServerError)
|
||||
.ProducesValidationProblem();
|
||||
|
||||
// Start the application.
|
||||
app.Run();
|
||||
|
||||
@@ -4,8 +4,19 @@ using BonchCalendar.Utils;
|
||||
|
||||
namespace BonchCalendar.Services;
|
||||
|
||||
// For better understanding of what is happening here,
|
||||
// I recommend visiting https://cabinet.sut.ru/raspisanie_all_new.php
|
||||
// and trying to send requests yourself.
|
||||
|
||||
/// <summary>
|
||||
/// Service for calling sut.ru API.
|
||||
/// </summary>
|
||||
public class ApiService
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieve list of faculties and their IDs.
|
||||
/// </summary>
|
||||
/// <returns>A dictionary, where the key is faculty's ID and the value is faculty's name.</returns>
|
||||
public async Task<Dictionary<int, string>> GetFacultiesListAsync() =>
|
||||
ParseListResponse(await SendRequestAsync(new()
|
||||
{
|
||||
@@ -13,15 +24,30 @@ public class ApiService
|
||||
["schet"] = GetCurrentSemesterId()
|
||||
}));
|
||||
|
||||
public async Task<Dictionary<int, string>> GetGroupsListAsync(int facultyId, int course) =>
|
||||
/// <summary>
|
||||
/// Retrieve list of groups for specified faculty and year.
|
||||
/// </summary>
|
||||
/// <param name="facultyId">ID of selected faculty.</param>
|
||||
/// <param name="year">An academic year. Should be from 0 to 5.</param>
|
||||
/// <returns>A dictionary, where the key is group's ID and the value is group's name.</returns>
|
||||
/// <remarks>
|
||||
/// If <paramref name="year"/> is set to 0, all groups for the specified faculty will be retrieved instead.
|
||||
/// </remarks>
|
||||
public async Task<Dictionary<int, string>> GetGroupsListAsync(int facultyId, int year) =>
|
||||
ParseListResponse(await SendRequestAsync(new()
|
||||
{
|
||||
["choice"] = "1",
|
||||
["schet"] = GetCurrentSemesterId(),
|
||||
["faculty"] = facultyId.ToString(), // Specifying faculty ID returns a list of groups
|
||||
["kurs"] = course.ToString() // Course number is actually optional, but filters out other groups. Can be set 0 to get all groups of the faculty.
|
||||
["kurs"] = year.ToString() // Course number is actually optional, but filters out other groups. Can be set 0 to get all groups of the faculty.
|
||||
}));
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve timetable document for the specified group.
|
||||
/// </summary>
|
||||
/// <param name="groupId">ID of selected group.</param>
|
||||
/// <param name="timetableType">Type of a timetable to retrieve.</param>
|
||||
/// <returns>A string, represeting raw HTML document, that contains the timetable.</returns>
|
||||
public async Task<string> GetScheduleDocumentAsync(int groupId, TimetableType timetableType) =>
|
||||
await SendRequestAsync(new()
|
||||
{
|
||||
@@ -30,14 +56,34 @@ public class ApiService
|
||||
["group"] = groupId.ToString()
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve current semester start date.
|
||||
/// </summary>
|
||||
/// <param name="groupId">ID of a group.</param>
|
||||
/// <returns>A <see cref="DateTime"/> object, representing the first day of current semester.</returns>
|
||||
/// <remarks>
|
||||
/// <paramref name="groupId"/> can be any valid group ID. We only need it for retrieving a correct HTML document.
|
||||
/// </remarks>
|
||||
public async Task<DateTime> GetSemesterStartDateAsync(int groupId)
|
||||
{
|
||||
using HttpClient client = new();
|
||||
// We go to this URL, since it has to contain current week number,
|
||||
// which we can use to calculate the first day of the semester.
|
||||
// If we don't specify group, we'll get a page listing all available groups,
|
||||
// which doesn't contain current week number, thus, rendering it useless for us.
|
||||
string content = await client.GetStringAsync($"https://www.sut.ru/studentu/raspisanie/raspisanie-zanyatiy-studentov-ochnoy-i-vecherney-form-obucheniya?group={groupId}");
|
||||
|
||||
using IHtmlDocument doc = new HtmlParser().ParseDocument(content);
|
||||
|
||||
// 1. Get <a> tag with id "rasp-prev"
|
||||
// 2. Get it's neighbor <div> tag that is the second child of their parent tag
|
||||
// 3. Get <span> tag inside the <div>
|
||||
string labelText = doc.QuerySelector("a#rasp-prev + div:nth-child(2) > span")!.TextContent;
|
||||
|
||||
// Content of the <span> tag is supposed to be something like "Нечетная неделя (15)"
|
||||
// So, we can use regular expressions to get the "15" part and parse it to an integer.
|
||||
int weekNumber = int.Parse(ParserUtils.NumberRegex().Match(labelText).Value);
|
||||
|
||||
DateTime currentDate = DateTime.Today;
|
||||
currentDate = currentDate
|
||||
.AddDays(-(int)currentDate.DayOfWeek + 1) // Move to Monday
|
||||
@@ -46,6 +92,8 @@ public class ApiService
|
||||
return currentDate;
|
||||
}
|
||||
|
||||
// Utility method that converts faculty or group list response into a dictionary.
|
||||
// It expected the reponse to be in format: "1,Group 1;2,Group2;..."
|
||||
private static Dictionary<int, string> ParseListResponse(string responseContent) =>
|
||||
responseContent
|
||||
.Split(';', StringSplitOptions.RemoveEmptyEntries)
|
||||
@@ -55,7 +103,8 @@ public class ApiService
|
||||
parts => parts[1]
|
||||
);
|
||||
|
||||
public async Task<string> SendRequestAsync(Dictionary<string, string> formData)
|
||||
// Utility method for sending request to sut.ru API.
|
||||
private static async Task<string> SendRequestAsync(Dictionary<string, string> formData)
|
||||
{
|
||||
HttpRequestMessage request = new(HttpMethod.Post, "https://cabinet.sut.ru/raspisanie_all_new.php")
|
||||
{
|
||||
@@ -64,7 +113,8 @@ public class ApiService
|
||||
|
||||
using HttpClient client = new(new HttpClientHandler
|
||||
{
|
||||
// Sometimes Bonch being Bonch just doesn't renew its SSL certificates properly
|
||||
// Sometimes Bonch being Bonch just doesn't renew its SSL certificates properly,
|
||||
// so we just assume that we're in the right place.
|
||||
ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true
|
||||
});
|
||||
|
||||
@@ -81,11 +131,12 @@ public class ApiService
|
||||
? 1 // August through January - first semester
|
||||
: 2; // Everything else - second
|
||||
|
||||
int termStartYear = now.Year - 2000; // We need only last two digits (e.g. 25 for 2025)
|
||||
int academicYearStartYear = now.Year - 2000; // We need only last two digits (e.g. 25 for 2025)
|
||||
// P.S. I am not a fun of this variable name either.
|
||||
|
||||
if (now.Month < 8) // Before August means we are in the second semester of the previous academic year
|
||||
termStartYear--;
|
||||
academicYearStartYear--;
|
||||
|
||||
return $"205.{termStartYear}{termStartYear + 1}/{currentSemester}";
|
||||
return $"205.{academicYearStartYear}{academicYearStartYear + 1}/{currentSemester}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
namespace BonchCalendar.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service that tracks results of the most recent requests.
|
||||
/// </summary>
|
||||
public class IssueTrackingService
|
||||
{
|
||||
private bool _isLastFacultyFetchSuccessful = true;
|
||||
|
||||
private readonly List<string> _unsuccessfulGroupFetches = [];
|
||||
|
||||
private readonly List<string> _unsuccessfulTimetableFetches = [];
|
||||
|
||||
/// <summary>
|
||||
/// Record whether the last attempt to retrieve faculty list was successful.
|
||||
/// </summary>
|
||||
/// <param name="isSuccessful">Set <c>true</c> if the attempt was successful. Otherwise, set <c>false</c></param>
|
||||
public void TrackFacultyFetch(bool isSuccessful) =>
|
||||
_isLastFacultyFetchSuccessful = isSuccessful;
|
||||
|
||||
/// <summary>
|
||||
/// Record whether the last attempt to retrieve groups for provided faculty and term year was successful.
|
||||
/// </summary>
|
||||
/// <param name="facultyId">ID of a faculty which was used to retrieve the group list.</param>
|
||||
/// <param name="termYear">Term year which was used to retrieve the group list.</param>
|
||||
/// <param name="isSuccessful">Set <c>true</c> if the attempt was successful. Otherwise, set <c>false</c></param>
|
||||
public void TrackGroupFetch(int facultyId, int termYear, bool isSuccessful)
|
||||
{
|
||||
string key = $"{facultyId}/{termYear}";
|
||||
|
||||
if (!isSuccessful)
|
||||
{
|
||||
if (!_unsuccessfulGroupFetches.Contains(key))
|
||||
_unsuccessfulGroupFetches.Add(key);
|
||||
}
|
||||
else
|
||||
_unsuccessfulGroupFetches.Remove(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record whether the last attempt to retrieve timetable for provided group was successful.
|
||||
/// </summary>
|
||||
/// <param name="facultyId">ID of a faculty the group belongs to.</param>
|
||||
/// <param name="groupId">ID of a group the timetable was retrieved for.</param>
|
||||
/// <param name="isSuccessful">Set <c>true</c> if the attempt was successful. Otherwise, set <c>false</c></param>
|
||||
public void TrackTimetableFetch(int facultyId, int groupId, bool isSuccessful)
|
||||
{
|
||||
string key = $"{facultyId}/{groupId}";
|
||||
|
||||
if (!isSuccessful)
|
||||
{
|
||||
if (!_unsuccessfulTimetableFetches.Contains(key))
|
||||
_unsuccessfulTimetableFetches.Add(key);
|
||||
}
|
||||
else
|
||||
_unsuccessfulTimetableFetches.Remove(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get report on the success of latest retrieval attempts.
|
||||
/// </summary>
|
||||
/// <returns>A dictionary for each of the tracked groups.</returns>
|
||||
/// <remarks>
|
||||
/// If the dictionary is empty, that means that there're no known issues.
|
||||
/// </remarks>
|
||||
public Dictionary<string, object> GetReport()
|
||||
{
|
||||
Dictionary<string, object> report = [];
|
||||
|
||||
if (!_isLastFacultyFetchSuccessful)
|
||||
report.Add("/faculties", false);
|
||||
|
||||
if (_unsuccessfulGroupFetches.Count > 0)
|
||||
report.Add("/groups", _unsuccessfulGroupFetches.ToArray());
|
||||
|
||||
if (_unsuccessfulTimetableFetches.Count > 0)
|
||||
report.Add("/timetable", _unsuccessfulTimetableFetches.ToArray());
|
||||
|
||||
// No issues example:
|
||||
/*
|
||||
* { }
|
||||
*/
|
||||
|
||||
// Report example with issues:
|
||||
/*
|
||||
* {
|
||||
* "/faculties": false,
|
||||
* "/groups": [
|
||||
* "123/1",
|
||||
* "321/3"
|
||||
* ],
|
||||
* "/timetable": [
|
||||
* "123/321",
|
||||
* "456/654"
|
||||
* ],
|
||||
* }
|
||||
*/
|
||||
|
||||
return report;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,13 @@ namespace BonchCalendar.Services;
|
||||
|
||||
public partial class ParsingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Parse general timetable document.
|
||||
/// </summary>
|
||||
/// <param name="rawHtml">HTML document retrieved from the API.</param>
|
||||
/// <param name="semesterStartDate"><see cref="DateTime"/> that represents the first day of current semester.</param>
|
||||
/// <param name="groupName">Name of a group this timetable is for.</param>
|
||||
/// <returns>An array of <see cref="CalendarEvent"/>s</returns>
|
||||
public CalendarEvent[] ParseGeneralTimetable(string rawHtml, DateTime semesterStartDate, string groupName)
|
||||
{
|
||||
using IHtmlDocument doc = new HtmlParser().ParseDocument(rawHtml);
|
||||
@@ -27,7 +34,7 @@ public partial class ParsingService
|
||||
Match timeMatch = ParserUtils.TimeLabelRegex().Match(timeLabelText);
|
||||
string number = timeMatch.Success ? timeMatch.Groups["number"].Value : timeLabelText;
|
||||
(TimeSpan startTime, TimeSpan endTime) = !timeMatch.Success ?
|
||||
ParserUtils.GetTimesFromLabel(timeLabelText) :
|
||||
ParserUtils.GetTimesFromLabel(timeLabelText) : // If the label for some reason doesn't contain start and end time, we can infer it from class' number
|
||||
(
|
||||
TimeSpan.Parse(timeMatch.Groups["start"].Value),
|
||||
TimeSpan.Parse(timeMatch.Groups["end"].Value)
|
||||
@@ -44,16 +51,26 @@ public partial class ParsingService
|
||||
.AddDays((week - 1) * 7) // Move to the correct week
|
||||
.AddDays(weekday - 1); // Move to the correct weekday
|
||||
|
||||
classes.Add(GetEvent(
|
||||
$"{number}. {className} ({classType})", auditorium,
|
||||
GetDescription(groupName, professors, auditorium, weeks),
|
||||
classDate, startTime, endTime));
|
||||
classes.Add(CreateEvent(
|
||||
title: $"{number}. {className} ({classType})",
|
||||
location: auditorium,
|
||||
description: CreateDescription(groupName, professors, auditorium, weeks),
|
||||
date: classDate,
|
||||
startTime,
|
||||
endTime
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return [.. classes];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse exam timetable document.
|
||||
/// </summary>
|
||||
/// <param name="rawHtml">HTML document, retrieved from the API.</param>
|
||||
/// <param name="groupName">Name of a group this timetable is for.</param>
|
||||
/// <returns>An array of <see cref="CalendarEvent"/>s</returns>
|
||||
public CalendarEvent[] ParseExamTimetable(string rawHtml, string groupName)
|
||||
{
|
||||
using IHtmlDocument doc = new HtmlParser().ParseDocument(rawHtml);
|
||||
@@ -77,26 +94,32 @@ public partial class ParsingService
|
||||
TimeSpan startTime = TimeSpan.Parse(timeMatch.Groups["start"].Value.Replace('.', ':'));
|
||||
TimeSpan endTime = TimeSpan.Parse(timeMatch.Groups["end"].Value.Replace('.', ':'));
|
||||
|
||||
classes.Add(GetEvent(
|
||||
$"{number}{className} ({classType})", auditorium,
|
||||
GetDescription(groupName, professors, auditorium),
|
||||
classDate, startTime, endTime));
|
||||
classes.Add(CreateEvent(
|
||||
title: $"{number}{className} ({classType})",
|
||||
location: auditorium,
|
||||
description: CreateDescription(groupName, professors, auditorium),
|
||||
date: classDate,
|
||||
startTime,
|
||||
endTime
|
||||
));
|
||||
}
|
||||
|
||||
return [.. classes];
|
||||
}
|
||||
|
||||
private static CalendarEvent GetEvent(string title, string auditorium, string description, DateTime date, TimeSpan startTime, TimeSpan endTime) =>
|
||||
// Create a calendar event
|
||||
private static CalendarEvent CreateEvent(string title, string location, string description, DateTime date, TimeSpan startTime, TimeSpan endTime) =>
|
||||
new()
|
||||
{
|
||||
Summary = title,
|
||||
Description = description,
|
||||
Start = new CalDateTime(date.Add(startTime - TimeSpan.FromHours(3)).ToUniversalTime()),
|
||||
End = new CalDateTime(date.Add(endTime - TimeSpan.FromHours(3)).ToUniversalTime()),
|
||||
Location = auditorium
|
||||
Location = location
|
||||
};
|
||||
|
||||
private static string GetDescription(string groupName, string[] professors, string auditorium, int[]? weeks = null)
|
||||
// Create event description
|
||||
private static string CreateDescription(string groupName, string[] professors, string auditorium, int[]? weeks = null)
|
||||
{
|
||||
string str = $"""
|
||||
Группа: {groupName}
|
||||
@@ -107,31 +130,37 @@ public partial class ParsingService
|
||||
if (weeks is not null && weeks.Length > 0)
|
||||
str += $"\nНедели: {string.Join(", ", weeks)}";
|
||||
|
||||
// Attempt to recognize wing and room number
|
||||
Match auditoriumMatch = ParserUtils.AuditoriumRegex().Match(auditorium);
|
||||
|
||||
if (!auditoriumMatch.Success)
|
||||
auditoriumMatch = ParserUtils.AuditoriumAltRegex().Match(auditorium);
|
||||
|
||||
// If successful, we can add a nav.sut.ru map link
|
||||
if (auditoriumMatch.Success)
|
||||
str += "\n\n" + $"""
|
||||
ГУТ.Навигатор:
|
||||
https://nav.sut.ru/?cab=k{auditoriumMatch.Groups["wing"].Value}-{auditoriumMatch.Groups["room"].Value}
|
||||
""";
|
||||
|
||||
// Some shameless self-promotion
|
||||
str += "\n\n" + "Создано при помощи сервиса Бонч.Календарь: https://bonch.xfox111.net";
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
// Parse basic info for a class
|
||||
private static (string className, string classType, string[] professors, string auditorium) ParseBaseInfo(IElement classElement)
|
||||
{
|
||||
string className = classElement.QuerySelector(".subect")!.TextContent;
|
||||
string classType = classElement.QuerySelector(".type")!.TextContent
|
||||
.Replace("(", string.Empty).Replace(")", string.Empty).Trim();
|
||||
string className = classElement.QuerySelector(".subect")?.TextContent ?? string.Empty;
|
||||
string classType = classElement.QuerySelector(".type")?.TextContent
|
||||
.Replace("(", string.Empty).Replace(")", string.Empty).Trim() ?? string.Empty;
|
||||
|
||||
string[] professors = classElement.QuerySelector(".teacher[title]")!.GetAttribute("title")
|
||||
!.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
string[] professors = classElement.QuerySelector(".teacher[title]")?.GetAttribute("title")
|
||||
?.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [];
|
||||
|
||||
string auditorium = classElement.QuerySelector(".aud")!.TextContent
|
||||
.Replace("ауд.:", string.Empty).Replace(';', ',').Trim();
|
||||
string auditorium = classElement.QuerySelector(".aud")?.TextContent
|
||||
.Replace("ауд.:", string.Empty).Replace(';', ',').Trim() ?? string.Empty;
|
||||
|
||||
return (className, classType, professors, auditorium);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using Ical.Net;
|
||||
using Ical.Net.CalendarComponents;
|
||||
using Ical.Net.Serialization;
|
||||
|
||||
namespace BonchCalendar.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for retrieving timetable.
|
||||
/// </summary>
|
||||
public class TimetableService(
|
||||
ApiService apiService,
|
||||
ParsingService parsingService,
|
||||
ILogger<TimetableService> logger,
|
||||
IHostEnvironment environment
|
||||
)
|
||||
{
|
||||
/// <summary>
|
||||
/// Try to retrieve timetable from application's cache.
|
||||
/// </summary>
|
||||
/// <param name="groupId">ID of a group to retrieve timetable for.</param>
|
||||
/// <returns><c>null</c> if cache for this timetable is not present, or is older than 6 hours. Otherwise, timetable content in iCal format.</returns>
|
||||
public async Task<string?> TryGetTimetableFromCacheAsync(int groupId)
|
||||
{
|
||||
string cacheFile = GetCachePath(groupId);
|
||||
|
||||
if (!File.Exists(cacheFile) || (DateTime.UtcNow - File.GetLastWriteTimeUtc(cacheFile)).TotalHours >= 6)
|
||||
return null;
|
||||
|
||||
if (environment.IsDevelopment())
|
||||
{
|
||||
logger.LogWarning("Caching is disabled for development environment.");
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.LogInformation("Calendar for group {GroupId} is present in cache ({CacheFile}).", groupId, cacheFile);
|
||||
|
||||
return await File.ReadAllTextAsync(cacheFile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve timetable for specified group from sut.ru API.
|
||||
/// </summary>
|
||||
/// <param name="saveToCache">If set to <c>true</c>, result timetable will be wirtten to cache for that group.</param>
|
||||
/// <param name="transform">Action delegate that can be used to manipulate the result <see cref="Calendar"/> object, before converting it to iCal.</param>
|
||||
/// <returns>A string that contains timetable in iCal format.</returns>
|
||||
public async Task<string> GetTimetableAsync(int facultyId, int groupId, bool saveToCache = true, Action<Calendar>? transform = null)
|
||||
{
|
||||
// We need semester start date, since the regular timetable is represented on sut.ru semester week numbers.
|
||||
DateTime semesterStartDate = await apiService.GetSemesterStartDateAsync(groupId);
|
||||
string groupName = (await apiService.GetGroupsListAsync(facultyId, 0))[groupId];
|
||||
|
||||
// Retrieve and parse regular timetable first, since it's the only timetable that has different structure.
|
||||
string classesRaw = await apiService.GetScheduleDocumentAsync(groupId, TimetableType.Classes);
|
||||
List<CalendarEvent> timetable = [.. parsingService.ParseGeneralTimetable(classesRaw, semesterStartDate, groupName)];
|
||||
|
||||
// Retrieve and parse other timetables using a loop, since they all can be parsed using the same parser.
|
||||
TimetableType[] types = [TimetableType.Attestations, TimetableType.Exams, TimetableType.ExamsForExtramural];
|
||||
foreach (TimetableType type in types)
|
||||
{
|
||||
classesRaw = await apiService.GetScheduleDocumentAsync(groupId, type);
|
||||
timetable.AddRange(parsingService.ParseExamTimetable(classesRaw, groupName));
|
||||
}
|
||||
|
||||
// Create and configure the calendar.
|
||||
Calendar calendar = new();
|
||||
calendar.AddTimeZone(new VTimeZone("Europe/Moscow"));
|
||||
calendar.Properties.Add(new CalendarProperty("X-WR-CALNAME", groupName));
|
||||
calendar.Properties.Add(new CalendarProperty("X-WR-TIMEZONE", "Europe/Moscow"));
|
||||
calendar.Properties.Add(new CalendarProperty("REFRESH-INTERVAL;VALUE=DURATION", "PT6H")); // Specifies how often calendar client should poll for new timetable.
|
||||
calendar.Events.AddRange(timetable);
|
||||
|
||||
transform?.Invoke(calendar); // If transform delegate is not null, invoke it.
|
||||
|
||||
// Serialize calendar to iCal format.
|
||||
string content = new CalendarSerializer().SerializeToString(calendar)!;
|
||||
|
||||
if (saveToCache)
|
||||
{
|
||||
string cacheFile = GetCachePath(groupId);
|
||||
await File.WriteAllTextAsync(cacheFile, content);
|
||||
logger.LogInformation("Cache updated: {CacheFile}", cacheFile);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private static string GetCachePath(int groupId) =>
|
||||
Path.Combine(Path.GetTempPath(), $"bonch_cal_{groupId}.ics");
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace BonchCalendar;
|
||||
|
||||
/// <summary>
|
||||
/// Response body object for /stats endpoint.
|
||||
/// </summary>
|
||||
/// <param name="ActiveUsers">Number of active users.</param>
|
||||
public record StatsResponse(int ActiveUsers);
|
||||
@@ -1,9 +1,27 @@
|
||||
namespace BonchCalendar;
|
||||
|
||||
/// <summary>
|
||||
/// Types of timetable documents retrieved from sut.ru API.
|
||||
/// </summary>
|
||||
public enum TimetableType
|
||||
{
|
||||
/// <summary>
|
||||
/// Regular timetable document (Занятия).
|
||||
/// </summary>
|
||||
Classes = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Exams timetable document (Экзаменационная сессия).
|
||||
/// </summary>
|
||||
Exams = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Exams timetable for extramural students document (Сессия для заочников).
|
||||
/// </summary>
|
||||
ExamsForExtramural = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Attestations timetable document (Зачеты).
|
||||
/// </summary>
|
||||
Attestations = 14
|
||||
}
|
||||
|
||||
@@ -2,8 +2,20 @@ using System.Text.RegularExpressions;
|
||||
|
||||
namespace BonchCalendar.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Utility methods for timetable parser.
|
||||
/// </summary>
|
||||
public static partial class ParserUtils
|
||||
{
|
||||
/// <summary>
|
||||
/// Get class start and end times from class' number label.
|
||||
/// </summary>
|
||||
/// <param name="label">Class' number label.</param>
|
||||
/// <returns>A tuple value of start and end times.</returns>
|
||||
/// <remarks>
|
||||
/// This method is only supposed to be used as a fallback method of determening class' time
|
||||
/// </remarks>
|
||||
/// <exception cref="NotImplementedException">Unknown label encountered.</exception>
|
||||
public static (TimeSpan startTime, TimeSpan endTime) GetTimesFromLabel(string label)
|
||||
{
|
||||
(string startTime, string endTime) = label switch
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
### This file contains exampels of HTTP requests to sut.ru API.
|
||||
# You can use them for reference and better understanding of what I have to deal with.
|
||||
# You can use a vscode extension (like REST Client) to make this file interactive.
|
||||
|
||||
# Current semester "ID" (must be updated before sending requests)
|
||||
@schet=205.2526/2
|
||||
|
||||
# Breakdown:
|
||||
# "205." part is static
|
||||
# "2526" represents current academic year (2025-2026 in this case)
|
||||
# "/2" represents current semester. Can be either "/1" (first semester) or "/2" (second semester)
|
||||
# When making requests this ID must always point to current semester, otherwise you may get a broken response.
|
||||
|
||||
# Tip:
|
||||
# From August through January is considered to be the first semester
|
||||
# Other months (February-July) are considered to be the second semester
|
||||
|
||||
###
|
||||
|
||||
# Get list of faculties
|
||||
POST https://cabinet.sut.ru/raspisanie_all_new.php
|
||||
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
|
||||
|
||||
choice=1&schet={{schet}}
|
||||
|
||||
###
|
||||
|
||||
# Get list of groups for faculty
|
||||
|
||||
# Year filters out groups by the term year they are at.
|
||||
# Year should be an integer from 0 to 5, inclusive.
|
||||
# If year is set to 0, all groups for the chosen faculty will be received instead.
|
||||
|
||||
@facultyId=50029
|
||||
@year=0
|
||||
POST https://cabinet.sut.ru/raspisanie_all_new.php
|
||||
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
|
||||
|
||||
choice=1&schet={{schet}}&faculty={{facultyId}}&kurs={{year}}
|
||||
|
||||
###
|
||||
|
||||
# Get timetable for selected group
|
||||
|
||||
# Type here can be on of the following:
|
||||
# 1 - for regular timetable (Занятия)
|
||||
# 2 - for exams timetable (Экзаменационная сессия)
|
||||
# 4 - for exams timetable for extramural students (Сессия для заочников)
|
||||
# 14 - for attestations timetable (Зачеты)
|
||||
|
||||
@type=1
|
||||
@groupId=55512
|
||||
POST https://cabinet.sut.ru/raspisanie_all_new.php
|
||||
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
|
||||
|
||||
schet={{schet}}&type_z={{type}}&group={{groupId}}
|
||||
|
||||
###
|
||||
|
||||
# Get page that contains current week number
|
||||
|
||||
# We use this page because it's the only known page that contains publicly accessible semester week number.
|
||||
# Since regular timetable doesn't show normal dates for classes, and instead uses week numbers,
|
||||
# we need to know a date of the first day of current semester to calculate dates for them
|
||||
# (e.g. 3 week tuesday is = first day + 3 * 7 + 1)
|
||||
|
||||
# Since we know current date and weekday, by knowing week number we can calculate date for the first day.
|
||||
|
||||
GET https://www.sut.ru/studentu/raspisanie/raspisanie-zanyatiy-studentov-ochnoy-i-vecherney-form-obucheniya?group={{groupId}}
|
||||
@@ -1 +1,2 @@
|
||||
VITE_BACKEND_HOST=https://api.bonch.xfox111.net
|
||||
VITE_BACKEND_HOST=http://localhost:8080
|
||||
|
||||
Generated
+1480
-2264
File diff suppressed because it is too large
Load Diff
+18
-17
@@ -10,25 +10,26 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluentui/react-components": "^9.72.7",
|
||||
"@fluentui/react-icons": "^2.0.315",
|
||||
"@fluentui/react-motion-components-preview": "^0.14.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-localization": "^2.0.6"
|
||||
"@fluentui/react-components": "^9.73.8",
|
||||
"@fluentui/react-icons": "^2.0.326",
|
||||
"@fluentui/react-motion-components-preview": "^0.15.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-localization": "^2.0.6",
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.6",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.47.0",
|
||||
"vite": "^7.2.2"
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"vite": "^8.0.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import MainView from "./views/MainView";
|
||||
import FaqView from "./views/FaqView";
|
||||
import DedicatedView from "./views/DedicatedView";
|
||||
import FooterView from "./views/FooterView";
|
||||
import StatsView from "./views/StatsView";
|
||||
|
||||
export default function App(): ReactElement
|
||||
{
|
||||
@@ -15,6 +16,7 @@ export default function App(): ReactElement
|
||||
<FluentProvider theme={ theme }>
|
||||
<main className={ cls.root }>
|
||||
<MainView />
|
||||
<StatsView />
|
||||
<FaqView />
|
||||
<DedicatedView />
|
||||
<FooterView />
|
||||
|
||||
@@ -16,6 +16,7 @@ const baseTheme: Partial<Theme> =
|
||||
colorBrandBackground: "#f68b1f",
|
||||
colorBrandBackgroundHover: "#c36e18",
|
||||
colorNeutralForeground2BrandHover: "#c36e18",
|
||||
colorNeutralForeground2BrandPressed: "#a95f15",
|
||||
colorBrandBackgroundPressed: "#a95f15",
|
||||
colorCompoundBrandStroke: "#f68b1f",
|
||||
colorCompoundBrandStrokePressed: "#a95f15"
|
||||
|
||||
+61
-9
@@ -1,11 +1,63 @@
|
||||
export const fetchFaculties = async (): Promise<[string, string][]> =>
|
||||
{
|
||||
const res = await fetch(import.meta.env.VITE_BACKEND_HOST + "/faculties");
|
||||
return Object.entries(await res.json());
|
||||
};
|
||||
const timeout: number = 5000;
|
||||
|
||||
export const fetchGroups = async (facultyId: string, course: number): Promise<[string, string][]> =>
|
||||
export const fetchFaculties = (): Promise<Record<string, string>> =>
|
||||
fetchApi("/faculties", {});
|
||||
|
||||
export const fetchGroups = (facultyId: string, year: number): Promise<Record<string, string>> =>
|
||||
fetchApi(`/groups?facultyId=${facultyId}&year=${year}`, {});
|
||||
|
||||
export const fetchStats = async (): Promise<StatsResponse> =>
|
||||
fetchApi("/stats", {
|
||||
activeUsers: 0
|
||||
});
|
||||
|
||||
export const fetchHealth = async (): Promise<HealthResponse> =>
|
||||
fetchApi("/health", {} as HealthResponse, true);
|
||||
|
||||
async function fetchApi<T>(path: string, defaultValue: T, alwaysReturnResponse: boolean = false): Promise<T>
|
||||
{
|
||||
const res = await fetch(`${import.meta.env.VITE_BACKEND_HOST}/groups?facultyId=${facultyId}&course=${course}`);
|
||||
return Object.entries(await res.json());
|
||||
};
|
||||
try
|
||||
{
|
||||
const res = await fetch(new URL(path, import.meta.env.VITE_BACKEND_HOST), {
|
||||
signal: AbortSignal.timeout(timeout)
|
||||
});
|
||||
|
||||
if (!res.ok && !alwaysReturnResponse)
|
||||
return defaultValue;
|
||||
|
||||
return await res.json();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
export type StatsResponse =
|
||||
{
|
||||
activeUsers: number;
|
||||
};
|
||||
|
||||
export type HealthResponse =
|
||||
{
|
||||
status: HealthStatus;
|
||||
totalDuration: string;
|
||||
entries: {
|
||||
["timetable_website"]: TimetableHealthResponseEntry;
|
||||
};
|
||||
};
|
||||
|
||||
export type HealthStatus = "healthy" | "unhealthy" | "degraded";
|
||||
|
||||
export type TimetableHealthResponseEntry =
|
||||
{
|
||||
status: HealthStatus;
|
||||
description?: string;
|
||||
duration: string;
|
||||
data:
|
||||
{
|
||||
"/faculties"?: false,
|
||||
"/groups"?: string[],
|
||||
"/timetable"?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,15 +9,33 @@ const strings = new LocalizedStrings({
|
||||
subtitle_p1: "Check your SPbSUT classes in {0} calendar",
|
||||
subtitle_p2: "your",
|
||||
pickFaculty: "1. Pick your faculty",
|
||||
pickCourse: "2. Pick your course",
|
||||
pickCourse: "2. Pick your year",
|
||||
pickGroup: "3. Pick your group",
|
||||
pickGroup_empty: "No groups are available for the selected course",
|
||||
pickGroup_empty: "No groups are available for the selected year",
|
||||
subscribe: "4. Subscribe to the calendar",
|
||||
copy: "Copy link",
|
||||
or: "or",
|
||||
download: "Download .ics file",
|
||||
cta: "Like the service? Tell your classmates!",
|
||||
|
||||
// StatsView.tsx
|
||||
users: "Active users: {0}",
|
||||
status_ok: "Status: Operational",
|
||||
status_unhealthy: "Status: Degraded",
|
||||
report_title: "Service status report",
|
||||
report_close: "Close",
|
||||
report_subtitle_ok: "Service operates normally",
|
||||
report_subtitle_unhealthy: "Active issues: {0}",
|
||||
report_issue_backend: "Unable to connect to service's backend application.",
|
||||
report_issue_faculties: "Last attempt to fetch faculties list resulted in an error.",
|
||||
report_issue_groups: "Last attempt to fetch groups for following faculties resulted in an error:",
|
||||
report_issue_groups_item: "{0} ({1}), {2} year",
|
||||
report_issue_groups_item_alt: "Faculty ID: {0}, {1} year",
|
||||
report_issue_timetable: "Last attempt to fetch timetable for following groups resulted in an error:",
|
||||
report_issue_timetable_item_alt1: "Group ID: {0}, {1} ({2})",
|
||||
report_issue_timetable_item_alt2: "{0} ({1}), Faculty ID: {2}",
|
||||
report_issue_timetable_item_alt3: "Group ID: {0}, Faculty ID: {1}",
|
||||
|
||||
// FaqView.tsx
|
||||
faq_h2: "Frequently asked questions",
|
||||
question1_h3: "How do I save timetable to my Outlook/Google calendar?",
|
||||
@@ -77,6 +95,24 @@ const strings = new LocalizedStrings({
|
||||
download: "Скачай .ics файл",
|
||||
cta: "Понравился сервис? Расскажи одногруппникам!",
|
||||
|
||||
// StatsView.tsx
|
||||
users: "Пользователей: {0}",
|
||||
status_ok: "Статус сервиса",
|
||||
status_unhealthy: "Статус сервиса",
|
||||
report_title: "Состояние сервиса",
|
||||
report_close: "Закрыть",
|
||||
report_subtitle_ok: "Сервис работает в нормальном режиме",
|
||||
report_subtitle_unhealthy: "Известных проблем: {0}",
|
||||
report_issue_backend: "Ошибка при подключении к серверу приложения.",
|
||||
report_issue_faculties: "Ошибка при попытке получить список факультетов.",
|
||||
report_issue_groups: "Ошибка при попытке получить список групп для следующих факультетов:",
|
||||
report_issue_groups_item: "{0} ({1}), {2} курс",
|
||||
report_issue_groups_item_alt: "ID факультета: {0}, {1} курс",
|
||||
report_issue_timetable: "Ошибка при попытке получить расписание для следующих групп:",
|
||||
report_issue_timetable_item_alt1: "ID группы: {0}, {1} ({2})",
|
||||
report_issue_timetable_item_alt2: "{0} ({1}), ID факультета: {2}",
|
||||
report_issue_timetable_item_alt3: "ID группы: {0}, ID факультета: {1}",
|
||||
|
||||
// FaqView.tsx
|
||||
faq_h2: "Часто задаваемые вопросы",
|
||||
question1_h3: "Как сохранить расписание в Outlook/Google календарь?",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { type TimetableHealthResponseEntry, fetchFaculties, fetchGroups } from "./api";
|
||||
import strings from "./strings";
|
||||
|
||||
export async function tryFormatNamesForReport(report?: TimetableHealthResponseEntry): Promise<TimetableHealthResponseEntry | undefined>
|
||||
{
|
||||
if (report === undefined)
|
||||
return report;
|
||||
|
||||
if (report.status === "healthy")
|
||||
return report;
|
||||
|
||||
const isGroupsDown: boolean = report.data["/groups"] !== undefined;
|
||||
const isTimetableDown: boolean = report.data["/timetable"] !== undefined;
|
||||
|
||||
if (!isGroupsDown && !isTimetableDown)
|
||||
return report;
|
||||
|
||||
let faculties: Record<string, string> | undefined = undefined;
|
||||
|
||||
try { faculties = await fetchFaculties(); }
|
||||
catch { /* empty */ }
|
||||
|
||||
const facultiesFormatted: string[] = [];
|
||||
|
||||
if (report.data["/groups"] !== undefined)
|
||||
for (const faculty of report.data["/groups"])
|
||||
{
|
||||
const [facultyId, course] = faculty.split("/");
|
||||
|
||||
if (faculties?.[facultyId] === undefined)
|
||||
facultiesFormatted.push(strings.formatString(strings.report_issue_groups_item_alt, facultyId, course) as string);
|
||||
|
||||
else
|
||||
facultiesFormatted.push(strings.formatString(strings.report_issue_groups_item, faculties[facultyId], facultyId, course) as string);
|
||||
}
|
||||
|
||||
const groups: Record<string, Record<string, string>> = {};
|
||||
const groupsFormatted: string[] = [];
|
||||
|
||||
if (report.data["/timetable"] !== undefined)
|
||||
for (const group of report.data["/timetable"])
|
||||
{
|
||||
const [facultyId, groupId] = group.split("/");
|
||||
|
||||
if (groups[facultyId] === undefined)
|
||||
try { groups[facultyId] = await fetchGroups(facultyId, 0); }
|
||||
catch { /* empty */ }
|
||||
|
||||
if (groups[facultyId]?.[groupId] !== undefined && faculties?.[facultyId] !== undefined)
|
||||
groupsFormatted.push(`${groups[facultyId][groupId]} (${groupId}), ${faculties[facultyId]} (${facultyId})`);
|
||||
else if (faculties?.[facultyId] !== undefined)
|
||||
groupsFormatted.push(strings.formatString(strings.report_issue_timetable_item_alt1, groupId, faculties[facultyId], facultyId) as string)
|
||||
else if (groups[facultyId]?.[groupId] !== undefined)
|
||||
groupsFormatted.push(strings.formatString(strings.report_issue_timetable_item_alt2, groups[facultyId][groupId], groupId, facultyId) as string)
|
||||
else
|
||||
groupsFormatted.push(strings.formatString(strings.report_issue_timetable_item_alt3, groupId, facultyId) as string)
|
||||
}
|
||||
|
||||
return {
|
||||
...report,
|
||||
data: {
|
||||
...report.data,
|
||||
["/groups"]: facultiesFormatted.length > 0 ? facultiesFormatted : undefined,
|
||||
["/timetable"]: groupsFormatted.length > 0 ? groupsFormatted : undefined
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@ const useStyles_MainView = makeStyles({
|
||||
flexFlow: "column",
|
||||
gap: tokens.spacingVerticalXXXL,
|
||||
justifyContent: "center",
|
||||
minHeight: "90vh",
|
||||
minHeight: "85vh",
|
||||
alignItems: "center",
|
||||
padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalL}`,
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@ import useTimeout from "../hooks/useTimeout";
|
||||
import useStyles_MainView from "./MainView.styles";
|
||||
import { fetchFaculties, fetchGroups } from "../utils/api";
|
||||
import strings from "../utils/strings";
|
||||
import { v7 as uuid7 } from "uuid";
|
||||
|
||||
const facultiesPromise = fetchFaculties();
|
||||
const facultiesPromise = fetchFaculties().then(Object.entries);
|
||||
|
||||
const getEntryOrEmpty = (entries: [string, string][], key: string): string =>
|
||||
entries.find(i => i[0] === key)?.[1] ?? "";
|
||||
@@ -25,6 +26,7 @@ export default function MainView(): ReactElement
|
||||
const [groups, setGroups] = useState<[string, string][] | null>(null);
|
||||
const [groupId, setGroupId] = useState<string>("");
|
||||
|
||||
const id = uuid7();
|
||||
const icalUrl = useMemo(() => `${import.meta.env.VITE_BACKEND_HOST}/timetable/${facultyId}/${groupId}`, [groupId, facultyId]);
|
||||
|
||||
const [showCta, setShowCta] = useState<boolean>(false);
|
||||
@@ -35,10 +37,10 @@ export default function MainView(): ReactElement
|
||||
|
||||
const copyLink = useCallback((): void =>
|
||||
{
|
||||
navigator.clipboard.writeText(icalUrl);
|
||||
navigator.clipboard.writeText(icalUrl + "?id=" + id);
|
||||
triggerCopy();
|
||||
setShowCta(true);
|
||||
}, [icalUrl, triggerCopy]);
|
||||
}, [icalUrl, triggerCopy, id]);
|
||||
|
||||
const onFacultySelect = useCallback((_: SelectionEvents, data: OptionOnSelectData): void =>
|
||||
{
|
||||
@@ -59,7 +61,7 @@ export default function MainView(): ReactElement
|
||||
setCourse(courseNumber);
|
||||
setGroupId("");
|
||||
setGroups(null);
|
||||
fetchGroups(facultyId, courseNumber).then(setGroups);
|
||||
fetchGroups(facultyId, courseNumber).then(Object.entries).then(setGroups);
|
||||
}, [course, facultyId]);
|
||||
|
||||
return (
|
||||
@@ -99,6 +101,7 @@ export default function MainView(): ReactElement
|
||||
<Button key={ i }
|
||||
className={ cls.courseButton }
|
||||
appearance={ course === i ? "primary" : "secondary" }
|
||||
disabled={ facultyId === "" }
|
||||
onClick={ () => onCourseSelect(i) }>
|
||||
|
||||
{ i }
|
||||
@@ -114,6 +117,7 @@ export default function MainView(): ReactElement
|
||||
className={ cls.field }
|
||||
positioning={ { pinned: true, position: "below" } }
|
||||
value={ getEntryOrEmpty(groups ?? [], groupId) }
|
||||
disabled={ course === 0 || groups === null }
|
||||
onOptionSelect={ (_, e) => setGroupId(e.optionValue!) }>
|
||||
|
||||
{ groups?.map(([id, name]) =>
|
||||
@@ -136,12 +140,13 @@ export default function MainView(): ReactElement
|
||||
className={ mergeClasses(cls.field, copyActive && cls.copiedStyle) }
|
||||
iconPosition="after"
|
||||
title={ strings.copy }
|
||||
disabled={ groupId === "" }
|
||||
icon={ copyActive
|
||||
? <Checkmark24Regular className={ cls.copyIcon } />
|
||||
: <Copy24Regular className={ cls.copyIcon } />
|
||||
}>
|
||||
|
||||
<span className={ cls.truncatedText }>{ icalUrl }</span>
|
||||
<span className={ cls.truncatedText }>{ icalUrl + "?id=" + id }</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Slide>
|
||||
@@ -151,7 +156,8 @@ export default function MainView(): ReactElement
|
||||
<Button as="a"
|
||||
appearance="subtle" icon={ <ArrowDownload24Regular /> }
|
||||
onClick={ () => setShowCta(true) }
|
||||
href={ icalUrl }>
|
||||
disabled={ groupId === "" }
|
||||
href={ icalUrl + "?id=download" }>
|
||||
|
||||
{ strings.download }
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { makeStyles, tokens } from "@fluentui/react-components";
|
||||
|
||||
export const useStyles = makeStyles({
|
||||
root:
|
||||
{
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
marginBottom: "80px"
|
||||
},
|
||||
container:
|
||||
{
|
||||
display: "flex",
|
||||
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalMNudge}`,
|
||||
gap: tokens.spacingHorizontalMNudge,
|
||||
boxShadow: tokens.shadow4,
|
||||
borderRadius: tokens.borderRadiusMedium
|
||||
},
|
||||
statsButton:
|
||||
{
|
||||
pointerEvents: "none"
|
||||
},
|
||||
statsButtonIcon:
|
||||
{
|
||||
color: tokens.colorBrandForeground1
|
||||
},
|
||||
statusIconHealthy:
|
||||
{
|
||||
color: tokens.colorStatusSuccessBorderActive,
|
||||
},
|
||||
statusIconUnhealthy:
|
||||
{
|
||||
color: tokens.colorStatusDangerBorderActive,
|
||||
},
|
||||
reportSubtitle:
|
||||
{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalS
|
||||
},
|
||||
reportContent:
|
||||
{
|
||||
userSelect: "text"
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Button, Dialog, DialogBody, DialogContent, DialogSurface, DialogTitle, DialogTrigger, Divider, Subtitle2 } from "@fluentui/react-components";
|
||||
import { ArrowTrendingLinesFilled, CheckmarkCircleFilled, Dismiss24Regular, WarningFilled } from "@fluentui/react-icons";
|
||||
import { use, useMemo, type ReactElement } from "react";
|
||||
import { fetchHealth, fetchStats, type StatsResponse, type TimetableHealthResponseEntry } from "../utils/api";
|
||||
import strings from "../utils/strings";
|
||||
import { tryFormatNamesForReport } from "../utils/tryFormatNamesForReport";
|
||||
import { useStyles } from "./StatsView.styles";
|
||||
|
||||
const healthPromise = fetchHealth().then(i => i.entries?.["timetable_website"]).then(tryFormatNamesForReport);
|
||||
const statsPromise = fetchStats();
|
||||
|
||||
export default function StatsView(): ReactElement
|
||||
{
|
||||
const cls = useStyles();
|
||||
|
||||
const health: TimetableHealthResponseEntry | undefined = use(healthPromise);
|
||||
const stats: StatsResponse = use(statsPromise);
|
||||
|
||||
const issueCounter: number = useMemo(() =>
|
||||
{
|
||||
let counter: number = 0;
|
||||
|
||||
if (health === undefined)
|
||||
return 1;
|
||||
|
||||
if (health.data["/faculties"] !== undefined)
|
||||
counter++;
|
||||
|
||||
counter += health.data["/groups"]?.length ?? 0;
|
||||
counter += health.data["/timetable"]?.length ?? 0;
|
||||
|
||||
return counter;
|
||||
}, [health]);
|
||||
|
||||
return (
|
||||
<div className={ cls.root }>
|
||||
<div className={ cls.container }>
|
||||
{ stats.activeUsers > 3 &&
|
||||
<>
|
||||
<Button
|
||||
className={ cls.statsButton } tabIndex={ -1 }
|
||||
icon={ <ArrowTrendingLinesFilled className={ cls.statsButtonIcon } /> }
|
||||
appearance="subtle"
|
||||
>
|
||||
{ strings.formatString(strings.users, stats.activeUsers) }
|
||||
</Button>
|
||||
<Divider vertical />
|
||||
</>
|
||||
}
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
{ health?.status === "healthy" ?
|
||||
<Button icon={ <CheckmarkCircleFilled className={ cls.statusIconHealthy } /> } appearance="subtle">
|
||||
{ strings.status_ok }
|
||||
</Button>
|
||||
:
|
||||
<Button icon={ <WarningFilled className={ cls.statusIconUnhealthy } /> } appearance="subtle">
|
||||
{ strings.status_unhealthy }
|
||||
</Button>
|
||||
}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogSurface>
|
||||
<DialogBody>
|
||||
<DialogTitle
|
||||
action={
|
||||
<DialogTrigger action="close">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
aria-label={ strings.report_close }
|
||||
icon={ <Dismiss24Regular /> }
|
||||
/>
|
||||
</DialogTrigger>
|
||||
}
|
||||
>
|
||||
{ strings.report_title }
|
||||
</DialogTitle>
|
||||
<DialogContent className={ cls.reportContent }>
|
||||
{ health?.status === "healthy" ?
|
||||
<div className={ cls.reportSubtitle }>
|
||||
<CheckmarkCircleFilled className={ cls.statusIconHealthy } fontSize={ 24 } />
|
||||
<Subtitle2>{ strings.report_subtitle_ok }</Subtitle2>
|
||||
</div>
|
||||
:
|
||||
<div className={ cls.reportSubtitle }>
|
||||
<WarningFilled className={ cls.statusIconUnhealthy } fontSize={ 24 } />
|
||||
<Subtitle2>
|
||||
{ strings.formatString(strings.report_subtitle_unhealthy, issueCounter) }
|
||||
</Subtitle2>
|
||||
</div>
|
||||
}
|
||||
{ health?.status !== "healthy" &&
|
||||
<ul>
|
||||
{ health === undefined &&
|
||||
<li>{ strings.report_issue_backend }</li>
|
||||
}
|
||||
{ health?.data["/faculties"] !== undefined &&
|
||||
<li>{ strings.report_issue_faculties }</li>
|
||||
}
|
||||
{ health?.data["/groups"] !== undefined &&
|
||||
<li>
|
||||
{ strings.report_issue_groups }
|
||||
<ul>
|
||||
{ health.data["/groups"].map((i, index) =>
|
||||
<li key={ index }>{ i }</li>
|
||||
) }
|
||||
</ul>
|
||||
</li>
|
||||
}
|
||||
{ health?.data["/timetable"] !== undefined &&
|
||||
<li>
|
||||
{ strings.report_issue_timetable }
|
||||
<ul>
|
||||
{ health.data["/timetable"].map((i, index) =>
|
||||
<li key={ index }>{ i }</li>
|
||||
) }
|
||||
</ul>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</DialogContent>
|
||||
</DialogBody>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.2 MiB |
@@ -3,6 +3,8 @@ services:
|
||||
image: xfox111/bonch-calendar-api:latest
|
||||
build:
|
||||
context: ./api
|
||||
environment:
|
||||
- ORIGIN_DOMAIN=localhost:8000
|
||||
ports:
|
||||
- 8080:8080
|
||||
|
||||
|
||||
Reference in New Issue
Block a user