Something went wrong. Try again.
the everything app :D cedarplan.dunkirk.sh
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371/** * Runs on selfservice.cedarville.edu and does nothing but fetch. * * Every request rides the student's existing session, so no credential is * collected, stored or transmitted. Interpretation happens in the app; this * side stays small enough to read in one sitting, which is the point of * asking anyone to install it. */
import { SelfService, UnauthorizedError } from "./client";import type { SearchCriteria, SearchPage } from "./crawl";import { normalize, programFor, unservedCredentials } from "./requirements";import type { Change, PlannedCourse } from "./sync";import type { CatalogVocabulary, DegreePlanDto, DegreePlanResponse, DegreePlanView, EvaluationResponse, ProgramSummary,} from "./types";
const api = new SelfService();
export interface Capture { capturedAt: string; studentId: string; enrolled: { code: string; title: string }[]; /** * Majors and minors the registrar names that no active program code matches, * so nothing evaluated them. Always present, empty when there are none: a * capture missing the field came from an older build of the extension, which * is worth being able to tell apart from a capture that found nothing. */ unmatched: string[]; /** Raw evaluations, keyed by program code. The app normalizes them. */ evaluations: Record<string, EvaluationResponse>;}
/** Learned from the first plan this tab reads, and the same thereafter. */let knownId: string | null = null;
/** * The degree plan, and the student id it carries. * * `Current` is the slowest thing Self-Service does: well over a minute on a * busy morning. Asking it once for the id and again for the plan, as this * used to, made a preview two of those back to back; with no id it answers * for whoever is signed in, so one call is both. */async function currentPlan(): Promise<{ id: string; response: DegreePlanResponse }> { const fromDom = document.querySelector<HTMLElement>("[data-person-id]")?.dataset.personId; const response = await api.currentDegreePlan(knownId ?? fromDom ?? ""); const id = response.DegreePlan?.PersonId ?? response.StudentPrograms?.[0]?.StudentId ?? fromDom ?? null; if (!id) throw new Error("could not determine student id"); knownId = id; return { id, response };}
/** * @param whatIf program codes the student is not enrolled in, evaluated * against their real transcript. */async function capture(whatIf: string[] = []): Promise<Capture> { const { id, response: plan } = await currentPlan(); const enrolled = plan.StudentPrograms.map((p) => p.Code);
const evaluations: Record<string, EvaluationResponse> = {}; for (const code of enrolled) { evaluations[code] = await api.programEvaluation(id, code, false); }
// A second major is recorded against the first one's enrolment and never // evaluated alongside it, so it has to be asked for by name. What comes back // is a what-if by mechanism only; the registrar has the student in it, so it // is reported as enrolled. const also: { code: string; title: string }[] = []; const unmatched: string[] = []; const unserved = new Map( Object.values(evaluations) .map(normalize) .flatMap((tree) => unservedCredentials(tree).map((name) => [name, tree] as const)), ); if (unserved.size) { const catalog = await api.activePrograms(); for (const [name, tree] of unserved) { const program = programFor(name, catalog, tree); if (!program) { unmatched.push(name); continue; } if (evaluations[program.Code]) continue; evaluations[program.Code] = await api.programEvaluation(id, program.Code, true); also.push({ code: program.Code, title: program.Title }); } }
for (const code of whatIf) { if (!evaluations[code]) { evaluations[code] = await api.programEvaluation(id, code, true); } }
return { capturedAt: new Date().toISOString(), studentId: id, enrolled: [...plan.StudentPrograms.map((p) => ({ code: p.Code, title: p.Title })), ...also], unmatched, evaluations, };}
/** Colleague's own plan, as the diff wants to see it. */export interface ColleaguePlan { studentId: string; planned: PlannedCourse[]; /** Terms already on the plan. */ terms: string[]; /** Terms it could be extended with, which is where the summers are. */ addable: string[]; /** A protected plan is an advisor's, and this refuses to touch one. */ locked: boolean;}
const codesOf = (terms: { Code: string }[] | string[] | undefined): string[] => (terms ?? []).map((t) => (typeof t === "string" ? t : t.Code));
function readPlan(response: DegreePlanResponse, studentId: string): ColleaguePlan { const plan = response.DegreePlan; const dto = plan.DegreePlanDto; // Sections the page itself draws as planned and nothing more. Anything it // does not vouch for is treated as registered, which is the safe mistake. const onlyPlanned = new Set( (plan.Terms ?? []).flatMap((term) => (term.PlannedCourses ?? []) .filter((c) => c.Section?.Id && c.IsPlanned && !c.HasRegisteredSection && !c.IsWaitlisted) .map((c) => `${c.Section?.Id}@${c.TermCode}`), ), ); return { studentId, planned: (dto.Terms ?? []).flatMap((term) => (term.PlannedCourses ?? []).map((c) => ({ courseId: c.CourseId, termId: c.TermId, credits: c.Credits, sectionId: c.SectionId, ...(c.IsProtected ? { isProtected: true } : {}), ...(c.SectionId ? { registered: !onlyPlanned.has(`${c.SectionId}@${c.TermId}`) } : {}), ...(c.GradingType ? { gradingType: c.GradingType } : {}), })), ), terms: (plan.Terms ?? []).map((t) => t.Code), addable: codesOf(plan.UnplannedTerms), locked: Boolean(plan.IsPlanProtected), };}
/** * Runs the changes the app decided on, in order, against a plan fetched here. * * The DTO is never taken from the app: Colleague versions the plan and * refuses a stale copy, so the freshest one is the one this just fetched and * then the one each call hands back. Stops at the first failure and says how * far it got, because a half-applied plan the student is told about is a * great deal better than one they are not. *//** * The plan as of this tab's last write. * * Colleague versions the plan and refuses a stale copy, so each write must * carry what the one before it returned. The app sends changes one at a time * — that is the only way it can show progress across thirty round trips — * so the thread between them is kept here rather than re-fetched thirty * times. Cleared on any failure, so the next attempt starts from the truth. */let carried: DegreePlanDto | null = null;
/** One change, as the endpoint that makes it. */function write(change: Change, dto: DegreePlanDto): Promise<DegreePlanView> { switch (change.kind) { case "term": return api.addTermToPlan(change.termId, dto); case "add": return api.addCourseToPlan(change.courseId, change.termId, change.credits, dto); case "move": return api.moveCourseOnPlan(change.courseId, change.from, change.to, dto); case "remove": return api.removeCourseFromPlan(change.courseId, change.termId, change.sectionId, dto); case "section": return api.addSectionToPlan( change.courseId, change.termId, change.sectionId, change.gradingType, change.credits, dto, ); case "unsection": return api.removeSectionFromPlan(change.sectionId, dto); }}
async function applyPlan(changes: Change[]): Promise<Applied> { if (!carried) { const { response } = await currentPlan(); if (response.DegreePlan.IsPlanProtected) { throw new Error("your degree plan is protected; an advisor has to unlock it"); } carried = response.DegreePlan.DegreePlanDto; }
const done: Change[] = []; for (const change of changes) { try { const view = await write(change, carried); carried = view.DegreePlanDto; done.push(change); } catch (err) { // Whatever went wrong, this tab's copy of the plan is now a guess. carried = null; return { applied: done, stopped: { change, why: err instanceof Error ? err.message : String(err) }, }; } } return { applied: done };}
export interface Applied { applied: Change[]; /** The change that failed, and why. Everything after it went untried. */ stopped?: { change: Change; why: string };}
export type Request = | { type: "ping" } /** * What build of the extension is answering. * * The background answers this one itself, so it works with no Self-Service * tab open at all — which is the point, since the commonest reason a * capture fails is an extension older than the page asking it. A page that * can read the version can say so instead of failing in a way that reads * as the planner being broken. */ | { type: "version" } | { type: "programs" } | { type: "terms" } | { type: "capture"; whatIf?: string[] } /** Colleague's degree plan, read only, for working out what would change. */ | { type: "colleaguePlan" } /** * The one request in this file that writes to the registrar's system. The * app decides what the changes are; this only carries them out. */ | { type: "applyPlan"; changes: Change[] } /** * The same, with sections among the changes. A request of its own rather * than more kinds under `applyPlan`, because an older content script reads * any kind it does not know as a removal; asked this, it says it is stale. */ | { type: "applySections"; changes: Change[] } /** * One page of the course catalog, read on the student's session. * * Cedarville put the public catalog behind SSO, so a signed-in browser is * the only thing left that can read a timetable at all. This is a read of * data that is identical for every student: it carries no transcript, and * what comes back is the same page the "Search for courses" screen shows. * * One page per message rather than a whole term, so the paging, the delay * between requests and the abort all stay on the page side where the * student can see a progress bar and stop it. */ | { type: "search"; criteria: SearchCriteria } /** * What the student chose, on its way to their own machine. Never reaches * this file: the background answers it without troubling Self-Service, * which has no opinion about anybody's pins. */ | { type: "picks"; picks: unknown };
export type Reply<T> = | { ok: true; data: T } | { ok: false; error: string; signedOut?: boolean; /** This tab is running a content script older than the page asking it. */ stale?: boolean; };
export interface ReplyMap { ping: true; version: string; picks: true; programs: ProgramSummary[]; terms: { code: string; description: string }[]; capture: Capture; colleaguePlan: ColleaguePlan; applyPlan: Applied; applySections: Applied; search: SearchPage;}
chrome.runtime.onMessage.addListener((msg: Request, _sender, reply) => { const run = async (): Promise<Reply<unknown>> => { try { switch (msg.type) { case "ping": await api.token(); return { ok: true, data: true }; case "programs": return { ok: true, data: await api.activePrograms() }; case "terms": { const vocabulary: CatalogVocabulary = await api.catalogVocabulary(); // Tuples arrive as Item1 = code, Item2 = description. const terms = (vocabulary.Terms ?? []).map((t) => ({ code: t.Item1, description: t.Item2, })); return { ok: true, data: terms }; } case "capture": return { ok: true, data: await capture(msg.whatIf) }; case "colleaguePlan": { const { id, response } = await currentPlan(); // The plan just read is the freshest there is, so the writes that // usually follow a preview start from it rather than reading again. carried = response.DegreePlan.DegreePlanDto; return { ok: true, data: readPlan(response, id) }; } case "applyPlan": case "applySections": return { ok: true, data: await applyPlan(msg.changes) }; case "search": return { ok: true, data: await api.search(msg.criteria) }; default: /* * A request this build has never heard of, which in practice means * one thing: the page has been rebuilt and this tab is still running * the content script it loaded this morning. Naming the message type * told the student nothing; naming the remedy tells them everything. * * ("picks" also lands in this branch and never gets here — the * background answers it without troubling Self-Service.) */ return { ok: false, error: "this Self-Service tab is running an older build of the extension. " + "Reload the extension at chrome://extensions, then reload this tab.", stale: true, }; } } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err), signedOut: err instanceof UnauthorizedError, }; } };
run().then(reply); return true; // keep the channel open for the async reply});