mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 09:11:06 +00:00
feat: update video plus colour syncing
This commit is contained in:
@@ -28,3 +28,5 @@ betterseqtaplus-safari/
|
||||
.env.submit
|
||||
dependency-graph.svg
|
||||
|
||||
update-videos/
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import { nearestGoogleEventColorId } from "./eventColor";
|
||||
|
||||
describe("nearestGoogleEventColorId", () => {
|
||||
it("returns undefined for empty input", () => {
|
||||
expect(nearestGoogleEventColorId(undefined)).toBeUndefined();
|
||||
expect(nearestGoogleEventColorId("")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps a red subject colour to Tomato", () => {
|
||||
expect(nearestGoogleEventColorId("#dc2127")).toBe("11");
|
||||
expect(nearestGoogleEventColorId("#E76F51")).toBe("4");
|
||||
});
|
||||
|
||||
it("maps a green subject colour to Basil or Sage", () => {
|
||||
expect(["2", "10"]).toContain(nearestGoogleEventColorId("#51b749"));
|
||||
});
|
||||
|
||||
it("accepts rgb() values", () => {
|
||||
expect(nearestGoogleEventColorId("rgb(220, 33, 39)")).toBe("11");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/** Google Calendar event palette (`colorId` → background). */
|
||||
export const GOOGLE_EVENT_COLORS: ReadonlyArray<{ id: string; background: string }> = [
|
||||
{ id: "1", background: "#a4bdfc" },
|
||||
{ id: "2", background: "#7ae7bf" },
|
||||
{ id: "3", background: "#dbadff" },
|
||||
{ id: "4", background: "#ff887c" },
|
||||
{ id: "5", background: "#fbd75b" },
|
||||
{ id: "6", background: "#ffb878" },
|
||||
{ id: "7", background: "#46d6db" },
|
||||
{ id: "8", background: "#e1e1e1" },
|
||||
{ id: "9", background: "#5484ed" },
|
||||
{ id: "10", background: "#51b749" },
|
||||
{ id: "11", background: "#dc2127" },
|
||||
];
|
||||
|
||||
type Rgb = { r: number; g: number; b: number };
|
||||
|
||||
function expandHex(hex: string): string | null {
|
||||
const raw = hex.replace("#", "").trim();
|
||||
if (/^[0-9a-fA-F]{3}$/.test(raw)) {
|
||||
return raw
|
||||
.split("")
|
||||
.map((ch) => ch + ch)
|
||||
.join("");
|
||||
}
|
||||
if (/^[0-9a-fA-F]{6}$/.test(raw)) return raw;
|
||||
if (/^[0-9a-fA-F]{8}$/.test(raw)) return raw.slice(0, 6);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse common CSS colour strings to RGB (hex / rgb / rgba). */
|
||||
export function parseRgbColour(value: string): Rgb | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const hexMatch = trimmed.match(/#([0-9a-fA-F]{3,8})\b/);
|
||||
if (hexMatch) {
|
||||
const expanded = expandHex(hexMatch[1]!);
|
||||
if (!expanded) return null;
|
||||
return {
|
||||
r: Number.parseInt(expanded.slice(0, 2), 16),
|
||||
g: Number.parseInt(expanded.slice(2, 4), 16),
|
||||
b: Number.parseInt(expanded.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
const rgbMatch = trimmed.match(
|
||||
/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i,
|
||||
);
|
||||
if (rgbMatch) {
|
||||
return {
|
||||
r: Math.round(Number(rgbMatch[1])),
|
||||
g: Math.round(Number(rgbMatch[2])),
|
||||
b: Math.round(Number(rgbMatch[3])),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function rgbDistance(a: Rgb, b: Rgb): number {
|
||||
const dr = a.r - b.r;
|
||||
const dg = a.g - b.g;
|
||||
const db = a.b - b.b;
|
||||
return dr * dr + dg * dg + db * db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a SEQTA subject colour to the nearest Google Calendar event `colorId`.
|
||||
* Returns undefined when the colour cannot be parsed.
|
||||
*/
|
||||
export function nearestGoogleEventColorId(colour: string | undefined): string | undefined {
|
||||
const target = colour ? parseRgbColour(colour) : null;
|
||||
if (!target) return undefined;
|
||||
|
||||
let bestId: string | undefined;
|
||||
let bestDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const entry of GOOGLE_EVENT_COLORS) {
|
||||
const candidate = parseRgbColour(entry.background);
|
||||
if (!candidate) continue;
|
||||
const distance = rgbDistance(target, candidate);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestId = entry.id;
|
||||
}
|
||||
}
|
||||
|
||||
return bestId;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import {
|
||||
googleApiEventBody,
|
||||
lessonToGoogleEvent,
|
||||
mapLessonsToGoogleEvents,
|
||||
outlookGraphEventBody,
|
||||
@@ -51,6 +52,27 @@ describe("lessonToGoogleEvent", () => {
|
||||
});
|
||||
expect(event?.description).toContain("Mr Smith");
|
||||
});
|
||||
|
||||
it("maps subject colour to a Google colorId", () => {
|
||||
const event = lessonToGoogleEvent(
|
||||
ORIGIN,
|
||||
{ ...baseLesson, colour: "#dc2127" },
|
||||
"Australia/Perth",
|
||||
);
|
||||
expect(event?.colorId).toBe("11");
|
||||
});
|
||||
});
|
||||
|
||||
describe("googleApiEventBody", () => {
|
||||
it("includes colorId when present", () => {
|
||||
const event = lessonToGoogleEvent(
|
||||
ORIGIN,
|
||||
{ ...baseLesson, colour: "#dc2127" },
|
||||
"Australia/Perth",
|
||||
);
|
||||
expect(event).not.toBeNull();
|
||||
expect(googleApiEventBody(event!)).toMatchObject({ colorId: "11" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapLessonsToGoogleEvents", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BSPLUS_GOOGLE_CALENDAR_EVENT_PROP } from "@/config/googleCalendar";
|
||||
import { BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY } from "@/config/outlookCalendar";
|
||||
import { outlookDescriptionWithKey } from "@/seqta/utils/calendarSync/eventFingerprint";
|
||||
import { nearestGoogleEventColorId } from "./eventColor";
|
||||
import type { GoogleCalendarEventInput, SeqtaTimetableLesson } from "./types";
|
||||
|
||||
const SKIP_TYPES = new Set(["note", "holiday", "assembly-note"]);
|
||||
@@ -54,6 +55,8 @@ export function lessonToGoogleEvent(
|
||||
if (lesson.code) descriptionLines.push(`Code: ${lesson.code}`);
|
||||
if (lesson.period) descriptionLines.push(`Period: ${lesson.period}`);
|
||||
|
||||
const colorId = nearestGoogleEventColorId(lesson.colour);
|
||||
|
||||
return {
|
||||
seqtaKey: seqtaLessonKey(origin, lesson),
|
||||
summary,
|
||||
@@ -62,6 +65,7 @@ export function lessonToGoogleEvent(
|
||||
startDateTime: `${lesson.date}T${from}:00`,
|
||||
endDateTime: `${lesson.date}T${until}:00`,
|
||||
timeZone,
|
||||
...(colorId ? { colorId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,7 +86,7 @@ export function mapLessonsToGoogleEvents(
|
||||
}
|
||||
|
||||
export function googleApiEventBody(event: GoogleCalendarEventInput): Record<string, unknown> {
|
||||
return {
|
||||
const body: Record<string, unknown> = {
|
||||
summary: event.summary,
|
||||
location: event.location,
|
||||
description: event.description,
|
||||
@@ -94,6 +98,10 @@ export function googleApiEventBody(event: GoogleCalendarEventInput): Record<stri
|
||||
},
|
||||
},
|
||||
};
|
||||
if (event.colorId) {
|
||||
body.colorId = event.colorId;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export function outlookGraphEventBody(event: GoogleCalendarEventInput): Record<string, unknown> {
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { SyncDateRange } from "./syncDateRange";
|
||||
import { syncWindowRange } from "./syncDateRange";
|
||||
import type { SeqtaTimetableLesson } from "./types";
|
||||
|
||||
type PrefItem = { name?: string; value?: string };
|
||||
|
||||
async function postSeqtaJson<T>(path: string, body: Record<string, unknown>): Promise<T> {
|
||||
const res = await fetch(`${location.origin}${path}`, {
|
||||
method: "POST",
|
||||
@@ -19,6 +21,54 @@ async function postSeqtaJson<T>(path: string, body: Record<string, unknown>): Pr
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
function prefsToSubjectColours(prefs: PrefItem[] | undefined): Record<string, string> {
|
||||
const colours: Record<string, string> = {};
|
||||
for (const pref of prefs ?? []) {
|
||||
if (!pref.name?.startsWith("timetable.subject.colour.") || !pref.value) continue;
|
||||
const code = pref.name.slice("timetable.subject.colour.".length);
|
||||
if (code) colours[code] = pref.value;
|
||||
}
|
||||
return colours;
|
||||
}
|
||||
|
||||
async function fetchSubjectColours(): Promise<Record<string, string>> {
|
||||
try {
|
||||
if (isEngageParentContext()) {
|
||||
const data = await postSeqtaJson<{ payload?: PrefItem[] }>("/seqta/parent/load/prefs?", {
|
||||
request: "userPrefs",
|
||||
asArray: true,
|
||||
});
|
||||
return prefsToSubjectColours(data?.payload);
|
||||
}
|
||||
|
||||
const studentId = await resolveStudentId();
|
||||
const body: Record<string, unknown> = {
|
||||
request: "userPrefs",
|
||||
asArray: true,
|
||||
};
|
||||
if (studentId != null) body.user = studentId;
|
||||
|
||||
const data = await postSeqtaJson<{ payload?: PrefItem[] }>("/seqta/student/load/prefs?", body);
|
||||
return prefsToSubjectColours(data?.payload);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function withSubjectColours(
|
||||
lessons: SeqtaTimetableLesson[],
|
||||
colours: Record<string, string>,
|
||||
): SeqtaTimetableLesson[] {
|
||||
if (Object.keys(colours).length === 0) return lessons;
|
||||
return lessons.map((lesson) => {
|
||||
const code = lesson.code?.trim();
|
||||
if (!code) return lesson;
|
||||
const colour = colours[code];
|
||||
if (!colour) return lesson;
|
||||
return { ...lesson, colour };
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveStudentId(): Promise<number | undefined> {
|
||||
try {
|
||||
const json = await postSeqtaJson<{ payload?: { id?: number; student?: number } }>(
|
||||
@@ -44,6 +94,9 @@ export async function fetchTimetableLessons(
|
||||
range: SyncDateRange,
|
||||
): Promise<SeqtaTimetableLesson[]> {
|
||||
const { from, until } = range;
|
||||
const coloursPromise = fetchSubjectColours();
|
||||
|
||||
let lessons: SeqtaTimetableLesson[] = [];
|
||||
|
||||
if (isEngageParentContext()) {
|
||||
const listJson = await postSeqtaJson<{ payload?: { id?: string | number }[] }>(
|
||||
@@ -59,9 +112,8 @@ export async function fetchTimetableLessons(
|
||||
"/seqta/parent/load/timetable",
|
||||
{ from, until, student: studentId },
|
||||
);
|
||||
return Array.isArray(data?.payload?.items) ? data.payload.items : [];
|
||||
}
|
||||
|
||||
lessons = Array.isArray(data?.payload?.items) ? data.payload.items : [];
|
||||
} else {
|
||||
const studentId = await resolveStudentId();
|
||||
const body: Record<string, unknown> = { from, until };
|
||||
if (studentId != null) body.student = studentId;
|
||||
@@ -70,7 +122,10 @@ export async function fetchTimetableLessons(
|
||||
"/seqta/student/load/timetable?",
|
||||
body,
|
||||
);
|
||||
return Array.isArray(data?.payload?.items) ? data.payload.items : [];
|
||||
lessons = Array.isArray(data?.payload?.items) ? data.payload.items : [];
|
||||
}
|
||||
|
||||
return withSubjectColours(lessons, await coloursPromise);
|
||||
}
|
||||
|
||||
export async function fetchTimetableForSync(weeksAhead?: number): Promise<SeqtaTimetableLesson[]> {
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface SeqtaTimetableLesson {
|
||||
period?: string;
|
||||
calendarid?: string | number;
|
||||
ci?: number;
|
||||
/** SEQTA subject colour (hex/rgb) used for Google Calendar event colour. */
|
||||
colour?: string;
|
||||
}
|
||||
|
||||
export interface GoogleCalendarEventInput {
|
||||
@@ -20,6 +22,8 @@ export interface GoogleCalendarEventInput {
|
||||
startDateTime: string;
|
||||
endDateTime: string;
|
||||
timeZone: string;
|
||||
/** Google Calendar event colorId ("1"–"11"). */
|
||||
colorId?: string;
|
||||
}
|
||||
|
||||
export interface GoogleCalendarSyncRequest {
|
||||
|
||||
Reference in New Issue
Block a user