mirror of
https://github.com/XFox111/bonch-calendar.git
synced 2026-06-30 10:52:41 +03:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f91d47bfe | |||
| dc394c2ba9 | |||
| 1c2cbe3103 | |||
| c94a7db21a | |||
| c107847f05 | |||
| b597f8e5c0 | |||
| 1adeff73e4 | |||
| bb6f9d493d | |||
| d5f5f54eb7 | |||
| cebd38698f | |||
| a3458b825e | |||
| 7f88891429 | |||
| 6a2b6980f9 | |||
| 4d4c3adde6 | |||
| 734c43548a | |||
| b03a05b89f | |||
| d765ab0269 | |||
| 462dab9e3e | |||
| b03ff5c61c | |||
| e33acd4fc4 | |||
| 916c7bcb22 | |||
| 882e196ea8 | |||
| fa39e8d26c | |||
| f4d1d4e983 | |||
| c0e6ced376 | |||
| 452e6d51b2 | |||
| 9b74bb63c5 |
@@ -6,12 +6,12 @@
|
|||||||
"image": "mcr.microsoft.com/devcontainers/dotnet:1-10.0",
|
"image": "mcr.microsoft.com/devcontainers/dotnet:1-10.0",
|
||||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/node:1": {
|
"ghcr.io/devcontainers/features/node:2": {
|
||||||
"version": "lts",
|
"version": "lts",
|
||||||
"pnpmVersion": "none",
|
"pnpmVersion": "none",
|
||||||
"nvmVersion": "latest"
|
"nvmVersion": "latest"
|
||||||
},
|
},
|
||||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
"ghcr.io/devcontainers/features/docker-in-docker:3": {
|
||||||
"installDockerBuildx": true,
|
"installDockerBuildx": true,
|
||||||
"version": "latest",
|
"version": "latest",
|
||||||
"dockerDashComposeVersion": "v2"
|
"dockerDashComposeVersion": "v2"
|
||||||
|
|||||||
@@ -33,14 +33,14 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
- uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: ./api
|
context: ./api
|
||||||
tags: ${{ github.repository }}-api:ci
|
tags: ${{ github.repository }}-api:ci
|
||||||
|
|
||||||
- run: docker save ${{ github.repository }}:ci | gzip > api_image.tar.gz
|
- run: docker save ${{ github.repository }}:ci | gzip > api_image.tar.gz
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v5
|
- uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: api-image
|
name: api-image
|
||||||
path: api_image.tar.gz
|
path: api_image.tar.gz
|
||||||
@@ -51,14 +51,14 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
- uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: ./app
|
context: ./app
|
||||||
tags: ${{ github.repository }}-app:ci
|
tags: ${{ github.repository }}-app:ci
|
||||||
|
|
||||||
- run: docker save ${{ github.repository }}:ci | gzip > app_image.tar.gz
|
- run: docker save ${{ github.repository }}:ci | gzip > app_image.tar.gz
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v5
|
- uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: app-image
|
name: app-image
|
||||||
path: app_image.tar.gz
|
path: app_image.tar.gz
|
||||||
@@ -82,7 +82,7 @@ jobs:
|
|||||||
- run: npm audit --audit-level=moderate --json > audit_report.json
|
- run: npm audit --audit-level=moderate --json > audit_report.json
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v5
|
- uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: app-audit-report
|
name: app-audit-report
|
||||||
path: ./app/audit_report.json
|
path: ./app/audit_report.json
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: docker/metadata-action@v5
|
- uses: docker/metadata-action@v6
|
||||||
id: meta
|
id: meta
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
@@ -26,19 +26,19 @@ jobs:
|
|||||||
${{ github.ref_name }}
|
${{ github.ref_name }}
|
||||||
|
|
||||||
- name: "Login to Docker Hub"
|
- name: "Login to Docker Hub"
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GitHub Container Registry"
|
- name: "Login to GitHub Container Registry"
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
- uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: ./api
|
context: ./api
|
||||||
push: true
|
push: true
|
||||||
@@ -50,7 +50,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: docker/metadata-action@v5
|
- uses: docker/metadata-action@v6
|
||||||
id: meta
|
id: meta
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
@@ -61,19 +61,19 @@ jobs:
|
|||||||
${{ github.ref_name }}
|
${{ github.ref_name }}
|
||||||
|
|
||||||
- name: "Login to Docker Hub"
|
- name: "Login to Docker Hub"
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: "Login to GitHub Container Registry"
|
- name: "Login to GitHub Container Registry"
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
- uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: ./app
|
context: ./app
|
||||||
push: true
|
push: true
|
||||||
@@ -104,12 +104,12 @@ jobs:
|
|||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
|
|
||||||
- name: Setup Pages
|
- 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:
|
with:
|
||||||
path: "./app/dist"
|
path: "./app/dist"
|
||||||
|
|
||||||
- name: Deploy to GitHub Pages
|
- name: Deploy to GitHub Pages
|
||||||
id: deployment
|
id: deployment
|
||||||
uses: actions/deploy-pages@v4
|
uses: actions/deploy-pages@v5
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[](https://bonch.xfox111.net)
|
[](https://bonch.xfox111.net)
|
||||||
[](https://github.com/xfox111/bonch-calendar/releases/latest)
|
[](https://github.com/xfox111/bonch-calendar/releases/latest)
|
||||||
[](https://github.com/XFox111/bonch-calendar/commits/main)
|
[](https://github.com/XFox111/bonch-calendar/commits/main)
|
||||||
|
[](https://bonch.xfox111.net)
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="assets/dark.png">
|
<source media="(prefers-color-scheme: dark)" srcset="assets/dark.png">
|
||||||
@@ -45,4 +46,4 @@ If you're interested in becoming a maintainer, please reach out to me via email
|
|||||||
[](https://github.com/xfox111)
|
[](https://github.com/xfox111)
|
||||||
[](https://buymeacoffee.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`
|
## Get latest from `dotnet new gitignore`
|
||||||
|
|
||||||
|
# Solution files
|
||||||
|
*.sln
|
||||||
|
*.slnx
|
||||||
|
|
||||||
# dotenv files
|
# dotenv files
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
|||||||
@@ -480,3 +480,5 @@ $RECYCLE.BIN/
|
|||||||
|
|
||||||
# Vim temporary swap files
|
# Vim temporary swap files
|
||||||
*.swp
|
*.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>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<PublishAot>true</PublishAot>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
||||||
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
|
<PackageReference Include="Ical.Net" Version="5.2.2" />
|
||||||
<PackageReference Include="Ical.Net" Version="5.1.3" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
+12
-1
@@ -5,6 +5,11 @@ Accept: application/json
|
|||||||
|
|
||||||
###
|
###
|
||||||
|
|
||||||
|
GET {{Host}}/stats
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
GET {{Host}}/faculties
|
GET {{Host}}/faculties
|
||||||
Accept: application/json
|
Accept: application/json
|
||||||
|
|
||||||
@@ -12,7 +17,7 @@ Accept: application/json
|
|||||||
|
|
||||||
GET {{Host}}/groups
|
GET {{Host}}/groups
|
||||||
?facultyId=56682
|
?facultyId=56682
|
||||||
&course=2
|
&year=2
|
||||||
Accept: application/json
|
Accept: application/json
|
||||||
|
|
||||||
###
|
###
|
||||||
@@ -20,4 +25,10 @@ Accept: application/json
|
|||||||
@groupId = 56606
|
@groupId = 56606
|
||||||
@facultyId = 50029
|
@facultyId = 50029
|
||||||
GET {{Host}}/timetable/{{facultyId}}/{{groupId}}
|
GET {{Host}}/timetable/{{facultyId}}/{{groupId}}
|
||||||
|
?id=download
|
||||||
Accept: text/calendar
|
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)
|
||||||
|
|||||||
+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
|
WORKDIR /build
|
||||||
|
|
||||||
ADD *.csproj .
|
ADD --link . .
|
||||||
RUN dotnet restore
|
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
|
||||||
|
|
||||||
ADD . ./
|
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine AS prod
|
||||||
RUN dotnet publish --no-restore --configuration Release --output /out
|
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS prod
|
|
||||||
WORKDIR /app
|
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;
|
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(
|
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||||
HealthCheckContext context, CancellationToken cancellationToken = default
|
HealthCheckContext context, CancellationToken cancellationToken = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
try
|
Dictionary<string, object> report = trackingService.GetReport();
|
||||||
{
|
|
||||||
Dictionary<int, string> faculties = await groupService.GetFacultiesListAsync();
|
|
||||||
|
|
||||||
if (faculties.Count > 0)
|
// We deem service "unhealthy" if any of the last requests to the API were unsuccessful.
|
||||||
return HealthCheckResult.Healthy();
|
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.");
|
return HealthCheckResult.Healthy();
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return HealthCheckResult.Unhealthy(description: "Timetable website appears to be down.", exception: ex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
+157
-60
@@ -4,19 +4,23 @@ using BonchCalendar;
|
|||||||
using BonchCalendar.Health;
|
using BonchCalendar.Health;
|
||||||
using BonchCalendar.Services;
|
using BonchCalendar.Services;
|
||||||
using BonchCalendar.Utils;
|
using BonchCalendar.Utils;
|
||||||
using HealthChecks.UI.Client;
|
using Ical.Net.DataTypes;
|
||||||
using Ical.Net;
|
|
||||||
using Ical.Net.CalendarComponents;
|
|
||||||
using Ical.Net.Serialization;
|
|
||||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
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.
|
// Add services to the container.
|
||||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi(); // OpenAPI specification generator
|
||||||
builder.Services.AddValidation();
|
builder.Services.AddValidation(); // Request validation
|
||||||
|
|
||||||
|
// Customizing non-200 responses to include trace identifier and a no-as-a-service reason
|
||||||
builder.Services.AddProblemDetails(configure =>
|
builder.Services.AddProblemDetails(configure =>
|
||||||
{
|
{
|
||||||
configure.CustomizeProblemDetails = context =>
|
configure.CustomizeProblemDetails = context =>
|
||||||
@@ -27,112 +31,205 @@ builder.Services.AddProblemDetails(configure =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddScoped<ApiService>()
|
.AddSingleton<IssueTrackingService>() // Service for tracking latest API request results
|
||||||
.AddScoped<ParsingService>();
|
.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()
|
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 =>
|
builder.Services.AddCors(options =>
|
||||||
options.AddDefaultPolicy(policy =>
|
options.AddDefaultPolicy(policy =>
|
||||||
|
{
|
||||||
|
// Allow only GET requests with any headers
|
||||||
policy
|
policy
|
||||||
.WithMethods(["GET"])
|
.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();
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline
|
||||||
app.UseCors();
|
app.UseCors(); // Enable CORS
|
||||||
app.UseStatusCodePages();
|
app.UseStatusCodePages(); // Enable default JSON response body for non-200 responses.
|
||||||
app.MapOpenApi();
|
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
|
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>>();
|
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) =>
|
app.MapGet("/faculties", async ([FromServices] ApiService apiService) =>
|
||||||
{
|
{
|
||||||
logger.LogInformation("Fetching faculties list.");
|
try
|
||||||
Dictionary<int, string> faculties = await apiService.GetFacultiesListAsync();
|
{
|
||||||
return Results.Ok(faculties);
|
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")
|
.WithName("GetFaculties")
|
||||||
.WithDescription("Gets the list of faculties.")
|
.WithDescription("Gets the list of faculties.")
|
||||||
|
.ProducesProblem(StatusCodes.Status500InternalServerError)
|
||||||
.Produces<Dictionary<int, string>>(StatusCodes.Status200OK);
|
.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);
|
year ??= 0; // Setting year to 0 (show all groups) if not specified in the request.
|
||||||
Dictionary<int, string> groups = await apiService.GetGroupsListAsync(facultyId, course);
|
|
||||||
return Results.Ok(groups);
|
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")
|
.WithName("GetGroups")
|
||||||
.WithDescription("Gets the list of groups for the specified faculty and course.")
|
.WithDescription("Gets the list of groups for the specified faculty and course.")
|
||||||
.Produces<Dictionary<int, string>>(StatusCodes.Status200OK)
|
.Produces<Dictionary<int, string>>(StatusCodes.Status200OK)
|
||||||
|
.ProducesProblem(StatusCodes.Status500InternalServerError)
|
||||||
.ProducesValidationProblem();
|
.ProducesValidationProblem();
|
||||||
|
|
||||||
|
// Retrieve timetable for specified group in form of iCal.
|
||||||
app.MapGet("/timetable/{facultyId}/{groupId}", async (
|
app.MapGet("/timetable/{facultyId}/{groupId}", async (
|
||||||
int facultyId, int groupId,
|
int facultyId, int groupId, string? id,
|
||||||
[FromServices] ApiService apiService,
|
[FromServices] TimetableService timetableService
|
||||||
[FromServices] ParsingService parsingService
|
|
||||||
) =>
|
) =>
|
||||||
{
|
{
|
||||||
logger.LogInformation("Generating timetable for group {GroupId} of faculty {FacultyId}.", groupId, facultyId);
|
string? content = await timetableService.TryGetTimetableFromCacheAsync(groupId);
|
||||||
string cacheFile = Path.Combine(Path.GetTempPath(), $"bonch_cal_{groupId}.ics");
|
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.LogInformation("Serving timetable for {FacultyId}/{GroupId} from cache.", facultyId, groupId);
|
||||||
logger.LogWarning("Cache disabled via --no-cache, regenerating timetable for group {GroupId}.", groupId);
|
return Results.Text(content, contentType: "text/calendar");
|
||||||
else
|
|
||||||
{
|
|
||||||
logger.LogInformation("Serving timetable for group {GroupId} from cache.", groupId);
|
|
||||||
return Results.Text(await File.ReadAllTextAsync(cacheFile), contentType: "text/calendar");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DateTime semesterStartDate = await apiService.GetSemesterStartDateAsync(groupId);
|
logger.LogInformation("Begin generating timetable for {FacultyId}/{GroupId}.", facultyId, groupId);
|
||||||
string groupName = (await apiService.GetGroupsListAsync(facultyId, 0))[groupId];
|
|
||||||
|
|
||||||
string classesRaw = await apiService.GetScheduleDocumentAsync(groupId, TimetableType.Classes);
|
if (hasId)
|
||||||
List<CalendarEvent> timetable = [.. parsingService.ParseGeneralTimetable(classesRaw, semesterStartDate, groupName)];
|
content = await timetableService.GetTimetableAsync(facultyId, groupId, saveToCache: true);
|
||||||
|
else
|
||||||
TimetableType[] types = [TimetableType.Attestations, TimetableType.Exams, TimetableType.ExamsForExtramural];
|
|
||||||
foreach (TimetableType type in types)
|
|
||||||
{
|
{
|
||||||
classesRaw = await apiService.GetScheduleDocumentAsync(groupId, type);
|
// For requests with no ID we append an event at 7pm that asks users to update their calendar URL,
|
||||||
timetable.AddRange(parsingService.ParseExamTimetable(classesRaw, groupName));
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
Calendar calendar = new();
|
logger.LogInformation("Fetched timetable for {FacultyId}/{GroupId}.", facultyId, groupId);
|
||||||
calendar.Properties.Add(new CalendarProperty("X-WR-CALNAME", groupName));
|
tracker.TrackTimetableFetch(facultyId, groupId, true); // Track whether retrieving timetable for this specific group was successul or not.
|
||||||
calendar.Properties.Add(new CalendarProperty("X-WR-TIMEZONE", "Europe/Moscow"));
|
return Results.Text(content, contentType: "text/calendar");
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Failed to generate timetable for group {GroupId} of faculty {FacultyId}.", groupId, facultyId);
|
logger.LogError(ex, "Failed to generate timetable for {FacultyId}/{GroupId}.", facultyId, groupId);
|
||||||
throw;
|
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
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.WithName("GetTimetable")
|
.WithName("GetTimetable")
|
||||||
.WithDescription("Gets the iCal timetable for the specified group.")
|
.WithDescription("Gets the iCal timetable for the specified group.")
|
||||||
.Produces<string>(StatusCodes.Status200OK, "text/calendar")
|
.Produces<string>(StatusCodes.Status200OK, "text/calendar")
|
||||||
|
.ProducesProblem(StatusCodes.Status500InternalServerError)
|
||||||
.ProducesValidationProblem();
|
.ProducesValidationProblem();
|
||||||
|
|
||||||
|
// Start the application.
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -4,8 +4,19 @@ using BonchCalendar.Utils;
|
|||||||
|
|
||||||
namespace BonchCalendar.Services;
|
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
|
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() =>
|
public async Task<Dictionary<int, string>> GetFacultiesListAsync() =>
|
||||||
ParseListResponse(await SendRequestAsync(new()
|
ParseListResponse(await SendRequestAsync(new()
|
||||||
{
|
{
|
||||||
@@ -13,15 +24,30 @@ public class ApiService
|
|||||||
["schet"] = GetCurrentSemesterId()
|
["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()
|
ParseListResponse(await SendRequestAsync(new()
|
||||||
{
|
{
|
||||||
["choice"] = "1",
|
["choice"] = "1",
|
||||||
["schet"] = GetCurrentSemesterId(),
|
["schet"] = GetCurrentSemesterId(),
|
||||||
["faculty"] = facultyId.ToString(), // Specifying faculty ID returns a list of groups
|
["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) =>
|
public async Task<string> GetScheduleDocumentAsync(int groupId, TimetableType timetableType) =>
|
||||||
await SendRequestAsync(new()
|
await SendRequestAsync(new()
|
||||||
{
|
{
|
||||||
@@ -30,14 +56,34 @@ public class ApiService
|
|||||||
["group"] = groupId.ToString()
|
["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)
|
public async Task<DateTime> GetSemesterStartDateAsync(int groupId)
|
||||||
{
|
{
|
||||||
using HttpClient client = new();
|
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}");
|
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);
|
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;
|
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);
|
int weekNumber = int.Parse(ParserUtils.NumberRegex().Match(labelText).Value);
|
||||||
|
|
||||||
DateTime currentDate = DateTime.Today;
|
DateTime currentDate = DateTime.Today;
|
||||||
currentDate = currentDate
|
currentDate = currentDate
|
||||||
.AddDays(-(int)currentDate.DayOfWeek + 1) // Move to Monday
|
.AddDays(-(int)currentDate.DayOfWeek + 1) // Move to Monday
|
||||||
@@ -46,6 +92,8 @@ public class ApiService
|
|||||||
return currentDate;
|
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) =>
|
private static Dictionary<int, string> ParseListResponse(string responseContent) =>
|
||||||
responseContent
|
responseContent
|
||||||
.Split(';', StringSplitOptions.RemoveEmptyEntries)
|
.Split(';', StringSplitOptions.RemoveEmptyEntries)
|
||||||
@@ -55,7 +103,8 @@ public class ApiService
|
|||||||
parts => parts[1]
|
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")
|
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
|
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
|
ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,11 +131,12 @@ public class ApiService
|
|||||||
? 1 // August through January - first semester
|
? 1 // August through January - first semester
|
||||||
: 2; // Everything else - second
|
: 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
|
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
|
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)
|
public CalendarEvent[] ParseGeneralTimetable(string rawHtml, DateTime semesterStartDate, string groupName)
|
||||||
{
|
{
|
||||||
using IHtmlDocument doc = new HtmlParser().ParseDocument(rawHtml);
|
using IHtmlDocument doc = new HtmlParser().ParseDocument(rawHtml);
|
||||||
@@ -27,7 +34,7 @@ public partial class ParsingService
|
|||||||
Match timeMatch = ParserUtils.TimeLabelRegex().Match(timeLabelText);
|
Match timeMatch = ParserUtils.TimeLabelRegex().Match(timeLabelText);
|
||||||
string number = timeMatch.Success ? timeMatch.Groups["number"].Value : timeLabelText;
|
string number = timeMatch.Success ? timeMatch.Groups["number"].Value : timeLabelText;
|
||||||
(TimeSpan startTime, TimeSpan endTime) = !timeMatch.Success ?
|
(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["start"].Value),
|
||||||
TimeSpan.Parse(timeMatch.Groups["end"].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((week - 1) * 7) // Move to the correct week
|
||||||
.AddDays(weekday - 1); // Move to the correct weekday
|
.AddDays(weekday - 1); // Move to the correct weekday
|
||||||
|
|
||||||
classes.Add(GetEvent(
|
classes.Add(CreateEvent(
|
||||||
$"{number}. {className} ({classType})", auditorium,
|
title: $"{number}. {className} ({classType})",
|
||||||
GetDescription(groupName, professors, auditorium, weeks),
|
location: auditorium,
|
||||||
classDate, startTime, endTime));
|
description: CreateDescription(groupName, professors, auditorium, weeks),
|
||||||
|
date: classDate,
|
||||||
|
startTime,
|
||||||
|
endTime
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [.. classes];
|
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)
|
public CalendarEvent[] ParseExamTimetable(string rawHtml, string groupName)
|
||||||
{
|
{
|
||||||
using IHtmlDocument doc = new HtmlParser().ParseDocument(rawHtml);
|
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 startTime = TimeSpan.Parse(timeMatch.Groups["start"].Value.Replace('.', ':'));
|
||||||
TimeSpan endTime = TimeSpan.Parse(timeMatch.Groups["end"].Value.Replace('.', ':'));
|
TimeSpan endTime = TimeSpan.Parse(timeMatch.Groups["end"].Value.Replace('.', ':'));
|
||||||
|
|
||||||
classes.Add(GetEvent(
|
classes.Add(CreateEvent(
|
||||||
$"{number}{className} ({classType})", auditorium,
|
title: $"{number}{className} ({classType})",
|
||||||
GetDescription(groupName, professors, auditorium),
|
location: auditorium,
|
||||||
classDate, startTime, endTime));
|
description: CreateDescription(groupName, professors, auditorium),
|
||||||
|
date: classDate,
|
||||||
|
startTime,
|
||||||
|
endTime
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [.. classes];
|
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()
|
new()
|
||||||
{
|
{
|
||||||
Summary = title,
|
Summary = title,
|
||||||
Description = description,
|
Description = description,
|
||||||
Start = new CalDateTime(date.Add(startTime - TimeSpan.FromHours(3)).ToUniversalTime()),
|
Start = new CalDateTime(date.Add(startTime - TimeSpan.FromHours(3)).ToUniversalTime()),
|
||||||
End = new CalDateTime(date.Add(endTime - 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 = $"""
|
string str = $"""
|
||||||
Группа: {groupName}
|
Группа: {groupName}
|
||||||
@@ -107,20 +130,26 @@ public partial class ParsingService
|
|||||||
if (weeks is not null && weeks.Length > 0)
|
if (weeks is not null && weeks.Length > 0)
|
||||||
str += $"\nНедели: {string.Join(", ", weeks)}";
|
str += $"\nНедели: {string.Join(", ", weeks)}";
|
||||||
|
|
||||||
|
// Attempt to recognize wing and room number
|
||||||
Match auditoriumMatch = ParserUtils.AuditoriumRegex().Match(auditorium);
|
Match auditoriumMatch = ParserUtils.AuditoriumRegex().Match(auditorium);
|
||||||
|
|
||||||
if (!auditoriumMatch.Success)
|
if (!auditoriumMatch.Success)
|
||||||
auditoriumMatch = ParserUtils.AuditoriumAltRegex().Match(auditorium);
|
auditoriumMatch = ParserUtils.AuditoriumAltRegex().Match(auditorium);
|
||||||
|
|
||||||
|
// If successful, we can add a nav.sut.ru map link
|
||||||
if (auditoriumMatch.Success)
|
if (auditoriumMatch.Success)
|
||||||
str += "\n\n" + $"""
|
str += "\n\n" + $"""
|
||||||
ГУТ.Навигатор:
|
ГУТ.Навигатор:
|
||||||
https://nav.sut.ru/?cab=k{auditoriumMatch.Groups["wing"].Value}-{auditoriumMatch.Groups["room"].Value}
|
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;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse basic info for a class
|
||||||
private static (string className, string classType, string[] professors, string auditorium) ParseBaseInfo(IElement classElement)
|
private static (string className, string classType, string[] professors, string auditorium) ParseBaseInfo(IElement classElement)
|
||||||
{
|
{
|
||||||
string className = classElement.QuerySelector(".subect")?.TextContent ?? string.Empty;
|
string className = classElement.QuerySelector(".subect")?.TextContent ?? string.Empty;
|
||||||
|
|||||||
@@ -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;
|
namespace BonchCalendar;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Types of timetable documents retrieved from sut.ru API.
|
||||||
|
/// </summary>
|
||||||
public enum TimetableType
|
public enum TimetableType
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Regular timetable document (Занятия).
|
||||||
|
/// </summary>
|
||||||
Classes = 1,
|
Classes = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exams timetable document (Экзаменационная сессия).
|
||||||
|
/// </summary>
|
||||||
Exams = 2,
|
Exams = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exams timetable for extramural students document (Сессия для заочников).
|
||||||
|
/// </summary>
|
||||||
ExamsForExtramural = 4,
|
ExamsForExtramural = 4,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attestations timetable document (Зачеты).
|
||||||
|
/// </summary>
|
||||||
Attestations = 14
|
Attestations = 14
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,20 @@ using System.Text.RegularExpressions;
|
|||||||
|
|
||||||
namespace BonchCalendar.Utils;
|
namespace BonchCalendar.Utils;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Utility methods for timetable parser.
|
||||||
|
/// </summary>
|
||||||
public static partial class ParserUtils
|
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)
|
public static (TimeSpan startTime, TimeSpan endTime) GetTimesFromLabel(string label)
|
||||||
{
|
{
|
||||||
(string startTime, string endTime) = label switch
|
(string startTime, string endTime) = label switch
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"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=https://api.bonch.xfox111.net
|
||||||
|
VITE_BACKEND_HOST=http://localhost:8080
|
||||||
|
|||||||
Generated
+1494
-2048
File diff suppressed because it is too large
Load Diff
+18
-17
@@ -10,25 +10,26 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fluentui/react-components": "^9.72.8",
|
"@fluentui/react-components": "^9.74.1",
|
||||||
"@fluentui/react-icons": "^2.0.316",
|
"@fluentui/react-icons": "^2.0.328",
|
||||||
"@fluentui/react-motion-components-preview": "^0.14.1",
|
"@fluentui/react-motion-components-preview": "^0.15.4",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.7",
|
||||||
"react-localization": "^2.0.6"
|
"react-localization": "^2.0.6",
|
||||||
|
"uuid": "^14.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/node": "^25.0.0",
|
"@types/node": "^25.9.1",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.16",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^10.4.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^16.5.0",
|
"globals": "^17.6.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~6.0.3",
|
||||||
"typescript-eslint": "^8.49.0",
|
"typescript-eslint": "^8.60.1",
|
||||||
"vite": "^7.2.7"
|
"vite": "^8.0.16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import MainView from "./views/MainView";
|
|||||||
import FaqView from "./views/FaqView";
|
import FaqView from "./views/FaqView";
|
||||||
import DedicatedView from "./views/DedicatedView";
|
import DedicatedView from "./views/DedicatedView";
|
||||||
import FooterView from "./views/FooterView";
|
import FooterView from "./views/FooterView";
|
||||||
|
import StatsView from "./views/StatsView";
|
||||||
|
|
||||||
export default function App(): ReactElement
|
export default function App(): ReactElement
|
||||||
{
|
{
|
||||||
@@ -15,6 +16,7 @@ export default function App(): ReactElement
|
|||||||
<FluentProvider theme={ theme }>
|
<FluentProvider theme={ theme }>
|
||||||
<main className={ cls.root }>
|
<main className={ cls.root }>
|
||||||
<MainView />
|
<MainView />
|
||||||
|
<StatsView />
|
||||||
<FaqView />
|
<FaqView />
|
||||||
<DedicatedView />
|
<DedicatedView />
|
||||||
<FooterView />
|
<FooterView />
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const baseTheme: Partial<Theme> =
|
|||||||
colorBrandBackground: "#f68b1f",
|
colorBrandBackground: "#f68b1f",
|
||||||
colorBrandBackgroundHover: "#c36e18",
|
colorBrandBackgroundHover: "#c36e18",
|
||||||
colorNeutralForeground2BrandHover: "#c36e18",
|
colorNeutralForeground2BrandHover: "#c36e18",
|
||||||
|
colorNeutralForeground2BrandPressed: "#a95f15",
|
||||||
colorBrandBackgroundPressed: "#a95f15",
|
colorBrandBackgroundPressed: "#a95f15",
|
||||||
colorCompoundBrandStroke: "#f68b1f",
|
colorCompoundBrandStroke: "#f68b1f",
|
||||||
colorCompoundBrandStrokePressed: "#a95f15"
|
colorCompoundBrandStrokePressed: "#a95f15"
|
||||||
|
|||||||
+61
-9
@@ -1,11 +1,63 @@
|
|||||||
export const fetchFaculties = async (): Promise<[string, string][]> =>
|
const timeout: number = 5000;
|
||||||
{
|
|
||||||
const res = await fetch(import.meta.env.VITE_BACKEND_HOST + "/faculties");
|
|
||||||
return Object.entries(await res.json());
|
|
||||||
};
|
|
||||||
|
|
||||||
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}`);
|
try
|
||||||
return Object.entries(await res.json());
|
{
|
||||||
};
|
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_p1: "Check your SPbSUT classes in {0} calendar",
|
||||||
subtitle_p2: "your",
|
subtitle_p2: "your",
|
||||||
pickFaculty: "1. Pick your faculty",
|
pickFaculty: "1. Pick your faculty",
|
||||||
pickCourse: "2. Pick your course",
|
pickCourse: "2. Pick your year",
|
||||||
pickGroup: "3. Pick your group",
|
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",
|
subscribe: "4. Subscribe to the calendar",
|
||||||
copy: "Copy link",
|
copy: "Copy link",
|
||||||
or: "or",
|
or: "or",
|
||||||
download: "Download .ics file",
|
download: "Download .ics file",
|
||||||
cta: "Like the service? Tell your classmates!",
|
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
|
// FaqView.tsx
|
||||||
faq_h2: "Frequently asked questions",
|
faq_h2: "Frequently asked questions",
|
||||||
question1_h3: "How do I save timetable to my Outlook/Google calendar?",
|
question1_h3: "How do I save timetable to my Outlook/Google calendar?",
|
||||||
@@ -77,6 +95,24 @@ const strings = new LocalizedStrings({
|
|||||||
download: "Скачай .ics файл",
|
download: "Скачай .ics файл",
|
||||||
cta: "Понравился сервис? Расскажи одногруппникам!",
|
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
|
// FaqView.tsx
|
||||||
faq_h2: "Часто задаваемые вопросы",
|
faq_h2: "Часто задаваемые вопросы",
|
||||||
question1_h3: "Как сохранить расписание в Outlook/Google календарь?",
|
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",
|
flexFlow: "column",
|
||||||
gap: tokens.spacingVerticalXXXL,
|
gap: tokens.spacingVerticalXXXL,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
minHeight: "90vh",
|
minHeight: "85vh",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalL}`,
|
padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalL}`,
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import useTimeout from "../hooks/useTimeout";
|
|||||||
import useStyles_MainView from "./MainView.styles";
|
import useStyles_MainView from "./MainView.styles";
|
||||||
import { fetchFaculties, fetchGroups } from "../utils/api";
|
import { fetchFaculties, fetchGroups } from "../utils/api";
|
||||||
import strings from "../utils/strings";
|
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 =>
|
const getEntryOrEmpty = (entries: [string, string][], key: string): string =>
|
||||||
entries.find(i => i[0] === key)?.[1] ?? "";
|
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 [groups, setGroups] = useState<[string, string][] | null>(null);
|
||||||
const [groupId, setGroupId] = useState<string>("");
|
const [groupId, setGroupId] = useState<string>("");
|
||||||
|
|
||||||
|
const id = uuid7();
|
||||||
const icalUrl = useMemo(() => `${import.meta.env.VITE_BACKEND_HOST}/timetable/${facultyId}/${groupId}`, [groupId, facultyId]);
|
const icalUrl = useMemo(() => `${import.meta.env.VITE_BACKEND_HOST}/timetable/${facultyId}/${groupId}`, [groupId, facultyId]);
|
||||||
|
|
||||||
const [showCta, setShowCta] = useState<boolean>(false);
|
const [showCta, setShowCta] = useState<boolean>(false);
|
||||||
@@ -35,10 +37,10 @@ export default function MainView(): ReactElement
|
|||||||
|
|
||||||
const copyLink = useCallback((): void =>
|
const copyLink = useCallback((): void =>
|
||||||
{
|
{
|
||||||
navigator.clipboard.writeText(icalUrl);
|
navigator.clipboard.writeText(icalUrl + "?id=" + id);
|
||||||
triggerCopy();
|
triggerCopy();
|
||||||
setShowCta(true);
|
setShowCta(true);
|
||||||
}, [icalUrl, triggerCopy]);
|
}, [icalUrl, triggerCopy, id]);
|
||||||
|
|
||||||
const onFacultySelect = useCallback((_: SelectionEvents, data: OptionOnSelectData): void =>
|
const onFacultySelect = useCallback((_: SelectionEvents, data: OptionOnSelectData): void =>
|
||||||
{
|
{
|
||||||
@@ -59,7 +61,7 @@ export default function MainView(): ReactElement
|
|||||||
setCourse(courseNumber);
|
setCourse(courseNumber);
|
||||||
setGroupId("");
|
setGroupId("");
|
||||||
setGroups(null);
|
setGroups(null);
|
||||||
fetchGroups(facultyId, courseNumber).then(setGroups);
|
fetchGroups(facultyId, courseNumber).then(Object.entries).then(setGroups);
|
||||||
}, [course, facultyId]);
|
}, [course, facultyId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -99,6 +101,7 @@ export default function MainView(): ReactElement
|
|||||||
<Button key={ i }
|
<Button key={ i }
|
||||||
className={ cls.courseButton }
|
className={ cls.courseButton }
|
||||||
appearance={ course === i ? "primary" : "secondary" }
|
appearance={ course === i ? "primary" : "secondary" }
|
||||||
|
disabled={ facultyId === "" }
|
||||||
onClick={ () => onCourseSelect(i) }>
|
onClick={ () => onCourseSelect(i) }>
|
||||||
|
|
||||||
{ i }
|
{ i }
|
||||||
@@ -114,6 +117,7 @@ export default function MainView(): ReactElement
|
|||||||
className={ cls.field }
|
className={ cls.field }
|
||||||
positioning={ { pinned: true, position: "below" } }
|
positioning={ { pinned: true, position: "below" } }
|
||||||
value={ getEntryOrEmpty(groups ?? [], groupId) }
|
value={ getEntryOrEmpty(groups ?? [], groupId) }
|
||||||
|
disabled={ course === 0 || groups === null }
|
||||||
onOptionSelect={ (_, e) => setGroupId(e.optionValue!) }>
|
onOptionSelect={ (_, e) => setGroupId(e.optionValue!) }>
|
||||||
|
|
||||||
{ groups?.map(([id, name]) =>
|
{ groups?.map(([id, name]) =>
|
||||||
@@ -136,12 +140,13 @@ export default function MainView(): ReactElement
|
|||||||
className={ mergeClasses(cls.field, copyActive && cls.copiedStyle) }
|
className={ mergeClasses(cls.field, copyActive && cls.copiedStyle) }
|
||||||
iconPosition="after"
|
iconPosition="after"
|
||||||
title={ strings.copy }
|
title={ strings.copy }
|
||||||
|
disabled={ groupId === "" }
|
||||||
icon={ copyActive
|
icon={ copyActive
|
||||||
? <Checkmark24Regular className={ cls.copyIcon } />
|
? <Checkmark24Regular className={ cls.copyIcon } />
|
||||||
: <Copy24Regular className={ cls.copyIcon } />
|
: <Copy24Regular className={ cls.copyIcon } />
|
||||||
}>
|
}>
|
||||||
|
|
||||||
<span className={ cls.truncatedText }>{ icalUrl }</span>
|
<span className={ cls.truncatedText }>{ icalUrl + "?id=" + id }</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Slide>
|
</Slide>
|
||||||
@@ -151,7 +156,8 @@ export default function MainView(): ReactElement
|
|||||||
<Button as="a"
|
<Button as="a"
|
||||||
appearance="subtle" icon={ <ArrowDownload24Regular /> }
|
appearance="subtle" icon={ <ArrowDownload24Regular /> }
|
||||||
onClick={ () => setShowCta(true) }
|
onClick={ () => setShowCta(true) }
|
||||||
href={ icalUrl }>
|
disabled={ groupId === "" }
|
||||||
|
href={ icalUrl + "?id=download" }>
|
||||||
|
|
||||||
{ strings.download }
|
{ strings.download }
|
||||||
</Button>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ services:
|
|||||||
image: xfox111/bonch-calendar-api:latest
|
image: xfox111/bonch-calendar-api:latest
|
||||||
build:
|
build:
|
||||||
context: ./api
|
context: ./api
|
||||||
|
environment:
|
||||||
|
- ORIGIN_DOMAIN=localhost:8000
|
||||||
ports:
|
ports:
|
||||||
- 8080:8080
|
- 8080:8080
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user