已完善个人教学日历订阅。
主要能力: 教师、学生可在“我的课表”启用订阅、复制 URL、唤起日历客户端或下载一次性 ICS。 自动覆盖固定课程、调停课后的最新安排、考试/监考、补考,以及灵活课程提醒。 采用独立随机订阅标识和 HMAC 签名;重置或停用后旧地址立即失效。 支持 ETag 条件请求和一小时刷新提示,减少日历客户端重复同步。 订阅地址按实际前端 API 地址生成,兼容独立部署域名。 已增加窄屏弹窗最大宽度和纵向操作布局。
This commit is contained in:
@@ -119,6 +119,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
{
|
||||
entity.Property(x => x.DisplayName).HasMaxLength(50);
|
||||
entity.Property(x => x.StaffNumber).HasMaxLength(30);
|
||||
entity.Property(x => x.CalendarSubscriptionStamp).HasMaxLength(64);
|
||||
entity.HasIndex(x => x.StaffNumber);
|
||||
});
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"20260726_27_academic_term_archiving";
|
||||
private const string CourseSelectionWaitlistMigration =
|
||||
"20260726_28_course_selection_waitlist";
|
||||
private const string PersonalCalendarSubscriptionMigration =
|
||||
"20260726_29_personal_calendar_subscription";
|
||||
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -359,6 +361,21 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
CourseSelectionWaitlistMigration,
|
||||
courseSelectionWaitlistExists ? [] : CourseSelectionWaitlistStatements,
|
||||
cancellationToken);
|
||||
|
||||
var personalCalendarSubscriptionExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM pragma_table_info('AspNetUsers')
|
||||
WHERE name = 'CalendarSubscriptionStamp'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
PersonalCalendarSubscriptionMigration,
|
||||
personalCalendarSubscriptionExists
|
||||
? []
|
||||
: PersonalCalendarSubscriptionStatements,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync(
|
||||
@@ -1616,6 +1633,18 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] PersonalCalendarSubscriptionStatements =
|
||||
[
|
||||
"""
|
||||
ALTER TABLE "AspNetUsers"
|
||||
ADD COLUMN "CalendarSubscriptionStamp" TEXT NULL;
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE "AspNetUsers"
|
||||
ADD COLUMN "CalendarSubscriptionCreatedAt" TEXT NULL;
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] CourseAdjustmentsStatements =
|
||||
[
|
||||
"""
|
||||
|
||||
+4398
File diff suppressed because it is too large
Load Diff
+40
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class PersonalCalendarSubscription : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CalendarSubscriptionCreatedAt",
|
||||
table: "AspNetUsers",
|
||||
type: "datetime(6)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CalendarSubscriptionStamp",
|
||||
table: "AspNetUsers",
|
||||
type: "varchar(64)",
|
||||
maxLength: 64,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CalendarSubscriptionCreatedAt",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CalendarSubscriptionStamp",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -3016,6 +3016,13 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("CalendarSubscriptionCreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("CalendarSubscriptionStamp")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<Guid?>("CollegeId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Timetables;
|
||||
|
||||
public sealed class PersonalCalendarService(
|
||||
AppDbContext db,
|
||||
TimetableDataService timetableDataService,
|
||||
IOptions<JwtOptions> jwtOptions)
|
||||
{
|
||||
private static readonly TimeSpan ChinaOffset = TimeSpan.FromHours(8);
|
||||
private readonly byte[] _signingKey = SHA256.HashData(
|
||||
Encoding.UTF8.GetBytes($"{jwtOptions.Value.Key}|personal-calendar"));
|
||||
|
||||
public static string CreateStamp() =>
|
||||
Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
public string CreateAccessToken(ApplicationUser user)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
|
||||
throw new InvalidOperationException("个人日历订阅尚未启用。");
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
$"{user.Id:N}:{user.CalendarSubscriptionStamp}");
|
||||
using var hmac = new HMACSHA256(_signingKey);
|
||||
return WebEncoders.Base64UrlEncode(hmac.ComputeHash(payload));
|
||||
}
|
||||
|
||||
public bool IsAccessTokenValid(ApplicationUser user, string accessToken)
|
||||
{
|
||||
if (!user.IsEnabled || string.IsNullOrWhiteSpace(user.CalendarSubscriptionStamp))
|
||||
return false;
|
||||
|
||||
byte[] provided;
|
||||
try
|
||||
{
|
||||
provided = WebEncoders.Base64UrlDecode(accessToken);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expected = WebEncoders.Base64UrlDecode(CreateAccessToken(user));
|
||||
return provided.Length == expected.Length &&
|
||||
CryptographicOperations.FixedTimeEquals(provided, expected);
|
||||
}
|
||||
|
||||
public async Task<PersonalCalendarResult?> BuildAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var student = await db.Students.AsNoTracking()
|
||||
.Where(x => x.UserId == user.Id && x.Status == StudentStatus.Active)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.AdministrativeClassId,
|
||||
x.StudentNumber,
|
||||
x.Name
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var teacher = student is null
|
||||
? await db.Teachers.AsNoTracking()
|
||||
.Where(x => x.UserId == user.Id && x.Status == TeacherStatus.Active)
|
||||
.Select(x => new { x.Id, x.Name })
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
if (student is null && teacher is null) return null;
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow.Add(ChinaOffset));
|
||||
var earliestDate = today.AddMonths(-2);
|
||||
var latestDate = today.AddYears(1);
|
||||
var termIds = await db.AcademicTerms.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.IsEnabled &&
|
||||
x.EndDate >= earliestDate &&
|
||||
x.StartDate <= latestDate)
|
||||
.OrderBy(x => x.StartDate)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var calendarName = $"明序教务 · {user.DisplayName}个人教学日历";
|
||||
var builder = new IcsBuilder(calendarName);
|
||||
foreach (var termId in termIds)
|
||||
{
|
||||
var timetable = student is not null
|
||||
? await timetableDataService.BuildAsync(
|
||||
TimetableResourceType.Class,
|
||||
student.AdministrativeClassId,
|
||||
termId,
|
||||
null,
|
||||
false,
|
||||
student.Id,
|
||||
new TimetableStudentDto(student.StudentNumber, student.Name),
|
||||
cancellationToken)
|
||||
: await timetableDataService.BuildAsync(
|
||||
TimetableResourceType.Teacher,
|
||||
teacher!.Id,
|
||||
termId,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
cancellationToken);
|
||||
if (timetable is null) continue;
|
||||
|
||||
AddScheduledCourses(builder, timetable);
|
||||
AddExamEntries(builder, timetable, teacher is not null);
|
||||
AddFlexibleCourseReminders(builder, timetable);
|
||||
}
|
||||
|
||||
var makeupSessions = await LoadMakeupSessionsAsync(
|
||||
student?.Id,
|
||||
teacher?.Id,
|
||||
earliestDate,
|
||||
latestDate,
|
||||
cancellationToken);
|
||||
foreach (var session in makeupSessions)
|
||||
{
|
||||
builder.AddTimedEvent(
|
||||
$"makeup-{session.Id:N}@jiaowu",
|
||||
teacher is null
|
||||
? $"[补考] {session.CourseName}"
|
||||
: $"[补考监考] {session.CourseName}",
|
||||
ToUtc(session.ExamDate, TimeOnly.FromDateTime(session.StartsAt)),
|
||||
ToUtc(session.ExamDate, TimeOnly.FromDateTime(session.EndsAt)),
|
||||
JoinLocation(
|
||||
session.CampusName,
|
||||
session.BuildingName,
|
||||
session.ClassroomName),
|
||||
JoinDescription(
|
||||
session.PlanName,
|
||||
session.TaskName,
|
||||
session.Notes),
|
||||
"补考",
|
||||
session.UpdatedAt);
|
||||
}
|
||||
|
||||
return new PersonalCalendarResult(
|
||||
builder.Build(),
|
||||
calendarName,
|
||||
builder.EventCount);
|
||||
}
|
||||
|
||||
private static void AddScheduledCourses(IcsBuilder builder, TimetableData timetable)
|
||||
{
|
||||
var slots = timetable.Slots.ToDictionary(x => x.PeriodNumber);
|
||||
var termMonday = StartOfWeek(timetable.Term.StartDate);
|
||||
foreach (var entry in timetable.Entries)
|
||||
{
|
||||
if (!slots.TryGetValue(entry.StartPeriod, out var startSlot) ||
|
||||
!slots.TryGetValue(
|
||||
entry.StartPeriod + entry.PeriodCount - 1,
|
||||
out var endSlot))
|
||||
continue;
|
||||
|
||||
for (var week = entry.StartWeek; week <= entry.EndWeek; week++)
|
||||
{
|
||||
if (!OccursInWeek(entry.WeekPattern, week)) continue;
|
||||
var date = termMonday.AddDays((week - 1) * 7 + entry.DayOfWeek - 1);
|
||||
if (date < timetable.Term.StartDate || date > timetable.Term.EndDate)
|
||||
continue;
|
||||
|
||||
builder.AddTimedEvent(
|
||||
$"course-{entry.Id:N}-{date:yyyyMMdd}@jiaowu",
|
||||
entry.CourseName,
|
||||
ToUtc(date, startSlot.StartsAt),
|
||||
ToUtc(date, endSlot.EndsAt),
|
||||
JoinLocation(
|
||||
entry.CampusName,
|
||||
entry.BuildingName,
|
||||
entry.ClassroomName),
|
||||
JoinDescription(
|
||||
entry.TaskName,
|
||||
$"教师:{string.Join('、', entry.TeacherNames)}",
|
||||
$"教学班:{string.Join('、', entry.ClassNames)}",
|
||||
entry.Notes),
|
||||
"课程",
|
||||
entry.UpdatedAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddExamEntries(
|
||||
IcsBuilder builder,
|
||||
TimetableData timetable,
|
||||
bool isTeacher)
|
||||
{
|
||||
var slots = timetable.Slots.ToDictionary(x => x.PeriodNumber);
|
||||
foreach (var entry in timetable.ExamEntries)
|
||||
{
|
||||
if (entry.ExamDate is not DateOnly examDate ||
|
||||
!slots.TryGetValue(entry.StartPeriod, out var startSlot) ||
|
||||
!slots.TryGetValue(
|
||||
entry.StartPeriod + entry.PeriodCount - 1,
|
||||
out var endSlot))
|
||||
continue;
|
||||
|
||||
builder.AddTimedEvent(
|
||||
$"exam-{entry.Id:N}@jiaowu",
|
||||
isTeacher
|
||||
? $"[监考] {entry.CourseName}"
|
||||
: $"[考试] {entry.CourseName}",
|
||||
ToUtc(examDate, startSlot.StartsAt),
|
||||
ToUtc(examDate, endSlot.EndsAt),
|
||||
JoinLocation(
|
||||
entry.CampusName,
|
||||
entry.BuildingName,
|
||||
entry.ClassroomName),
|
||||
JoinDescription(
|
||||
entry.ExamPlanName,
|
||||
entry.TaskName,
|
||||
string.Join('、', entry.TeacherNames),
|
||||
entry.Notes),
|
||||
"考试",
|
||||
entry.UpdatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddFlexibleCourseReminders(
|
||||
IcsBuilder builder,
|
||||
TimetableData timetable)
|
||||
{
|
||||
foreach (var course in timetable.FlexibleCourses)
|
||||
{
|
||||
builder.AddAllDayEvent(
|
||||
$"flexible-{course.Id:N}-{timetable.Term.Id:N}@jiaowu",
|
||||
$"[灵活安排] {course.CourseName}",
|
||||
timetable.Term.StartDate,
|
||||
JoinDescription(
|
||||
$"{course.StartWeek}—{course.EndWeek} 周",
|
||||
$"每周 {course.WeeklyHours} 学时",
|
||||
$"教师:{string.Join('、', course.TeacherNames)}",
|
||||
course.Notes),
|
||||
"灵活课程",
|
||||
course.UpdatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<PersonalMakeupSession>> LoadMakeupSessionsAsync(
|
||||
Guid? studentId,
|
||||
Guid? teacherId,
|
||||
DateOnly earliestDate,
|
||||
DateOnly latestDate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!studentId.HasValue && !teacherId.HasValue) return [];
|
||||
var source = db.MakeupExamSessions.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published &&
|
||||
x.ExamDate >= earliestDate &&
|
||||
x.ExamDate <= latestDate);
|
||||
source = studentId.HasValue
|
||||
? source.Where(x => x.Enrollments.Any(e => e.StudentId == studentId.Value))
|
||||
: source.Where(x => x.Invigilators.Any(i => i.TeacherId == teacherId!.Value));
|
||||
|
||||
return await source
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartsAt)
|
||||
.Select(x => new PersonalMakeupSession(
|
||||
x.Id,
|
||||
x.MakeupExamPlan!.Name,
|
||||
x.TeachingTask!.Name,
|
||||
x.TeachingTask.Course!.Name,
|
||||
x.ExamDate,
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
x.Classroom == null ? null : x.Classroom.Name,
|
||||
x.Classroom == null ? null : x.Classroom.Building!.Name,
|
||||
x.Classroom == null ? null : x.Classroom.Building!.Campus!.Name,
|
||||
x.Notes,
|
||||
x.UpdatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static DateOnly StartOfWeek(DateOnly date)
|
||||
{
|
||||
var day = date.DayOfWeek == DayOfWeek.Sunday ? 7 : (int)date.DayOfWeek;
|
||||
return date.AddDays(1 - day);
|
||||
}
|
||||
|
||||
private static bool OccursInWeek(WeekPattern pattern, int week) => pattern switch
|
||||
{
|
||||
WeekPattern.Odd => week % 2 == 1,
|
||||
WeekPattern.Even => week % 2 == 0,
|
||||
_ => true
|
||||
};
|
||||
|
||||
private static DateTime ToUtc(DateOnly date, TimeOnly time)
|
||||
{
|
||||
var local = date.ToDateTime(time, DateTimeKind.Unspecified);
|
||||
return new DateTimeOffset(local, ChinaOffset).UtcDateTime;
|
||||
}
|
||||
|
||||
private static string JoinLocation(params string?[] parts) =>
|
||||
string.Join(" · ", parts.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
|
||||
private static string JoinDescription(params string?[] parts) =>
|
||||
string.Join("\n", parts.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
|
||||
private sealed record PersonalMakeupSession(
|
||||
Guid Id,
|
||||
string PlanName,
|
||||
string TaskName,
|
||||
string CourseName,
|
||||
DateOnly ExamDate,
|
||||
DateTime StartsAt,
|
||||
DateTime EndsAt,
|
||||
string? ClassroomName,
|
||||
string? BuildingName,
|
||||
string? CampusName,
|
||||
string? Notes,
|
||||
DateTime UpdatedAt);
|
||||
}
|
||||
|
||||
public sealed record PersonalCalendarResult(
|
||||
byte[] Content,
|
||||
string Name,
|
||||
int EventCount);
|
||||
|
||||
internal sealed class IcsBuilder
|
||||
{
|
||||
private static readonly UTF8Encoding Utf8 = new(false);
|
||||
private readonly StringBuilder _content = new();
|
||||
private bool _built;
|
||||
|
||||
public IcsBuilder(string name)
|
||||
{
|
||||
AddLine("BEGIN:VCALENDAR");
|
||||
AddLine("VERSION:2.0");
|
||||
AddLine("PRODID:-//Mingxu Jiaowu//Personal Teaching Calendar//ZH-CN");
|
||||
AddLine("CALSCALE:GREGORIAN");
|
||||
AddLine("METHOD:PUBLISH");
|
||||
AddLine($"X-WR-CALNAME:{Escape(name)}");
|
||||
AddLine("X-WR-TIMEZONE:Asia/Shanghai");
|
||||
AddLine("REFRESH-INTERVAL;VALUE=DURATION:PT1H");
|
||||
AddLine("X-PUBLISHED-TTL:PT1H");
|
||||
}
|
||||
|
||||
public int EventCount { get; private set; }
|
||||
|
||||
public void AddTimedEvent(
|
||||
string uid,
|
||||
string summary,
|
||||
DateTime startsAtUtc,
|
||||
DateTime endsAtUtc,
|
||||
string location,
|
||||
string description,
|
||||
string category,
|
||||
DateTime updatedAt)
|
||||
{
|
||||
AddLine("BEGIN:VEVENT");
|
||||
AddLine($"UID:{uid}");
|
||||
AddLine($"DTSTAMP:{FormatUtc(NormalizeUpdatedAt(updatedAt))}");
|
||||
AddLine($"LAST-MODIFIED:{FormatUtc(NormalizeUpdatedAt(updatedAt))}");
|
||||
AddLine($"DTSTART:{FormatUtc(startsAtUtc)}");
|
||||
AddLine($"DTEND:{FormatUtc(endsAtUtc)}");
|
||||
AddLine($"SUMMARY:{Escape(summary)}");
|
||||
if (!string.IsNullOrWhiteSpace(location))
|
||||
AddLine($"LOCATION:{Escape(location)}");
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
AddLine($"DESCRIPTION:{Escape(description)}");
|
||||
AddLine($"CATEGORIES:{Escape(category)}");
|
||||
AddLine("STATUS:CONFIRMED");
|
||||
AddLine("END:VEVENT");
|
||||
EventCount++;
|
||||
}
|
||||
|
||||
public void AddAllDayEvent(
|
||||
string uid,
|
||||
string summary,
|
||||
DateOnly date,
|
||||
string description,
|
||||
string category,
|
||||
DateTime updatedAt)
|
||||
{
|
||||
AddLine("BEGIN:VEVENT");
|
||||
AddLine($"UID:{uid}");
|
||||
AddLine($"DTSTAMP:{FormatUtc(NormalizeUpdatedAt(updatedAt))}");
|
||||
AddLine($"LAST-MODIFIED:{FormatUtc(NormalizeUpdatedAt(updatedAt))}");
|
||||
AddLine($"DTSTART;VALUE=DATE:{date:yyyyMMdd}");
|
||||
AddLine($"DTEND;VALUE=DATE:{date.AddDays(1):yyyyMMdd}");
|
||||
AddLine($"SUMMARY:{Escape(summary)}");
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
AddLine($"DESCRIPTION:{Escape(description)}");
|
||||
AddLine($"CATEGORIES:{Escape(category)}");
|
||||
AddLine("STATUS:CONFIRMED");
|
||||
AddLine("TRANSP:TRANSPARENT");
|
||||
AddLine("END:VEVENT");
|
||||
EventCount++;
|
||||
}
|
||||
|
||||
public byte[] Build()
|
||||
{
|
||||
if (!_built)
|
||||
{
|
||||
AddLine("END:VCALENDAR");
|
||||
_built = true;
|
||||
}
|
||||
return Utf8.GetBytes(_content.ToString());
|
||||
}
|
||||
|
||||
private void AddLine(string line)
|
||||
{
|
||||
var current = new StringBuilder();
|
||||
var byteCount = 0;
|
||||
foreach (var rune in line.EnumerateRunes())
|
||||
{
|
||||
var value = rune.ToString();
|
||||
var runeBytes = Utf8.GetByteCount(value);
|
||||
if (byteCount > 0 && byteCount + runeBytes > 74)
|
||||
{
|
||||
_content.Append(current).Append("\r\n ");
|
||||
current.Clear();
|
||||
byteCount = 1;
|
||||
}
|
||||
current.Append(value);
|
||||
byteCount += runeBytes;
|
||||
}
|
||||
_content.Append(current).Append("\r\n");
|
||||
}
|
||||
|
||||
private static string Escape(string value) => value
|
||||
.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||
.Replace("\r\n", "\\n", StringComparison.Ordinal)
|
||||
.Replace("\n", "\\n", StringComparison.Ordinal)
|
||||
.Replace(";", "\\;", StringComparison.Ordinal)
|
||||
.Replace(",", "\\,", StringComparison.Ordinal);
|
||||
|
||||
private static string FormatUtc(DateTime value) =>
|
||||
value.ToUniversalTime().ToString(
|
||||
"yyyyMMdd'T'HHmmss'Z'",
|
||||
CultureInfo.InvariantCulture);
|
||||
|
||||
private static DateTime NormalizeUpdatedAt(DateTime value) =>
|
||||
value == default
|
||||
? new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
: value.Kind == DateTimeKind.Unspecified
|
||||
? DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||
: value.ToUniversalTime();
|
||||
}
|
||||
@@ -99,7 +99,11 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
x.StartWeek,
|
||||
x.EndWeek,
|
||||
x.WeekPattern,
|
||||
x.Notes))
|
||||
x.Notes,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
x.UpdatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -290,7 +294,8 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
x.Classes.Select(item => item.AdministrativeClass!.Name),
|
||||
x.Notes))
|
||||
x.Notes,
|
||||
x.UpdatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -357,7 +362,8 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
x.ExamPlan!.Name,
|
||||
InvigilatorNames = x.Invigilators
|
||||
.Select(i => i.Teacher!.Name),
|
||||
x.Notes
|
||||
x.Notes,
|
||||
x.UpdatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -385,7 +391,8 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
x.Notes,
|
||||
true,
|
||||
x.Name,
|
||||
x.ExamDate);
|
||||
x.ExamDate,
|
||||
x.UpdatedAt);
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
@@ -468,7 +475,8 @@ public sealed record TimetableEntryDto(
|
||||
string? Notes,
|
||||
bool IsExam = false,
|
||||
string? ExamPlanName = null,
|
||||
DateOnly? ExamDate = null);
|
||||
DateOnly? ExamDate = null,
|
||||
DateTime UpdatedAt = default);
|
||||
|
||||
public sealed record FlexibleCourseDto(
|
||||
Guid Id,
|
||||
@@ -483,4 +491,5 @@ public sealed record FlexibleCourseDto(
|
||||
int WeeklyHours,
|
||||
IEnumerable<string> TeacherNames,
|
||||
IEnumerable<string> ClassNames,
|
||||
string? Notes);
|
||||
string? Notes,
|
||||
DateTime UpdatedAt = default);
|
||||
|
||||
Reference in New Issue
Block a user