mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 09:11:06 +00:00
feat: implement proper remote event checking for sync
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
/** Stable content fingerprint for skip-unchanged calendar sync. */
|
||||
|
||||
export type FingerprintableEvent = {
|
||||
summary: string;
|
||||
location?: string;
|
||||
description?: string;
|
||||
startDateTime: string;
|
||||
endDateTime: string;
|
||||
timeZone: string;
|
||||
};
|
||||
|
||||
export function eventFingerprint(event: FingerprintableEvent): string {
|
||||
return [
|
||||
event.summary.trim(),
|
||||
(event.location ?? "").trim(),
|
||||
(event.description ?? "").trim(),
|
||||
event.startDateTime.trim(),
|
||||
event.endDateTime.trim(),
|
||||
event.timeZone.trim(),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Parse `Key: {seqtaKey}` from Outlook event body text. */
|
||||
export function parseOutlookSeqtaKey(bodyContent: string | undefined | null): string | undefined {
|
||||
if (!bodyContent) return undefined;
|
||||
const match = bodyContent.match(/^Key:\s*(.+)$/m);
|
||||
const key = match?.[1]?.trim();
|
||||
return key && key.length > 0 ? key : undefined;
|
||||
}
|
||||
|
||||
export function outlookDescriptionWithKey(
|
||||
description: string | undefined,
|
||||
seqtaKey: string,
|
||||
): string {
|
||||
const base = (description ?? "Synced by BetterSEQTA+").trim();
|
||||
const withoutKey = base
|
||||
.split("\n")
|
||||
.filter((line) => !/^Key:\s*/.test(line))
|
||||
.join("\n")
|
||||
.trim();
|
||||
return `${withoutKey}\nKey: ${seqtaKey}`;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import browser from "webextension-polyfill";
|
||||
export interface EventMapEntry {
|
||||
id: string;
|
||||
date: string;
|
||||
/** Last-synced content fingerprint; used to skip unchanged events. */
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
export type EventMapRecord = Record<string, string | EventMapEntry>;
|
||||
@@ -17,11 +19,21 @@ export function normalizeEventMapEntry(
|
||||
if (value == null) return undefined;
|
||||
if (typeof value === "string") return { id: value, date: "" };
|
||||
if (typeof value.id === "string" && value.id.length > 0) {
|
||||
return { id: value.id, date: value.date ?? "" };
|
||||
return {
|
||||
id: value.id,
|
||||
date: value.date ?? "",
|
||||
fingerprint: value.fingerprint,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getStoredFingerprint(
|
||||
value: string | EventMapEntry | undefined,
|
||||
): string | undefined {
|
||||
return normalizeEventMapEntry(value)?.fingerprint;
|
||||
}
|
||||
|
||||
export function getStoredEventId(
|
||||
value: string | EventMapEntry | undefined,
|
||||
): string | undefined {
|
||||
|
||||
@@ -4,8 +4,21 @@ jest.mock("@/utils/verboseLog", () => ({
|
||||
verboseLog: jest.fn(),
|
||||
}));
|
||||
|
||||
import { reportSyncProgress } from "./lessonSyncShared";
|
||||
import {
|
||||
eventFingerprint,
|
||||
outlookDescriptionWithKey,
|
||||
parseOutlookSeqtaKey,
|
||||
} from "./eventFingerprint";
|
||||
import {
|
||||
buildLessonSyncResult,
|
||||
entriesToPrune,
|
||||
formatLessonSyncResultMessage,
|
||||
mergeRemoteEventsIntoMap,
|
||||
reportSyncProgress,
|
||||
upsertLessonEvents,
|
||||
} from "./lessonSyncShared";
|
||||
import type { GoogleCalendarSyncProgress } from "@/seqta/utils/googleCalendar/types";
|
||||
import { syncWindowRange } from "@/seqta/utils/googleCalendar/syncDateRange";
|
||||
|
||||
describe("reportSyncProgress", () => {
|
||||
it("calls onProgress when provided", () => {
|
||||
@@ -24,3 +37,189 @@ describe("reportSyncProgress", () => {
|
||||
expect(onProgress).toHaveBeenCalledWith(progress);
|
||||
});
|
||||
});
|
||||
|
||||
describe("eventFingerprint", () => {
|
||||
it("is stable for equivalent event content", () => {
|
||||
const base = {
|
||||
summary: "Math",
|
||||
location: "MA1",
|
||||
description: "Synced by BetterSEQTA+",
|
||||
startDateTime: "2026-07-13T09:00:00",
|
||||
endDateTime: "2026-07-13T10:00:00",
|
||||
timeZone: "Australia/Adelaide",
|
||||
};
|
||||
expect(eventFingerprint(base)).toBe(eventFingerprint({ ...base }));
|
||||
expect(eventFingerprint(base)).not.toBe(
|
||||
eventFingerprint({ ...base, summary: "English" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("outlook Key helpers", () => {
|
||||
it("embeds and parses Key lines", () => {
|
||||
const withKey = outlookDescriptionWithKey("Synced by BetterSEQTA+\nTeacher: A", "origin:cal:1");
|
||||
expect(withKey).toContain("Key: origin:cal:1");
|
||||
expect(parseOutlookSeqtaKey(withKey)).toBe("origin:cal:1");
|
||||
expect(outlookDescriptionWithKey(withKey, "origin:cal:2")).toContain("Key: origin:cal:2");
|
||||
expect(outlookDescriptionWithKey(withKey, "origin:cal:2").match(/^Key:/gm)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entriesToPrune", () => {
|
||||
const origin = "https://school.seqta.com.au";
|
||||
const weeksAhead = 12;
|
||||
const syncDate = syncWindowRange(weeksAhead).from;
|
||||
const mapKey = `${origin}::${origin}:cal:1`;
|
||||
const cancelledKey = `${origin}::${origin}:cal:cancelled`;
|
||||
|
||||
it("returns nothing for incremental mode", () => {
|
||||
expect(
|
||||
entriesToPrune(
|
||||
{ [mapKey]: { id: "evt-1", date: syncDate } },
|
||||
origin,
|
||||
"incremental",
|
||||
weeksAhead,
|
||||
new Set([mapKey]),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("prunes cancelled lessons still inside the sync window", () => {
|
||||
const pruned = entriesToPrune(
|
||||
{
|
||||
[mapKey]: { id: "evt-1", date: syncDate },
|
||||
[cancelledKey]: { id: "evt-cancelled", date: syncDate },
|
||||
},
|
||||
origin,
|
||||
"full",
|
||||
weeksAhead,
|
||||
new Set([mapKey]),
|
||||
);
|
||||
expect(pruned).toEqual([[cancelledKey, "evt-cancelled"]]);
|
||||
});
|
||||
|
||||
it("prunes events whose date is outside the sync window", () => {
|
||||
const staleKey = `${origin}::${origin}:cal:old`;
|
||||
const pruned = entriesToPrune(
|
||||
{ [staleKey]: { id: "evt-old", date: "2020-01-06" } },
|
||||
origin,
|
||||
"full",
|
||||
weeksAhead,
|
||||
new Set(),
|
||||
);
|
||||
expect(pruned).toEqual([[staleKey, "evt-old"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeRemoteEventsIntoMap", () => {
|
||||
const origin = "https://school.seqta.com.au";
|
||||
const mapKey = (o: string, seqtaKey: string) => `${o}::${seqtaKey}`;
|
||||
|
||||
it("recovers remote IDs for the current origin only", () => {
|
||||
const eventMap: Record<string, { id: string; date: string; fingerprint?: string }> = {};
|
||||
mergeRemoteEventsIntoMap(
|
||||
eventMap,
|
||||
origin,
|
||||
[
|
||||
{
|
||||
seqtaKey: `${origin}:cal:1`,
|
||||
id: "remote-1",
|
||||
date: "2026-07-13",
|
||||
fingerprint: "fp-1",
|
||||
},
|
||||
{
|
||||
seqtaKey: "https://other.seqta.com.au:cal:2",
|
||||
id: "remote-other",
|
||||
date: "2026-07-13",
|
||||
fingerprint: "fp-other",
|
||||
},
|
||||
],
|
||||
mapKey,
|
||||
);
|
||||
|
||||
expect(eventMap[`${origin}::${origin}:cal:1`]).toMatchObject({
|
||||
id: "remote-1",
|
||||
fingerprint: "fp-1",
|
||||
});
|
||||
expect(eventMap[`${origin}::https://other.seqta.com.au:cal:2`]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps an existing local fingerprint when reconciling", () => {
|
||||
const key = `${origin}::${origin}:cal:1`;
|
||||
const eventMap = {
|
||||
[key]: { id: "old-id", date: "2026-07-13", fingerprint: "local-fp" },
|
||||
};
|
||||
mergeRemoteEventsIntoMap(
|
||||
eventMap,
|
||||
origin,
|
||||
[
|
||||
{
|
||||
seqtaKey: `${origin}:cal:1`,
|
||||
id: "remote-1",
|
||||
date: "2026-07-13",
|
||||
fingerprint: "remote-fp",
|
||||
},
|
||||
],
|
||||
mapKey,
|
||||
);
|
||||
expect(eventMap[key]).toMatchObject({ id: "remote-1", fingerprint: "local-fp" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatLessonSyncResultMessage", () => {
|
||||
it("includes unchanged counts", () => {
|
||||
expect(
|
||||
formatLessonSyncResultMessage(
|
||||
{ success: true, created: 1, updated: 2, deleted: 0, skipped: 3 },
|
||||
"Google Calendar",
|
||||
),
|
||||
).toBe("Google Calendar updated (1 new, 2 updated, 3 unchanged).");
|
||||
});
|
||||
|
||||
it("reports up to date when nothing changed", () => {
|
||||
expect(
|
||||
formatLessonSyncResultMessage(
|
||||
buildLessonSyncResult(0, 0, 0, 0, 0, Date.now()),
|
||||
"Outlook Calendar",
|
||||
),
|
||||
).toBe("Outlook Calendar is up to date.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertLessonEvents skip unchanged", () => {
|
||||
it("skips API writes when fingerprint matches", async () => {
|
||||
const origin = "https://school.seqta.com.au";
|
||||
const event = {
|
||||
seqtaKey: `${origin}:cal:1`,
|
||||
summary: "Math",
|
||||
description: "Synced by BetterSEQTA+",
|
||||
startDateTime: "2026-07-13T09:00:00",
|
||||
endDateTime: "2026-07-13T10:00:00",
|
||||
timeZone: "UTC",
|
||||
};
|
||||
const fp = eventFingerprint(event);
|
||||
const key = `${origin}::${event.seqtaKey}`;
|
||||
const eventMap = {
|
||||
[key]: { id: "existing", date: "2026-07-13", fingerprint: fp },
|
||||
};
|
||||
const upsert = jest.fn(async () => "existing");
|
||||
|
||||
const result = await upsertLessonEvents({
|
||||
events: [event],
|
||||
eventMap,
|
||||
origin,
|
||||
staleEntryCount: 0,
|
||||
totalSteps: 1,
|
||||
lastSyncAt: Date.now(),
|
||||
initialFailed: 0,
|
||||
getAccessToken: async () => "token",
|
||||
mapKey: (o, k) => `${o}::${k}`,
|
||||
upsert,
|
||||
writeState: async () => undefined,
|
||||
logLabel: "Test",
|
||||
});
|
||||
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ created: 0, updated: 0, skipped: 1, failed: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { verboseLog } from "@/utils/verboseLog";
|
||||
import {
|
||||
getStoredEventId,
|
||||
getStoredFingerprint,
|
||||
lessonDateFromSeqtaKey,
|
||||
normalizeEventMapEntry,
|
||||
type EventMapRecord,
|
||||
} from "@/seqta/utils/calendarSync/eventMap";
|
||||
import { eventFingerprint } from "@/seqta/utils/calendarSync/eventFingerprint";
|
||||
import type { RemoteSyncedEvent } from "@/seqta/utils/calendarSync/remoteEvents";
|
||||
import {
|
||||
isDateInRange,
|
||||
syncWindowRange,
|
||||
type SyncDateRange,
|
||||
} from "@/seqta/utils/googleCalendar/syncDateRange";
|
||||
import type {
|
||||
GoogleCalendarDeleteResult,
|
||||
@@ -20,7 +24,12 @@ export const EVENT_MAP_PERSIST_EVERY = 10;
|
||||
|
||||
export type MappedLessonEvent = {
|
||||
seqtaKey: string;
|
||||
summary: string;
|
||||
location?: string;
|
||||
description?: string;
|
||||
startDateTime: string;
|
||||
endDateTime: string;
|
||||
timeZone: string;
|
||||
};
|
||||
|
||||
export function reportSyncProgress(
|
||||
@@ -65,15 +74,49 @@ export function entriesToPrune(
|
||||
if (!mapKey.startsWith(prefix)) continue;
|
||||
const entry = normalizeEventMapEntry(raw);
|
||||
if (!entry) continue;
|
||||
const stale = entry.date
|
||||
? !isDateInRange(entry.date, window)
|
||||
: !currentMapKeys.has(mapKey);
|
||||
if (stale) entries.push([mapKey, entry.id]);
|
||||
const missingFromTimetable = !currentMapKeys.has(mapKey);
|
||||
const outsideWindow = entry.date ? !isDateInRange(entry.date, window) : false;
|
||||
if (missingFromTimetable || outsideWindow) {
|
||||
entries.push([mapKey, entry.id]);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Merge remote BS+ events into the local eventMap (origin-scoped keys). */
|
||||
export function mergeRemoteEventsIntoMap(
|
||||
eventMap: EventMapRecord,
|
||||
origin: string,
|
||||
remoteEvents: RemoteSyncedEvent[],
|
||||
mapKey: (origin: string, seqtaKey: string) => string,
|
||||
): void {
|
||||
for (const remote of remoteEvents) {
|
||||
if (!remote.seqtaKey.startsWith(origin)) continue;
|
||||
const key = mapKey(origin, remote.seqtaKey);
|
||||
const existing = normalizeEventMapEntry(eventMap[key]);
|
||||
eventMap[key] = {
|
||||
id: remote.id,
|
||||
date: remote.date || existing?.date || lessonDateFromSeqtaKey(remote.seqtaKey) || "",
|
||||
// Prefer last-written local fingerprint so skip stays stable across API format quirks.
|
||||
fingerprint: existing?.fingerprint ?? remote.fingerprint,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function reconcileRangeForMode(
|
||||
mode: "full" | "incremental",
|
||||
weeksAhead: number,
|
||||
ranges: {
|
||||
syncWindowRange: (weeks: number) => SyncDateRange;
|
||||
trailingWeekRange: (weeks: number) => SyncDateRange;
|
||||
},
|
||||
): SyncDateRange {
|
||||
return mode === "incremental"
|
||||
? ranges.trailingWeekRange(weeksAhead)
|
||||
: ranges.syncWindowRange(weeksAhead);
|
||||
}
|
||||
|
||||
export function notConfiguredSyncResult(error: string): GoogleCalendarSyncResult {
|
||||
return { success: false, configured: false, error };
|
||||
}
|
||||
@@ -98,10 +141,12 @@ export function formatLessonSyncResultMessage(
|
||||
const created = result.created ?? 0;
|
||||
const updated = result.updated ?? 0;
|
||||
const deleted = result.deleted ?? 0;
|
||||
const skipped = result.skipped ?? 0;
|
||||
const parts: string[] = [];
|
||||
if (created > 0) parts.push(`${created} new`);
|
||||
if (updated > 0) parts.push(`${updated} updated`);
|
||||
if (deleted > 0) parts.push(`${deleted} removed`);
|
||||
if (skipped > 0) parts.push(`${skipped} unchanged`);
|
||||
if (parts.length === 0) return `${calendarLabel} is up to date.`;
|
||||
return `${calendarLabel} updated (${parts.join(", ")}).`;
|
||||
}
|
||||
@@ -187,6 +232,7 @@ export function buildLessonSyncResult(
|
||||
created: number,
|
||||
updated: number,
|
||||
deleted: number,
|
||||
skipped: number,
|
||||
failed: number,
|
||||
lastSyncAt: number,
|
||||
): GoogleCalendarSyncResult {
|
||||
@@ -197,7 +243,7 @@ export function buildLessonSyncResult(
|
||||
created,
|
||||
updated,
|
||||
deleted,
|
||||
skipped: 0,
|
||||
skipped,
|
||||
failed,
|
||||
lastSyncAt,
|
||||
error:
|
||||
@@ -234,7 +280,13 @@ type UpsertLessonEventsParams<TEvent extends MappedLessonEvent> = {
|
||||
|
||||
export async function upsertLessonEvents<TEvent extends MappedLessonEvent>(
|
||||
params: UpsertLessonEventsParams<TEvent>,
|
||||
): Promise<{ created: number; updated: number; failed: number; accessToken: string }> {
|
||||
): Promise<{
|
||||
created: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
accessToken: string;
|
||||
}> {
|
||||
const {
|
||||
events,
|
||||
eventMap,
|
||||
@@ -254,16 +306,29 @@ export async function upsertLessonEvents<TEvent extends MappedLessonEvent>(
|
||||
let accessToken = await getAccessToken();
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let failed = initialFailed;
|
||||
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i];
|
||||
const key = mapKey(origin, event.seqtaKey);
|
||||
const existingId = getStoredEventId(eventMap[key]);
|
||||
const desiredFingerprint = eventFingerprint(event);
|
||||
const progressCurrent = staleEntryCount + i + 1;
|
||||
const progressMessage = `Syncing events (${i + 1}/${events.length})…`;
|
||||
|
||||
try {
|
||||
if (existingId && getStoredFingerprint(eventMap[key]) === desiredFingerprint) {
|
||||
skipped += 1;
|
||||
reportSyncProgress(onProgress, {
|
||||
phase: "upserting",
|
||||
current: progressCurrent,
|
||||
total: totalSteps,
|
||||
message: progressMessage,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const remoteId = await upsert(accessToken, existingId, event, async () => {
|
||||
accessToken = await getAccessToken();
|
||||
return accessToken;
|
||||
@@ -273,6 +338,7 @@ export async function upsertLessonEvents<TEvent extends MappedLessonEvent>(
|
||||
eventMap[key] = {
|
||||
id: remoteId,
|
||||
date: lessonDateForEvent(event.startDateTime, event.seqtaKey),
|
||||
fingerprint: desiredFingerprint,
|
||||
};
|
||||
|
||||
if ((i + 1) % EVENT_MAP_PERSIST_EVERY === 0 || i === events.length - 1) {
|
||||
@@ -291,5 +357,5 @@ export async function upsertLessonEvents<TEvent extends MappedLessonEvent>(
|
||||
});
|
||||
}
|
||||
|
||||
return { created, updated, failed, accessToken };
|
||||
return { created, updated, skipped, failed, accessToken };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { GOOGLE_CALENDAR_API } from "@/config/googleCalendar";
|
||||
import { OUTLOOK_GRAPH_API } from "@/config/outlookCalendar";
|
||||
import { BSPLUS_GOOGLE_CALENDAR_EVENT_PROP, GOOGLE_CALENDAR_API } from "@/config/googleCalendar";
|
||||
import {
|
||||
BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY,
|
||||
OUTLOOK_GRAPH_API,
|
||||
} from "@/config/outlookCalendar";
|
||||
import {
|
||||
eventFingerprint,
|
||||
parseOutlookSeqtaKey,
|
||||
} from "@/seqta/utils/calendarSync/eventFingerprint";
|
||||
import type { SyncDateRange } from "@/seqta/utils/googleCalendar/syncDateRange";
|
||||
|
||||
export type RemoteSyncedEvent = {
|
||||
seqtaKey: string;
|
||||
id: string;
|
||||
fingerprint: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
async function authorizedFetch(
|
||||
accessToken: string,
|
||||
@@ -138,3 +153,190 @@ export function deleteOutlookCalendarEvent(
|
||||
refreshAccessToken,
|
||||
);
|
||||
}
|
||||
|
||||
function toRfc3339Start(date: string): string {
|
||||
return `${date}T00:00:00Z`;
|
||||
}
|
||||
|
||||
function toRfc3339EndExclusive(date: string): string {
|
||||
const d = new Date(`${date}T12:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + 1);
|
||||
const y = d.getUTCFullYear();
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}T00:00:00Z`;
|
||||
}
|
||||
|
||||
function dateFromDateTime(value: string | undefined): string {
|
||||
if (!value) return "";
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
type GoogleListItem = {
|
||||
id?: string;
|
||||
summary?: string;
|
||||
location?: string;
|
||||
description?: string;
|
||||
start?: { dateTime?: string; date?: string; timeZone?: string };
|
||||
end?: { dateTime?: string; date?: string; timeZone?: string };
|
||||
extendedProperties?: { private?: Record<string, string> };
|
||||
};
|
||||
|
||||
function googleItemToRemote(item: GoogleListItem): RemoteSyncedEvent | null {
|
||||
const seqtaKey = item.extendedProperties?.private?.[BSPLUS_GOOGLE_CALENDAR_EVENT_PROP];
|
||||
if (!item.id || !seqtaKey) return null;
|
||||
const startDateTime = item.start?.dateTime ?? (item.start?.date ? `${item.start.date}T00:00:00` : "");
|
||||
const endDateTime = item.end?.dateTime ?? (item.end?.date ? `${item.end.date}T00:00:00` : "");
|
||||
const timeZone = item.start?.timeZone ?? item.end?.timeZone ?? "UTC";
|
||||
return {
|
||||
seqtaKey,
|
||||
id: item.id,
|
||||
date: dateFromDateTime(startDateTime) || item.start?.date || "",
|
||||
fingerprint: eventFingerprint({
|
||||
summary: item.summary ?? "",
|
||||
location: item.location,
|
||||
description: item.description,
|
||||
startDateTime,
|
||||
endDateTime,
|
||||
timeZone,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listGoogleSyncedEvents(
|
||||
accessToken: string,
|
||||
calendarId: string,
|
||||
range: SyncDateRange,
|
||||
refreshAccessToken?: () => Promise<string>,
|
||||
): Promise<RemoteSyncedEvent[]> {
|
||||
const encodedCalendar = encodeURIComponent(calendarId);
|
||||
const out: RemoteSyncedEvent[] = [];
|
||||
let pageToken: string | undefined;
|
||||
|
||||
do {
|
||||
const params = new URLSearchParams({
|
||||
singleEvents: "true",
|
||||
orderBy: "startTime",
|
||||
maxResults: "2500",
|
||||
timeMin: toRfc3339Start(range.from),
|
||||
timeMax: toRfc3339EndExclusive(range.until),
|
||||
fields:
|
||||
"nextPageToken,items(id,summary,location,description,start,end,extendedProperties)",
|
||||
});
|
||||
if (pageToken) params.set("pageToken", pageToken);
|
||||
|
||||
const res = await authorizedFetch(
|
||||
accessToken,
|
||||
`${GOOGLE_CALENDAR_API}/calendars/${encodedCalendar}/events?${params}`,
|
||||
{ method: "GET" },
|
||||
refreshAccessToken,
|
||||
);
|
||||
const json = (await res.json().catch(() => ({}))) as {
|
||||
items?: GoogleListItem[];
|
||||
nextPageToken?: string;
|
||||
error?: { message?: string };
|
||||
};
|
||||
if (!res.ok) {
|
||||
throw new Error(json?.error?.message ?? `Google Calendar list failed (${res.status})`);
|
||||
}
|
||||
|
||||
for (const item of json.items ?? []) {
|
||||
const mapped = googleItemToRemote(item);
|
||||
if (mapped) out.push(mapped);
|
||||
}
|
||||
pageToken = json.nextPageToken;
|
||||
} while (pageToken);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
type OutlookListItem = {
|
||||
id?: string;
|
||||
subject?: string;
|
||||
body?: { content?: string; contentType?: string };
|
||||
location?: { displayName?: string };
|
||||
start?: { dateTime?: string; timeZone?: string };
|
||||
end?: { dateTime?: string; timeZone?: string };
|
||||
categories?: string[];
|
||||
};
|
||||
|
||||
function outlookItemToRemote(item: OutlookListItem): RemoteSyncedEvent | null {
|
||||
if (!item.id) return null;
|
||||
const categories = item.categories ?? [];
|
||||
if (!categories.includes(BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY)) return null;
|
||||
const bodyContent = item.body?.content ?? "";
|
||||
const seqtaKey = parseOutlookSeqtaKey(bodyContent);
|
||||
if (!seqtaKey) return null;
|
||||
|
||||
const startDateTime = (item.start?.dateTime ?? "").replace(/\.\d+$/, "");
|
||||
const endDateTime = (item.end?.dateTime ?? "").replace(/\.\d+$/, "");
|
||||
const timeZone = item.start?.timeZone ?? item.end?.timeZone ?? "UTC";
|
||||
|
||||
return {
|
||||
seqtaKey,
|
||||
id: item.id,
|
||||
date: dateFromDateTime(startDateTime),
|
||||
fingerprint: eventFingerprint({
|
||||
summary: item.subject ?? "",
|
||||
location: item.location?.displayName,
|
||||
description: outlookDescriptionForFingerprint(bodyContent),
|
||||
startDateTime,
|
||||
endDateTime,
|
||||
timeZone,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Strip Outlook Key line so fingerprint matches local mapped event description. */
|
||||
function outlookDescriptionForFingerprint(bodyContent: string): string {
|
||||
return bodyContent
|
||||
.split("\n")
|
||||
.filter((line) => !/^Key:\s*/.test(line))
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export async function listOutlookSyncedEvents(
|
||||
accessToken: string,
|
||||
range: SyncDateRange,
|
||||
refreshAccessToken?: () => Promise<string>,
|
||||
): Promise<RemoteSyncedEvent[]> {
|
||||
const out: RemoteSyncedEvent[] = [];
|
||||
const params = new URLSearchParams({
|
||||
startDateTime: toRfc3339Start(range.from),
|
||||
endDateTime: toRfc3339EndExclusive(range.until),
|
||||
$top: "100",
|
||||
$select: "id,subject,body,location,start,end,categories",
|
||||
});
|
||||
|
||||
let nextUrl: string | undefined =
|
||||
`${OUTLOOK_GRAPH_API}/me/calendarView?${params.toString()}`;
|
||||
|
||||
while (nextUrl) {
|
||||
const res = await authorizedFetch(
|
||||
accessToken,
|
||||
nextUrl,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Prefer: 'outlook.body-content-type="text"' },
|
||||
},
|
||||
refreshAccessToken,
|
||||
);
|
||||
const json = (await res.json().catch(() => ({}))) as {
|
||||
value?: OutlookListItem[];
|
||||
"@odata.nextLink"?: string;
|
||||
error?: { message?: string };
|
||||
};
|
||||
if (!res.ok) {
|
||||
throw new Error(json?.error?.message ?? `Outlook Calendar list failed (${res.status})`);
|
||||
}
|
||||
|
||||
for (const item of json.value ?? []) {
|
||||
const mapped = outlookItemToRemote(item);
|
||||
if (mapped) out.push(mapped);
|
||||
}
|
||||
nextUrl = json["@odata.nextLink"];
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -9,13 +9,19 @@ import {
|
||||
deleteTrackedLessonEvents,
|
||||
emptyLessonsSyncResult,
|
||||
entriesToPrune,
|
||||
mergeRemoteEventsIntoMap,
|
||||
notConfiguredSyncResult,
|
||||
notConnectedSyncResult,
|
||||
originEventMapEntries,
|
||||
reconcileRangeForMode,
|
||||
reportSyncProgress,
|
||||
upsertLessonEvents,
|
||||
} from "@/seqta/utils/calendarSync/lessonSyncShared";
|
||||
import { googleApiEventBody, mapLessonsToGoogleEvents, outlookGraphEventBody } from "@/seqta/utils/googleCalendar/eventMapper";
|
||||
import {
|
||||
googleApiEventBody,
|
||||
mapLessonsToGoogleEvents,
|
||||
outlookGraphEventBody,
|
||||
} from "@/seqta/utils/googleCalendar/eventMapper";
|
||||
import {
|
||||
readGoogleCalendarState,
|
||||
writeGoogleCalendarState,
|
||||
@@ -30,14 +36,23 @@ import type {
|
||||
import {
|
||||
deleteGoogleCalendarEvent,
|
||||
deleteOutlookCalendarEvent,
|
||||
listGoogleSyncedEvents,
|
||||
listOutlookSyncedEvents,
|
||||
upsertGoogleCalendarEvent,
|
||||
upsertOutlookCalendarEvent,
|
||||
type RemoteSyncedEvent,
|
||||
} from "@/seqta/utils/calendarSync/remoteEvents";
|
||||
import { ensureGoogleAppCalendar } from "@/seqta/utils/googleCalendar/calendarProvisioning";
|
||||
import {
|
||||
syncWindowRange,
|
||||
trailingWeekRange,
|
||||
type SyncDateRange,
|
||||
} from "@/seqta/utils/googleCalendar/syncDateRange";
|
||||
import {
|
||||
readOutlookCalendarState,
|
||||
writeOutlookCalendarState,
|
||||
} from "@/seqta/utils/outlookCalendar/storage";
|
||||
import { verboseLog } from "@/utils/verboseLog";
|
||||
|
||||
type CalendarStoredState = {
|
||||
refreshToken?: string;
|
||||
@@ -67,6 +82,11 @@ export type CalendarLessonSyncProvider = {
|
||||
body: Record<string, unknown>,
|
||||
refreshAccessToken: () => Promise<string>,
|
||||
) => Promise<string>;
|
||||
listSyncedEvents: (
|
||||
accessToken: string,
|
||||
range: SyncDateRange,
|
||||
refreshAccessToken: () => Promise<string>,
|
||||
) => Promise<RemoteSyncedEvent[]>;
|
||||
toApiBody: (event: GoogleCalendarEventInput) => Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -101,6 +121,13 @@ export const googleLessonSyncProvider: CalendarLessonSyncProvider = {
|
||||
body,
|
||||
refreshAccessToken,
|
||||
),
|
||||
listSyncedEvents: async (accessToken, range, refreshAccessToken) =>
|
||||
listGoogleSyncedEvents(
|
||||
accessToken,
|
||||
await getOrProvisionGoogleCalendarId(accessToken),
|
||||
range,
|
||||
refreshAccessToken,
|
||||
),
|
||||
toApiBody: googleApiEventBody,
|
||||
};
|
||||
|
||||
@@ -115,6 +142,8 @@ export const outlookLessonSyncProvider: CalendarLessonSyncProvider = {
|
||||
deleteOutlookCalendarEvent(accessToken, eventId, refreshAccessToken),
|
||||
upsertEvent: (accessToken, existingId, body, refreshAccessToken) =>
|
||||
upsertOutlookCalendarEvent(accessToken, existingId, body, refreshAccessToken),
|
||||
listSyncedEvents: (accessToken, range, refreshAccessToken) =>
|
||||
listOutlookSyncedEvents(accessToken, range, refreshAccessToken),
|
||||
toApiBody: outlookGraphEventBody,
|
||||
};
|
||||
|
||||
@@ -151,6 +180,28 @@ export async function syncLessonsToCalendar(
|
||||
});
|
||||
|
||||
const eventMap = { ...(state.eventMap ?? {}) };
|
||||
let accessToken = await getAccessToken();
|
||||
const refreshAccessToken = async () => {
|
||||
accessToken = await getAccessToken();
|
||||
return accessToken;
|
||||
};
|
||||
|
||||
const reconcileRange = reconcileRangeForMode(mode, weeksAhead, {
|
||||
syncWindowRange,
|
||||
trailingWeekRange,
|
||||
});
|
||||
|
||||
try {
|
||||
const remoteEvents = await provider.listSyncedEvents(
|
||||
accessToken,
|
||||
reconcileRange,
|
||||
refreshAccessToken,
|
||||
);
|
||||
mergeRemoteEventsIntoMap(eventMap, request.origin, remoteEvents, eventMapKey);
|
||||
} catch (err) {
|
||||
verboseLog(`[BetterSEQTA+] ${provider.label} remote event list failed:`, err);
|
||||
}
|
||||
|
||||
const currentMapKeys = new Set(events.map((event) => eventMapKey(request.origin, event.seqtaKey)));
|
||||
const staleEntries = entriesToPrune(eventMap, request.origin, mode, weeksAhead, currentMapKeys);
|
||||
const totalSteps = staleEntries.length + events.length;
|
||||
@@ -179,13 +230,8 @@ export async function syncLessonsToCalendar(
|
||||
initialFailed: staleResult.failed,
|
||||
getAccessToken,
|
||||
mapKey: eventMapKey,
|
||||
upsert: (accessToken, existingId, event, refreshAccessToken) =>
|
||||
provider.upsertEvent(
|
||||
accessToken,
|
||||
existingId,
|
||||
provider.toApiBody(event),
|
||||
refreshAccessToken,
|
||||
),
|
||||
upsert: (token, existingId, event, refresh) =>
|
||||
provider.upsertEvent(token, existingId, provider.toApiBody(event), refresh),
|
||||
writeState: provider.writeState,
|
||||
onProgress: options.onProgress,
|
||||
logLabel: provider.label,
|
||||
@@ -210,6 +256,7 @@ export async function syncLessonsToCalendar(
|
||||
upsertResult.created,
|
||||
upsertResult.updated,
|
||||
staleResult.deleted,
|
||||
upsertResult.skipped,
|
||||
upsertResult.failed,
|
||||
lastSyncAt,
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "@jest/globals";
|
||||
import {
|
||||
lessonToGoogleEvent,
|
||||
mapLessonsToGoogleEvents,
|
||||
outlookGraphEventBody,
|
||||
seqtaLessonKey,
|
||||
shouldSyncLesson,
|
||||
} from "./eventMapper";
|
||||
@@ -62,3 +63,14 @@ describe("mapLessonsToGoogleEvents", () => {
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("outlookGraphEventBody", () => {
|
||||
it("embeds Key: seqtaKey in the body for remote reconcile", () => {
|
||||
const event = lessonToGoogleEvent(ORIGIN, baseLesson, "Australia/Perth");
|
||||
expect(event).not.toBeNull();
|
||||
const body = outlookGraphEventBody(event!);
|
||||
const content = (body.body as { content: string }).content;
|
||||
expect(content).toContain(`Key: ${ORIGIN}:cal:12345`);
|
||||
expect(content).toContain("Synced by BetterSEQTA+");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 type { GoogleCalendarEventInput, SeqtaTimetableLesson } from "./types";
|
||||
|
||||
const SKIP_TYPES = new Set(["note", "holiday", "assembly-note"]);
|
||||
@@ -100,7 +101,7 @@ export function outlookGraphEventBody(event: GoogleCalendarEventInput): Record<s
|
||||
subject: event.summary,
|
||||
body: {
|
||||
contentType: "text",
|
||||
content: event.description ?? "Synced by BetterSEQTA+",
|
||||
content: outlookDescriptionWithKey(event.description, event.seqtaKey),
|
||||
},
|
||||
start: { dateTime: event.startDateTime, timeZone: event.timeZone },
|
||||
end: { dateTime: event.endDateTime, timeZone: event.timeZone },
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { SeqtaTimetableLesson } from "./types";
|
||||
|
||||
jest.mock("@/config/googleCalendar", () => ({
|
||||
isGoogleCalendarConfigured: jest.fn(() => true),
|
||||
BSPLUS_GOOGLE_CALENDAR_EVENT_PROP: "bsplusSeqtaKey",
|
||||
GOOGLE_CALENDAR_API: "https://www.googleapis.com/calendar/v3",
|
||||
}));
|
||||
|
||||
jest.mock("@/utils/verboseLog", () => ({
|
||||
@@ -27,14 +29,21 @@ jest.mock("@/seqta/utils/calendarSync/settings", () => ({
|
||||
jest.mock("@/seqta/utils/calendarSync/remoteEvents", () => ({
|
||||
upsertGoogleCalendarEvent: jest.fn(),
|
||||
deleteGoogleCalendarEvent: jest.fn(),
|
||||
listGoogleSyncedEvents: jest.fn(async () => []),
|
||||
}));
|
||||
|
||||
import { readGoogleCalendarState } from "@/seqta/utils/googleCalendar/storage";
|
||||
import {
|
||||
deleteGoogleCalendarEvent,
|
||||
listGoogleSyncedEvents,
|
||||
upsertGoogleCalendarEvent,
|
||||
} from "@/seqta/utils/calendarSync/remoteEvents";
|
||||
import { deleteSyncedEventsFromGoogleCalendar, syncLessonsToGoogleCalendar } from "@/seqta/utils/calendarSync/syncEngine";
|
||||
import {
|
||||
deleteSyncedEventsFromGoogleCalendar,
|
||||
syncLessonsToGoogleCalendar,
|
||||
} from "@/seqta/utils/calendarSync/syncEngine";
|
||||
import { eventFingerprint } from "@/seqta/utils/calendarSync/eventFingerprint";
|
||||
import { lessonToGoogleEvent } from "@/seqta/utils/googleCalendar/eventMapper";
|
||||
|
||||
import { syncWindowRange } from "@/seqta/utils/googleCalendar/syncDateRange";
|
||||
|
||||
@@ -67,6 +76,7 @@ describe("syncLessonsToGoogleCalendar", () => {
|
||||
});
|
||||
jest.mocked(upsertGoogleCalendarEvent).mockResolvedValue("google-existing");
|
||||
jest.mocked(deleteGoogleCalendarEvent).mockResolvedValue(undefined);
|
||||
jest.mocked(listGoogleSyncedEvents).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("updates existing events and removes stale tracked events on full sync", async () => {
|
||||
@@ -75,6 +85,7 @@ describe("syncLessonsToGoogleCalendar", () => {
|
||||
getAccessToken,
|
||||
);
|
||||
|
||||
expect(listGoogleSyncedEvents).toHaveBeenCalled();
|
||||
expect(deleteGoogleCalendarEvent).toHaveBeenCalled();
|
||||
expect(upsertGoogleCalendarEvent).toHaveBeenCalledTimes(1);
|
||||
expect(result).toMatchObject({
|
||||
@@ -107,6 +118,64 @@ describe("syncLessonsToGoogleCalendar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers remote IDs when the local map is empty and skips unchanged", async () => {
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
const mapped = lessonToGoogleEvent(ORIGIN, baseLesson, timeZone);
|
||||
expect(mapped).not.toBeNull();
|
||||
const fp = eventFingerprint(mapped!);
|
||||
|
||||
jest.mocked(readGoogleCalendarState).mockResolvedValue({
|
||||
refreshToken: "refresh",
|
||||
calendarId: "app-calendar-id",
|
||||
eventMap: {},
|
||||
});
|
||||
jest.mocked(listGoogleSyncedEvents).mockResolvedValue([
|
||||
{
|
||||
seqtaKey: `${ORIGIN}:cal:12345`,
|
||||
id: "recovered-id",
|
||||
date: syncDate,
|
||||
fingerprint: fp,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await syncLessonsToGoogleCalendar(
|
||||
{ origin: ORIGIN, lessons: [baseLesson], mode: "full" },
|
||||
getAccessToken,
|
||||
);
|
||||
|
||||
expect(upsertGoogleCalendarEvent).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
skipped: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes cancelled lessons that remain inside the sync window", async () => {
|
||||
jest.mocked(readGoogleCalendarState).mockResolvedValue({
|
||||
refreshToken: "refresh",
|
||||
calendarId: "app-calendar-id",
|
||||
eventMap: {
|
||||
[`${ORIGIN}::${ORIGIN}:cal:12345`]: { id: "keep", date: syncDate },
|
||||
[`${ORIGIN}::${ORIGIN}:cal:cancelled`]: { id: "gone", date: syncDate },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await syncLessonsToGoogleCalendar(
|
||||
{ origin: ORIGIN, lessons: [baseLesson], mode: "full" },
|
||||
getAccessToken,
|
||||
);
|
||||
|
||||
expect(deleteGoogleCalendarEvent).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
"app-calendar-id",
|
||||
"gone",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(result.deleted).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("does not delete events during incremental sync", async () => {
|
||||
const result = await syncLessonsToGoogleCalendar(
|
||||
{ origin: ORIGIN, lessons: [baseLesson], mode: "incremental" },
|
||||
|
||||
@@ -5,8 +5,12 @@ global.fetch = mockFetch as typeof fetch;
|
||||
|
||||
import {
|
||||
deleteOutlookCalendarEvent,
|
||||
listGoogleSyncedEvents,
|
||||
listOutlookSyncedEvents,
|
||||
upsertOutlookCalendarEvent,
|
||||
} from "@/seqta/utils/calendarSync/remoteEvents";
|
||||
import { BSPLUS_GOOGLE_CALENDAR_EVENT_PROP } from "@/config/googleCalendar";
|
||||
import { BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY } from "@/config/outlookCalendar";
|
||||
|
||||
describe("outlook calendar remote events", () => {
|
||||
beforeEach(() => {
|
||||
@@ -28,4 +32,101 @@ describe("outlook calendar remote events", () => {
|
||||
|
||||
await expect(deleteOutlookCalendarEvent("token", "evt-1")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("lists Outlook synced events and paginates", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
value: [
|
||||
{
|
||||
id: "o1",
|
||||
subject: "Math",
|
||||
body: { content: "Synced by BetterSEQTA+\nKey: https://school.seqta.com.au:cal:1" },
|
||||
start: { dateTime: "2026-07-13T09:00:00", timeZone: "UTC" },
|
||||
end: { dateTime: "2026-07-13T10:00:00", timeZone: "UTC" },
|
||||
categories: [BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY],
|
||||
},
|
||||
],
|
||||
"@odata.nextLink": "https://graph.microsoft.com/v1.0/me/calendarView?$skiptoken=2",
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
value: [
|
||||
{
|
||||
id: "o2",
|
||||
subject: "English",
|
||||
body: { content: "Synced by BetterSEQTA+\nKey: https://school.seqta.com.au:cal:2" },
|
||||
start: { dateTime: "2026-07-14T09:00:00", timeZone: "UTC" },
|
||||
end: { dateTime: "2026-07-14T10:00:00", timeZone: "UTC" },
|
||||
categories: [BSPLUS_OUTLOOK_CALENDAR_EVENT_CATEGORY],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const events = await listOutlookSyncedEvents("token", {
|
||||
from: "2026-07-13",
|
||||
until: "2026-07-20",
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toMatchObject({
|
||||
id: "o1",
|
||||
seqtaKey: "https://school.seqta.com.au:cal:1",
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("google calendar remote list", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
it("lists Google synced events using private extended properties", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
id: "g1",
|
||||
summary: "Math",
|
||||
description: "Synced by BetterSEQTA+",
|
||||
start: { dateTime: "2026-07-13T09:00:00", timeZone: "UTC" },
|
||||
end: { dateTime: "2026-07-13T10:00:00", timeZone: "UTC" },
|
||||
extendedProperties: {
|
||||
private: { [BSPLUS_GOOGLE_CALENDAR_EVENT_PROP]: "https://school.seqta.com.au:cal:1" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "g-skip",
|
||||
summary: "Unrelated",
|
||||
start: { dateTime: "2026-07-13T11:00:00", timeZone: "UTC" },
|
||||
end: { dateTime: "2026-07-13T12:00:00", timeZone: "UTC" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const events = await listGoogleSyncedEvents("token", "cal-id", {
|
||||
from: "2026-07-13",
|
||||
until: "2026-07-20",
|
||||
});
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
id: "g1",
|
||||
seqtaKey: "https://school.seqta.com.au:cal:1",
|
||||
date: "2026-07-13",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user