mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 17:21:05 +00:00
feat: update video plus colour syncing
This commit is contained in:
@@ -28,3 +28,5 @@ betterseqtaplus-safari/
|
|||||||
.env.submit
|
.env.submit
|
||||||
dependency-graph.svg
|
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 { describe, expect, it } from "@jest/globals";
|
||||||
import {
|
import {
|
||||||
|
googleApiEventBody,
|
||||||
lessonToGoogleEvent,
|
lessonToGoogleEvent,
|
||||||
mapLessonsToGoogleEvents,
|
mapLessonsToGoogleEvents,
|
||||||
outlookGraphEventBody,
|
outlookGraphEventBody,
|
||||||
@@ -51,6 +52,27 @@ describe("lessonToGoogleEvent", () => {
|
|||||||
});
|
});
|
||||||
expect(event?.description).toContain("Mr Smith");
|
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", () => {
|
describe("mapLessonsToGoogleEvents", () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BSPLUS_GOOGLE_CALENDAR_EVENT_PROP } from "@/config/googleCalendar";
|
import { BSPLUS_GOOGLE_CALENDAR_EVENT_PROP } from "@/config/googleCalendar";
|
||||||
import { BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY } from "@/config/outlookCalendar";
|
import { BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY } from "@/config/outlookCalendar";
|
||||||
import { outlookDescriptionWithKey } from "@/seqta/utils/calendarSync/eventFingerprint";
|
import { outlookDescriptionWithKey } from "@/seqta/utils/calendarSync/eventFingerprint";
|
||||||
|
import { nearestGoogleEventColorId } from "./eventColor";
|
||||||
import type { GoogleCalendarEventInput, SeqtaTimetableLesson } from "./types";
|
import type { GoogleCalendarEventInput, SeqtaTimetableLesson } from "./types";
|
||||||
|
|
||||||
const SKIP_TYPES = new Set(["note", "holiday", "assembly-note"]);
|
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.code) descriptionLines.push(`Code: ${lesson.code}`);
|
||||||
if (lesson.period) descriptionLines.push(`Period: ${lesson.period}`);
|
if (lesson.period) descriptionLines.push(`Period: ${lesson.period}`);
|
||||||
|
|
||||||
|
const colorId = nearestGoogleEventColorId(lesson.colour);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
seqtaKey: seqtaLessonKey(origin, lesson),
|
seqtaKey: seqtaLessonKey(origin, lesson),
|
||||||
summary,
|
summary,
|
||||||
@@ -62,6 +65,7 @@ export function lessonToGoogleEvent(
|
|||||||
startDateTime: `${lesson.date}T${from}:00`,
|
startDateTime: `${lesson.date}T${from}:00`,
|
||||||
endDateTime: `${lesson.date}T${until}:00`,
|
endDateTime: `${lesson.date}T${until}:00`,
|
||||||
timeZone,
|
timeZone,
|
||||||
|
...(colorId ? { colorId } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +86,7 @@ export function mapLessonsToGoogleEvents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function googleApiEventBody(event: GoogleCalendarEventInput): Record<string, unknown> {
|
export function googleApiEventBody(event: GoogleCalendarEventInput): Record<string, unknown> {
|
||||||
return {
|
const body: Record<string, unknown> = {
|
||||||
summary: event.summary,
|
summary: event.summary,
|
||||||
location: event.location,
|
location: event.location,
|
||||||
description: event.description,
|
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> {
|
export function outlookGraphEventBody(event: GoogleCalendarEventInput): Record<string, unknown> {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { SyncDateRange } from "./syncDateRange";
|
|||||||
import { syncWindowRange } from "./syncDateRange";
|
import { syncWindowRange } from "./syncDateRange";
|
||||||
import type { SeqtaTimetableLesson } from "./types";
|
import type { SeqtaTimetableLesson } from "./types";
|
||||||
|
|
||||||
|
type PrefItem = { name?: string; value?: string };
|
||||||
|
|
||||||
async function postSeqtaJson<T>(path: string, body: Record<string, unknown>): Promise<T> {
|
async function postSeqtaJson<T>(path: string, body: Record<string, unknown>): Promise<T> {
|
||||||
const res = await fetch(`${location.origin}${path}`, {
|
const res = await fetch(`${location.origin}${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -19,6 +21,54 @@ async function postSeqtaJson<T>(path: string, body: Record<string, unknown>): Pr
|
|||||||
return (await res.json()) as T;
|
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> {
|
export async function resolveStudentId(): Promise<number | undefined> {
|
||||||
try {
|
try {
|
||||||
const json = await postSeqtaJson<{ payload?: { id?: number; student?: number } }>(
|
const json = await postSeqtaJson<{ payload?: { id?: number; student?: number } }>(
|
||||||
@@ -44,6 +94,9 @@ export async function fetchTimetableLessons(
|
|||||||
range: SyncDateRange,
|
range: SyncDateRange,
|
||||||
): Promise<SeqtaTimetableLesson[]> {
|
): Promise<SeqtaTimetableLesson[]> {
|
||||||
const { from, until } = range;
|
const { from, until } = range;
|
||||||
|
const coloursPromise = fetchSubjectColours();
|
||||||
|
|
||||||
|
let lessons: SeqtaTimetableLesson[] = [];
|
||||||
|
|
||||||
if (isEngageParentContext()) {
|
if (isEngageParentContext()) {
|
||||||
const listJson = await postSeqtaJson<{ payload?: { id?: string | number }[] }>(
|
const listJson = await postSeqtaJson<{ payload?: { id?: string | number }[] }>(
|
||||||
@@ -59,18 +112,20 @@ export async function fetchTimetableLessons(
|
|||||||
"/seqta/parent/load/timetable",
|
"/seqta/parent/load/timetable",
|
||||||
{ from, until, student: studentId },
|
{ 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;
|
||||||
|
|
||||||
|
const data = await postSeqtaJson<{ payload?: { items?: SeqtaTimetableLesson[] } }>(
|
||||||
|
"/seqta/student/load/timetable?",
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
lessons = Array.isArray(data?.payload?.items) ? data.payload.items : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const studentId = await resolveStudentId();
|
return withSubjectColours(lessons, await coloursPromise);
|
||||||
const body: Record<string, unknown> = { from, until };
|
|
||||||
if (studentId != null) body.student = studentId;
|
|
||||||
|
|
||||||
const data = await postSeqtaJson<{ payload?: { items?: SeqtaTimetableLesson[] } }>(
|
|
||||||
"/seqta/student/load/timetable?",
|
|
||||||
body,
|
|
||||||
);
|
|
||||||
return Array.isArray(data?.payload?.items) ? data.payload.items : [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchTimetableForSync(weeksAhead?: number): Promise<SeqtaTimetableLesson[]> {
|
export async function fetchTimetableForSync(weeksAhead?: number): Promise<SeqtaTimetableLesson[]> {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface SeqtaTimetableLesson {
|
|||||||
period?: string;
|
period?: string;
|
||||||
calendarid?: string | number;
|
calendarid?: string | number;
|
||||||
ci?: number;
|
ci?: number;
|
||||||
|
/** SEQTA subject colour (hex/rgb) used for Google Calendar event colour. */
|
||||||
|
colour?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GoogleCalendarEventInput {
|
export interface GoogleCalendarEventInput {
|
||||||
@@ -20,6 +22,8 @@ export interface GoogleCalendarEventInput {
|
|||||||
startDateTime: string;
|
startDateTime: string;
|
||||||
endDateTime: string;
|
endDateTime: string;
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
|
/** Google Calendar event colorId ("1"–"11"). */
|
||||||
|
colorId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GoogleCalendarSyncRequest {
|
export interface GoogleCalendarSyncRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user