From 4fd4bd44b24a733244392ffa6828464505118f19 Mon Sep 17 00:00:00 2001 From: andrewmuratov Date: Sun, 27 Sep 2026 12:25:28 -0400 Subject: [PATCH] chore: publish canonical CLI with portable tests and CI --- .github/workflows/ci.yml | 21 + .gitignore | 6 + CONTRIBUTING.md | 9 + README.md | 16 +- SECURITY.md | 5 + package.json | 8 + tests/cli.test.mjs | 25 +- .../schemas/universities/campus.schema.json | 23 + .../gapwise/src/data/campuses/index.ts | 573 ++++++++++++++++++ .../src/universities/timetable-adapters.ts | 133 ++++ tests/fixtures/gapwise/universities.json | 203 +++++++ 11 files changed, 1001 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 tests/fixtures/data/schemas/universities/campus.schema.json create mode 100644 tests/fixtures/gapwise/src/data/campuses/index.ts create mode 100644 tests/fixtures/gapwise/src/universities/timetable-adapters.ts create mode 100644 tests/fixtures/gapwise/universities.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0fa17a9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + - run: npm test + - run: npm pack --dry-run + - run: git diff --check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc214af --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +coverage/ +*.tgz +.DS_Store +.env +.env.* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..29836d0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,9 @@ +# Contributing to Gapwise CLI + +Gapwise CLI helps maintain university integrations across the [Gapwise ecosystem](https://github.com/GapwiseHQ). Open a focused pull request with the related issue, the campus data sources, and the checks you ran. + +## Local checks + +Requires Node.js 24 or newer. Run `npm test`, `npm pack --dry-run`, and `git diff --check` before opening a pull request. The tests use a small, committed workspace fixture so they run without sibling repositories. Also try the command against current `gapwise` and `data` checkouts when changing scaffolding or validation behavior. + +Keep generated data empty until sources have been reviewed. Do not invent building identities, entrances, routes, or accessibility facts. Report vulnerabilities privately through [SECURITY.md](SECURITY.md). diff --git a/README.md b/README.md index 7e9dfa6..766aab1 100644 --- a/README.md +++ b/README.md @@ -7,29 +7,29 @@ ### Repeatable university integrations for one Gapwise product. [![MIT](https://img.shields.io/badge/License-MIT-111111?style=for-the-badge)](LICENSE) +[![CI](https://github.com/GapwiseHQ/cli/actions/workflows/ci.yml/badge.svg)](https://github.com/GapwiseHQ/cli/actions/workflows/ci.yml) ## Purpose -The CLI scaffolds a university manifest entry, timetable adapter, test, and empty campus data files in the sibling `gapwise` and `data` repositories. It also creates the app's empty campus mirror and map catalog so the generic campus loader discovers the new ID. It never copies application screens. Generated data contains no invented buildings, entrances, or routes. +The CLI scaffolds a university manifest entry, timetable adapter, test, and empty campus data files in the sibling [`gapwise`](https://github.com/GapwiseHQ/gapwise) and [`data`](https://github.com/GapwiseHQ/data) repositories. It also creates the app's empty campus mirror and map catalog so the generic campus loader discovers the new ID. It never copies application screens. Generated data contains no invented buildings, entrances, or routes. ## Installation -Requires Node 24, Bun 1.3.14 for application tests, and sibling `gapwise` and `data` checkouts. +Requires Node 24, Bun 1.3.14 for application tests, and sibling `gapwise` and `data` checkouts. The canonical CLI source is [GapwiseHQ/cli](https://github.com/GapwiseHQ/cli). Install directly from that repository: ```sh -npm install -g . -# Or run without global installation: -node bin/gapwise.mjs university create example-university --dry-run +npm install -g github:GapwiseHQ/cli +gapwise university create example-university --dry-run ``` -Set `GAPWISE_WORKSPACE` or pass `--workspace /path/to/workspace` if the repositories are elsewhere. +To work on the CLI itself, clone this repository and run `npm test` or `node bin/gapwise.mjs`. By default, commands expect `cli`, `gapwise`, and `data` as sibling directories. Set `GAPWISE_WORKSPACE` or pass `--workspace /path/to/workspace` to point to their parent directory. ## Usage ```sh -gapwise university create tmu --name "Toronto Metropolitan University" --short-name TMU +gapwise university create example-university --name "Example University" --short-name Example --dry-run gapwise university validate carleton gapwise university test carleton gapwise university dev carleton @@ -54,7 +54,7 @@ node bin/gapwise.mjs university create example-university --dry-run ## Contributing -Use a focused PR, cite data sources and their redistribution terms, and keep unknown access facts unknown. See the Gapwise contributor guide in `gapwise`. +Use a focused PR, cite data sources and their redistribution terms, and keep unknown access facts unknown. See [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), and the [Gapwise documentation](https://docs.gapwise.ca). ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5263d75 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Security Policy + +Security fixes target the latest `main` branch. Report suspected vulnerabilities privately to `security@gapwise.ca` or through GitHub private vulnerability reporting for this repository when available. The public policy is at [gapwise.ca/security](https://gapwise.ca/security). + +Include reproduction steps using test data and the expected impact. Do not put credentials, private timetables, tokens, or exploit details in a public issue. Do not access another person's data or disrupt production services while testing. diff --git a/package.json b/package.json index b5d63bd..3d290c2 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,16 @@ { "name": "@gapwise/cli", "version": "0.1.0", + "description": "University integration and campus data tooling for Gapwise", "type": "module", "license": "MIT", + "homepage": "https://docs.gapwise.ca", + "repository": { + "type": "git", + "url": "git+https://github.com/GapwiseHQ/cli.git" + }, + "bugs": { "url": "https://github.com/GapwiseHQ/cli/issues" }, + "keywords": ["gapwise", "university", "campus", "timetable", "cli"], "bin": { "gapwise": "./bin/gapwise.mjs" }, "scripts": { "test": "node --test tests/*.test.mjs" }, "engines": { "node": ">=24" }, diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index 0562e24..ee28f31 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -6,7 +6,7 @@ import { dirname, join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; import { universityPlan } from '../bin/gapwise.mjs'; -const realWorkspace = resolve(import.meta.dirname, '../..'); +const fixtureSource = resolve(import.meta.dirname, 'fixtures'); function fixture() { const workspace = mkdtempSync(join(tmpdir(), 'gapwise-cli-')); for (const file of [ @@ -17,7 +17,7 @@ function fixture() { ]) { const target = join(workspace, file); mkdirSync(dirname(target), { recursive: true }); - writeFileSync(target, readFileSync(join(realWorkspace, file))); + writeFileSync(target, readFileSync(join(fixtureSource, file))); } return workspace; } @@ -39,19 +39,19 @@ test('dry run shows integration and data files without changing the workspace', test('scaffold creates adapter, manifest, test, and empty campus snapshots', () => { const workspace = fixture(); try { - const plan = universityPlan('mcmaster', workspace, ['--name', 'McMaster University', '--short-name', 'McMaster']); + const plan = universityPlan('sample-university', workspace, ['--name', 'Sample University', '--short-name', 'Sample']); assert.equal(plan.length, 11); assert.ok(plan.every(([path]) => !path.includes('/src/components/') && !path.includes('/src/routes/'))); - const result = spawnSync('node', ['bin/gapwise.mjs', 'university', 'create', 'mcmaster', '--name', 'McMaster University', '--short-name', 'McMaster', '--workspace', workspace], + const result = spawnSync('node', ['bin/gapwise.mjs', 'university', 'create', 'sample-university', '--name', 'Sample University', '--short-name', 'Sample', '--workspace', workspace], { cwd: resolve(import.meta.dirname, '..'), encoding: 'utf8' }); assert.equal(result.status, 0, result.stderr); const manifest = JSON.parse(readFileSync(join(workspace, 'gapwise/universities.json'))); - assert.equal(manifest.universities.at(-1).name, 'McMaster University'); + assert.equal(manifest.universities.at(-1).name, 'Sample University'); assert.equal(manifest.universities.at(-1).status, 'scaffold'); - assert.deepEqual(JSON.parse(readFileSync(join(workspace, 'data/universities/mcmaster/campus.json'))).entrances, []); - assert.deepEqual(JSON.parse(readFileSync(join(workspace, 'gapwise/src/data/campuses/mcmaster/catalog.json'))).buildings, []); - assert.match(readFileSync(join(workspace, 'gapwise/src/data/campuses/index.ts'), 'utf8'), /"mcmaster": mcmasterCatalogRaw/); - assert.deepEqual(readdirSync(join(workspace, 'gapwise/src/universities/mcmaster')).sort(), ['adapter.test.ts', 'adapter.ts', 'demo-timetable.ts']); + assert.deepEqual(JSON.parse(readFileSync(join(workspace, 'data/universities/sample-university/campus.json'))).entrances, []); + assert.deepEqual(JSON.parse(readFileSync(join(workspace, 'gapwise/src/data/campuses/sample-university/catalog.json'))).buildings, []); + assert.match(readFileSync(join(workspace, 'gapwise/src/data/campuses/index.ts'), 'utf8'), /"sample-university": sample_universityCatalogRaw/); + assert.deepEqual(readdirSync(join(workspace, 'gapwise/src/universities/sample-university')).sort(), ['adapter.test.ts', 'adapter.ts', 'demo-timetable.ts']); } finally { rmSync(workspace, { recursive: true, force: true }); } }); @@ -73,15 +73,14 @@ test('university validate detects scaffold state and unknown universities', () = assert.notEqual(unknown.status, 0); assert.match(unknown.stderr, /nonexistent is not registered/); - // Scaffold mcmaster - spawnSync('node', ['bin/gapwise.mjs', 'university', 'create', 'mcmaster', '--workspace', workspace], + // Scaffold an unregistered institution + spawnSync('node', ['bin/gapwise.mjs', 'university', 'create', 'sample-university', '--workspace', workspace], { cwd: resolve(import.meta.dirname, '..'), encoding: 'utf8' }); // Validating unactivated scaffold fails - const scaffoldCheck = spawnSync('node', ['bin/gapwise.mjs', 'university', 'validate', 'mcmaster', '--workspace', workspace], + const scaffoldCheck = spawnSync('node', ['bin/gapwise.mjs', 'university', 'validate', 'sample-university', '--workspace', workspace], { cwd: resolve(import.meta.dirname, '..'), encoding: 'utf8' }); assert.notEqual(scaffoldCheck.status, 0); assert.match(scaffoldCheck.stderr, /still a scaffold/); } finally { rmSync(workspace, { recursive: true, force: true }); } }); - diff --git a/tests/fixtures/data/schemas/universities/campus.schema.json b/tests/fixtures/data/schemas/universities/campus.schema.json new file mode 100644 index 0000000..c98dbc6 --- /dev/null +++ b/tests/fixtures/data/schemas/universities/campus.schema.json @@ -0,0 +1,23 @@ +{ + "$schema":"http://json-schema.org/draft-07/schema#", + "type":"object","additionalProperties":false, + "required":["schemaVersion","institution","campus","sources","buildings","entrances","pathNodes","pathEdges"], + "properties":{ + "schemaVersion":{"const":1},"institution":{"type":"string","pattern":"^[a-z][a-z0-9-]*$"}, + "campus":{"type":"object","additionalProperties":false,"required":["id","name","bounds"],"properties":{"id":{"type":"string","minLength":1},"name":{"type":"string","minLength":1},"bounds":{"anyOf":[{"type":"null"},{"type":"array","items":{"$ref":"#/$defs/coordinate"},"minItems":2,"maxItems":2}]}}}, + "sources":{"type":"array","items":{"$ref":"#/$defs/source"}}, + "buildings":{"type":"array","items":{"$ref":"#/$defs/building"}}, + "entrances":{"type":"array","items":{"$ref":"#/$defs/entrance"}}, + "pathNodes":{"type":"array","items":{"$ref":"#/$defs/node"}}, + "pathEdges":{"type":"array","items":{"$ref":"#/$defs/edge"}} + }, + "$defs":{ + "coordinate":{"type":"array","items":[{"type":"number","minimum":-180,"maximum":180},{"type":"number","minimum":-90,"maximum":90}],"additionalItems":false}, + "source":{"type":"object","additionalProperties":false,"required":["id","title","url","retrievedAt","licenseOrTerms","redistribution","transformation"],"properties":{"id":{"type":"string","minLength":1},"title":{"type":"string","minLength":1},"url":{"type":"string","format":"uri"},"retrievedAt":{"type":"string","format":"date"},"licenseOrTerms":{"type":"string","minLength":1},"redistribution":{"enum":["permitted","unclear","prohibited"]},"transformation":{"type":"string","minLength":1},"attribution":{"type":"string"}}}, + "provenance":{"type":"object","additionalProperties":false,"required":["sourceId","nativeId","verification"],"properties":{"sourceId":{"type":"string","minLength":1},"nativeId":{"type":["string","null"]},"verification":{"enum":["source-backed","field-reviewed","inferred"]}}}, + "building":{"type":"object","additionalProperties":false,"required":["id","name","nativeCodes","aliases","geometry","provenance"],"properties":{"id":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]*$"},"name":{"type":"string","minLength":1},"nativeCodes":{"type":"array","items":{"type":"string","minLength":1}},"aliases":{"type":"array","items":{"type":"string","minLength":1}},"category":{"enum":["academic","residence","facility"]},"geometry":{"anyOf":[{"type":"null"},{"type":"object","additionalProperties":false,"required":["type","coordinates"],"properties":{"type":{"enum":["Polygon","MultiPolygon"]},"coordinates":{"type":"array","minItems":1}}}]},"provenance":{"type":"array","minItems":1,"items":{"$ref":"#/$defs/provenance"}}}}, + "entrance":{"type":"object","additionalProperties":false,"required":["id","buildingId","coordinate","pathNodeId","access","provenance"],"properties":{"id":{"type":"string","minLength":1},"buildingId":{"type":"string","minLength":1},"coordinate":{"$ref":"#/$defs/coordinate"},"pathNodeId":{"type":"string","minLength":1},"access":{"enum":["public","restricted","unknown"]},"provenance":{"type":"array","minItems":1,"items":{"$ref":"#/$defs/provenance"}}}}, + "node":{"type":"object","additionalProperties":false,"required":["id","coordinate","provenance"],"properties":{"id":{"type":"string","minLength":1},"coordinate":{"$ref":"#/$defs/coordinate"},"provenance":{"type":"array","minItems":1,"items":{"$ref":"#/$defs/provenance"}}}}, + "edge":{"type":"object","additionalProperties":false,"required":["id","from","to","mode","provenance"],"properties":{"id":{"type":"string","minLength":1},"from":{"type":"string","minLength":1},"to":{"type":"string","minLength":1},"mode":{"enum":["outdoor-walk","tunnel-walk"]},"provenance":{"type":"array","minItems":1,"items":{"$ref":"#/$defs/provenance"}}}} + } +} diff --git a/tests/fixtures/gapwise/src/data/campuses/index.ts b/tests/fixtures/gapwise/src/data/campuses/index.ts new file mode 100644 index 0000000..8f20a43 --- /dev/null +++ b/tests/fixtures/gapwise/src/data/campuses/index.ts @@ -0,0 +1,573 @@ +import type { BuildingConfiguration } from "../utm/building-registry.js"; +import { getCampusBuilding, type BuildingEntrance } from "../utm/routing-buildings.js"; +import { UTM_BUILDINGS } from "../utm/building-registry.js"; +import { + CAMPUS_BUILDING_FOOTPRINTS as UTM_FOOTPRINTS, + buildingCodeAtCoordinate as utmBuildingCodeAtCoordinate, + getCampusBuildingFootprint as getUtmBuildingFootprint, + representativePointForFootprint as representativeUtmPoint, + type FootprintCoordinate, +} from "../utm/building-footprints.js"; +import type { Campus } from "../../lib/timetable-types.js"; +import manifest from "../../../universities.json" with { type: "json" }; +import utsgBuildingsRaw from "./utsg/buildings.json?raw"; +import utsgFootprintsRaw from "./utsg/buildings.geojson?raw"; +import utscBuildingsRaw from "./utsc/buildings.json?raw"; +import utscFootprintsRaw from "./utsc/buildings.geojson?raw"; +import carletonCatalogRaw from "./carleton/catalog.json?raw"; +import tmuCatalogRaw from "./tmu/catalog.json?raw"; +import queensCatalogRaw from "./queens/catalog.json?raw"; +import laurierCatalogRaw from "./laurier/catalog.json?raw"; +import yorkCatalogRaw from "./york/catalog.json?raw"; +import mcmasterCatalogRaw from "./mcmaster/catalog.json?raw"; +import westernCatalogRaw from "./western/catalog.json?raw"; +import guelphCatalogRaw from "./guelph/catalog.json?raw"; +import uottawaCatalogRaw from "./uottawa/catalog.json?raw"; +import brockCatalogRaw from "./brock/catalog.json?raw"; + +const universityCatalogRaw: Record = { + carleton: carletonCatalogRaw, + tmu: tmuCatalogRaw, + queens: queensCatalogRaw, + waterloo: laurierCatalogRaw, + laurier: laurierCatalogRaw, + keele: yorkCatalogRaw, + york: yorkCatalogRaw, + main: mcmasterCatalogRaw, + mcmaster: mcmasterCatalogRaw, + western: westernCatalogRaw, + guelph: guelphCatalogRaw, + uottawa: uottawaCatalogRaw, + brock: brockCatalogRaw, + // GAPWISE_CAMPUS_CATALOG_REGISTRY: the CLI inserts new catalog imports here. +}; + +export type GapwiseCampusId = string; + +type ExternalBuildingRecord = { + id: string; + campus: string; + code: string; + name: string; + category: "academic" | "residence" | "facility"; + aliases?: string[]; + timetableCodes?: string[]; + facilityCodes?: string[]; + status?: string; +}; + +type ExternalRegistry = { + campus: string; + generatedAt: string; + buildings: ExternalBuildingRecord[]; +}; + +export type CampusFootprintGeometry = + | { type: "Polygon"; coordinates: FootprintCoordinate[][] } + | { type: "MultiPolygon"; coordinates: FootprintCoordinate[][][] }; + +export type CampusFootprintFeature = { + type: "Feature"; + id?: string; + properties: { + campus?: GapwiseCampusId; + buildingId?: string; + buildingCode: string; + name: string; + timetableCodes?: string[]; + facilityCodes?: string[]; + verificationStatus?: string; + [key: string]: unknown; + }; + geometry: CampusFootprintGeometry; +}; + +export type CampusFootprintCollection = { + type: "FeatureCollection"; + features: CampusFootprintFeature[]; + [key: string]: unknown; +}; + +const utsgRegistry = JSON.parse(utsgBuildingsRaw) as ExternalRegistry; +const utscRegistry = JSON.parse(utscBuildingsRaw) as ExternalRegistry; +const utsgFootprints = JSON.parse(utsgFootprintsRaw) as CampusFootprintCollection; +const utscFootprints = JSON.parse(utscFootprintsRaw) as CampusFootprintCollection; + +type UniversityCatalog = { + campus?: { bounds?: [[number, number], [number, number]] | null }; + sources: Array<{ id: string; title: string; url: string; retrievedAt: string }>; + buildings: Array<{ + id: string; + name: string; + nativeCodes: string[]; + aliases: string[]; + category?: "academic" | "residence" | "facility"; + geometry: CampusFootprintGeometry | null; + }>; + entrances: Array<{ + id: string; + buildingId: string; + coordinate: [number, number]; + pathNodeId: string; + access: BuildingEntrance["access"]; + provenance: Array<{ + sourceId: string; + verification: "source-backed" | "field-reviewed" | "inferred"; + }>; + }>; +}; +const universityCatalogs: Record = Object.fromEntries( + Object.entries(universityCatalogRaw).map(([id, raw]) => [ + id, + JSON.parse(raw) as UniversityCatalog, + ]), +); + +const EXTERNAL_REGISTRIES: Record = { + utsg: utsgRegistry, + utsc: utscRegistry, + ...Object.fromEntries( + Object.entries(universityCatalogs).map(([campus, catalog]) => [ + campus, + { + campus, + generatedAt: "", + buildings: catalog.buildings.map((building) => ({ + id: building.id, + campus, + code: building.nativeCodes[0] ?? building.id, + name: building.name, + category: (building.category ?? "facility") as "academic" | "residence" | "facility", + aliases: [building.id, ...building.aliases, ...building.nativeCodes.slice(1)], + })), + }, + ]), + ), +} as const; + +const EXTERNAL_FOOTPRINTS: Record = { + utsg: utsgFootprints, + utsc: utscFootprints, + ...Object.fromEntries( + Object.entries(universityCatalogs).map(([campus, catalog]) => [ + campus, + { + type: "FeatureCollection" as const, + features: catalog.buildings.flatMap((building) => + building.geometry + ? [ + { + type: "Feature" as const, + id: building.id, + properties: { + campus, + buildingId: building.id, + buildingCode: building.nativeCodes[0] ?? building.id, + name: building.name, + }, + geometry: building.geometry, + }, + ] + : [], + ), + }, + ]), + ), +} as const; + +const CAMPUS_FALLBACK_BOUNDS: Record = { + utm: [ + [-79.6765, 43.5415], + [-79.6535, 43.5585], + ], + utsg: [ + [-79.4215, 43.645], + [-79.365, 43.6825], + ], + utsc: [ + [-79.205, 43.772], + [-79.165, 43.7995], + ], + ...Object.fromEntries( + Object.entries(universityCatalogs) + .filter(([, catalog]) => catalog.campus?.bounds) + .map(([id, catalog]) => [id, catalog.campus!.bounds!]), + ), +}; + +export const CAMPUS_LABELS: Record = { + utm: "University of Toronto Mississauga", + utsg: "University of Toronto St. George", + utsc: "University of Toronto Scarborough", + ...Object.fromEntries( + manifest.universities.flatMap((university) => + university.id === "uoft" + ? [] + : university.campuses.map((campus) => [campus, university.name]), + ), + ), +}; + +export const CAMPUS_SHORT_LABELS: Record = { + utm: "UTM", + utsg: "UTSG", + utsc: "UTSC", + ...Object.fromEntries( + manifest.universities.flatMap((university) => + university.id === "uoft" + ? [] + : university.campuses.map((campus) => [campus, university.shortName]), + ), + ), +}; + +export function gapwiseCampusIdForCampus(campus: Campus | undefined): GapwiseCampusId | null { + const id = campus?.toLowerCase(); + return id && id in CONFIGURATIONS ? (id as GapwiseCampusId) : null; +} + +function normalizeText(value: string) { + return value + .trim() + .toUpperCase() + .replace(/[,._]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function unique(values: readonly string[]) { + return [...new Set(values.filter(Boolean))]; +} + +// U of T Student Life's current St. George residence map groups these canonical +// buildings as student residences. Some are mixed-use college buildings, so residence +// membership is intentionally kept separate from the single building category field. +// Source: https://studentlife.utoronto.ca/wp-content/uploads/Housing-Map.pdf +const UTSG_RESIDENCE_CODES = new Set([ + "013", // Whitney Hall + "029", // Sir Daniel Wilson Residence + "064", // Graduate House + "101", // Morrison Hall + "131", // New College III / 45 Willcocks + "133", // Innis College Student Residence + "158", // Chestnut Residence + "505", // Burwash Residence (Lower Houses) + "505A", // Burwash Residence (Upper Houses) + "506", // Annesley Hall + "508", // Margaret Addison Hall + "518", // Rowell Jackman Hall + "575", // Knox College + "608", // St. Hilda's College + "790", // University Family Housing, 30 Charles + "791", // University Family Housing, 35 Charles + "BR", // Brennan Hall, St. Michael's College + "TC", // Trinity College + "WE", // Wetmore Hall + "WI", // Wilson Hall + "WO", // Woodsworth College Residence +]); + +function externalConfigurations(campusId: string): BuildingConfiguration[] { + return (EXTERNAL_REGISTRIES[campusId]?.buildings ?? []) + .filter((building) => building.status !== "inactive") + .map((building) => ({ + code: building.code.toUpperCase(), + name: building.name, + category: + campusId === "utsg" && UTSG_RESIDENCE_CODES.has(building.code) + ? ("residence" as const) + : building.category, + aliases: unique([ + ...(building.aliases ?? []), + ...(building.timetableCodes ?? []), + ...(building.facilityCodes ?? []), + ]), + })); +} + +const CONFIGURATIONS: Record = { + utm: UTM_BUILDINGS, + utsg: externalConfigurations("utsg"), + utsc: externalConfigurations("utsc"), + ...Object.fromEntries( + Object.keys(universityCatalogs).map((id) => [id, externalConfigurations(id)]), + ), +}; + +export function campusResidenceBuildings(campusId: GapwiseCampusId): BuildingConfiguration[] { + return (CONFIGURATIONS[campusId] ?? []).filter((building) => building.category === "residence"); +} + +export function getResidenceBuildingForCampus( + campusId: GapwiseCampusId, + code: string | null | undefined, +): BuildingConfiguration | null { + if (!code) return null; + const normalized = code.trim().toUpperCase(); + return ( + campusResidenceBuildings(campusId).find( + (building) => + building.code.toUpperCase() === normalized || + (building.aliases ?? []).some((a) => a.toUpperCase() === normalized), + ) ?? null + ); +} + +export function campusBuildingConfigurations(campusId: GapwiseCampusId) { + return CONFIGURATIONS[campusId] ?? []; +} + +/** Attribution attached to the campus overlay, separate from the basemap attribution. */ +export function campusMapAttribution(campusId: GapwiseCampusId): string | undefined { + const sources = universityCatalogs[campusId]?.sources ?? []; + return sources.some((source) => source.url?.startsWith("https://www.openstreetmap.org")) + ? '© OpenStreetMap contributors · ODbL' + : undefined; +} + +/** Canonical mapped doors for the selected campus, preserving unknown access facts. */ +export function campusBuildingEntrances( + campusId: GapwiseCampusId, + code: string | null, +): BuildingEntrance[] { + if (!code) return []; + if (campusId === "utm") return getCampusBuilding(code)?.entrances ?? []; + const catalog = universityCatalogs[campusId]; + if (!catalog) return []; + const identity = getCampusBuildingIdentity(campusId, code); + if (!identity) return []; + const building = catalog.buildings.find((item) => item.nativeCodes[0] === identity.code); + if (!building) return []; + return catalog.entrances + .filter((entrance) => entrance.buildingId === building.id) + .map((entrance) => { + const provenance = entrance.provenance[0]; + const source = catalog.sources.find((item) => item.id === provenance?.sourceId); + return { + id: entrance.id, + label: `${building.name} mapped entrance`, + kind: "entrance", + coordinates: entrance.coordinate, + routingNodeId: entrance.pathNodeId, + accessibility: "unknown", + access: entrance.access, + direction: "unknown", + preferredForRouting: false, + verificationMethod: provenance?.verification ?? "unknown", + sourceIdentifier: source?.id ?? "unknown", + metadata: { + source: source?.title ?? "Gapwise Data", + sourceUrl: source?.url ?? "", + lastVerified: source?.retrievedAt ?? "", + verificationStatus: provenance?.verification === "inferred" ? "inferred" : "verified", + }, + }; + }); +} + +export function getCampusBuildingIdentity(campusId: GapwiseCampusId, value: string | null) { + if (!value) return null; + const normalized = normalizeText(value); + return ( + CONFIGURATIONS[campusId]?.find((building) => + [building.code, building.name, ...(building.aliases ?? [])].some( + (candidate) => normalizeText(candidate) === normalized, + ), + ) ?? null + ); +} + +export function resolveCampusBuildingLocation( + campusId: GapwiseCampusId, + raw: string | null | undefined, +): { building: BuildingConfiguration; room: string | null } | null { + const normalized = normalizeText(raw ?? ""); + if (!normalized) return null; + const candidates = (CONFIGURATIONS[campusId] ?? []) + .flatMap((building) => + [building.code, ...(building.aliases ?? []), building.name].map((key) => ({ + building, + key: normalizeText(key), + })), + ) + .filter((candidate) => candidate.key) + .sort((a, b) => b.key.length - a.key.length); + + for (const candidate of candidates) { + if (normalized === candidate.key) return { building: candidate.building, room: null }; + if (normalized.startsWith(`${candidate.key} `) || normalized.startsWith(`${candidate.key}-`)) { + return { + building: candidate.building, + room: normalized.slice(candidate.key.length).replace(/^[\s-]+/, "") || null, + }; + } + } + return null; +} + +function externalFootprints(campusId: Exclude) { + return EXTERNAL_FOOTPRINTS[campusId]?.features ?? []; +} + +export function campusFootprintCollection(campusId: GapwiseCampusId): CampusFootprintCollection { + if (campusId === "utm") { + return UTM_FOOTPRINTS as unknown as CampusFootprintCollection; + } + return EXTERNAL_FOOTPRINTS[campusId] ?? { type: "FeatureCollection", features: [] }; +} + +export function getBuildingFootprintForCampus( + campusId: GapwiseCampusId, + code: string | null, +): CampusFootprintFeature | null { + if (!code) return null; + if (campusId === "utm") { + return getUtmBuildingFootprint(code) as unknown as CampusFootprintFeature | null; + } + const identity = getCampusBuildingIdentity(campusId, code); + if (!identity) return null; + return ( + externalFootprints(campusId).find( + (feature) => feature.properties.buildingCode.toUpperCase() === identity.code.toUpperCase(), + ) ?? null + ); +} + +function geometryPolygons(geometry: CampusFootprintGeometry): FootprintCoordinate[][][] { + return geometry.type === "Polygon" ? [geometry.coordinates] : geometry.coordinates; +} + +export function campusFootprintGeometryPoints(geometry: CampusFootprintGeometry) { + return geometryPolygons(geometry).flat(2) as FootprintCoordinate[]; +} + +function pointOnSegment( + point: FootprintCoordinate, + start: FootprintCoordinate, + end: FootprintCoordinate, +) { + const dx = end[0] - start[0]; + const dy = end[1] - start[1]; + const squaredLength = dx * dx + dy * dy; + if (squaredLength <= 1e-24) { + const pointDx = point[0] - start[0]; + const pointDy = point[1] - start[1]; + return pointDx * pointDx + pointDy * pointDy <= 1e-24; + } + const cross = (point[1] - start[1]) * dx - (point[0] - start[0]) * dy; + if (Math.abs(cross) > 1e-11) return false; + const dot = (point[0] - start[0]) * dx + (point[1] - start[1]) * dy; + if (dot < 0) return false; + return dot <= squaredLength; +} + +function pointInRing(point: FootprintCoordinate, ring: FootprintCoordinate[]) { + let inside = false; + for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) { + const currentPoint = ring[index]!; + const previousPoint = ring[previous]!; + if (pointOnSegment(point, previousPoint, currentPoint)) return true; + const [x, y] = point; + const [xi, yi] = currentPoint; + const [xj, yj] = previousPoint; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; + } + return inside; +} + +function pointInGeometry(point: FootprintCoordinate, geometry: CampusFootprintGeometry) { + return geometryPolygons(geometry).some((polygon) => { + const outer = polygon[0]; + if (!outer || !pointInRing(point, outer)) return false; + return !polygon.slice(1).some((hole) => pointInRing(point, hole)); + }); +} + +export function buildingCodeAtCampusCoordinate( + campusId: GapwiseCampusId, + point: FootprintCoordinate, +) { + if (campusId === "utm") return utmBuildingCodeAtCoordinate(point); + const matches = externalFootprints(campusId).filter((feature) => + pointInGeometry(point, feature.geometry), + ); + return matches.length === 1 ? matches[0]!.properties.buildingCode : null; +} + +function featureBounds(feature: CampusFootprintFeature) { + const points = campusFootprintGeometryPoints(feature.geometry); + if (!points.length) return null; + let west = Number.POSITIVE_INFINITY; + let south = Number.POSITIVE_INFINITY; + let east = Number.NEGATIVE_INFINITY; + let north = Number.NEGATIVE_INFINITY; + for (const [longitude, latitude] of points) { + west = Math.min(west, longitude); + south = Math.min(south, latitude); + east = Math.max(east, longitude); + north = Math.max(north, latitude); + } + return [ + [west, south], + [east, north], + ] as [[number, number], [number, number]]; +} + +export function representativePointForCampusFootprint( + campusId: GapwiseCampusId, + feature: CampusFootprintFeature, +): FootprintCoordinate | null { + if (campusId === "utm") { + return representativeUtmPoint(feature as never); + } + const bounds = featureBounds(feature); + if (!bounds) return null; + const [[west, south], [east, north]] = bounds; + const center: FootprintCoordinate = [(west + east) / 2, (south + north) / 2]; + if (pointInGeometry(center, feature.geometry)) return center; + for (let row = 1; row < 30; row += 1) { + for (let column = 1; column < 30; column += 1) { + const point: FootprintCoordinate = [ + west + ((east - west) * column) / 30, + south + ((north - south) * row) / 30, + ]; + if (pointInGeometry(point, feature.geometry)) return point; + } + } + return campusFootprintGeometryPoints(feature.geometry)[0] ?? null; +} + +export function campusCameraBounds( + campusId: GapwiseCampusId, +): [[number, number], [number, number]] { + const features = campusFootprintCollection(campusId).features; + const points = features.flatMap((feature) => campusFootprintGeometryPoints(feature.geometry)); + if (!points.length) + return ( + CAMPUS_FALLBACK_BOUNDS[campusId] ?? [ + [-180, -90], + [180, 90], + ] + ); + let west = Number.POSITIVE_INFINITY; + let south = Number.POSITIVE_INFINITY; + let east = Number.NEGATIVE_INFINITY; + let north = Number.NEGATIVE_INFINITY; + for (const [longitude, latitude] of points) { + west = Math.min(west, longitude); + south = Math.min(south, latitude); + east = Math.max(east, longitude); + north = Math.max(north, latitude); + } + const longitudePadding = Math.max((east - west) * 0.08, 0.001); + const latitudePadding = Math.max((north - south) * 0.08, 0.001); + return [ + [west - longitudePadding, south - latitudePadding], + [east + longitudePadding, north + latitudePadding], + ]; +} + +export function campusCenter(campusId: GapwiseCampusId): [number, number] { + const [[west, south], [east, north]] = campusCameraBounds(campusId); + return [(west + east) / 2, (south + north) / 2]; +} diff --git a/tests/fixtures/gapwise/src/universities/timetable-adapters.ts b/tests/fixtures/gapwise/src/universities/timetable-adapters.ts new file mode 100644 index 0000000..462080a --- /dev/null +++ b/tests/fixtures/gapwise/src/universities/timetable-adapters.ts @@ -0,0 +1,133 @@ +import type { Meeting, ParsedTimetable } from "@/lib/timetable-types"; + +/** University integration registry. Shared product surfaces consume ParsedTimetable only. */ +export const timetableAdapters: Record Promise> = { + "acorn-ics": async (text) => { + const { parseIcs } = await import("@/lib/ics-parser"); + const { enrichCourseTitles } = await import("@/lib/course-title-catalog"); + const parsed = parseIcs(text); + return { ...parsed, meetings: await enrichCourseTitles(parsed.meetings) }; + }, + "carleton-ics": async (text) => { + const { parseCarletonTimetable } = await import("./carleton/adapter"); + return parseCarletonTimetable(text); + }, + "tmu-schedule": async (text) => { + const { parseTimetable } = await import("./tmu/adapter"); + return parseTimetable(text); + }, + "queens-schedule": async (text) => { + const { parseTimetable } = await import("./queens/adapter"); + return parseTimetable(text); + }, + "laurier-schedule": async (text) => { + const { parseTimetable } = await import("./laurier/adapter"); + return parseTimetable(text); + }, + "york-schedule": async (text) => { + const { parseTimetable } = await import("./york/adapter"); + return parseTimetable(text); + }, + "mcmaster-schedule": async (text) => { + const { parseTimetable } = await import("./mcmaster/adapter"); + return parseTimetable(text); + }, + "western-schedule": async (text) => { + const { parseTimetable } = await import("./western/adapter"); + return parseTimetable(text); + }, + "guelph-schedule": async (text) => { + const { parseTimetable } = await import("./guelph/adapter"); + return parseTimetable(text); + }, + "uottawa-schedule": async (text) => { + const { parseTimetable } = await import("./uottawa/adapter"); + return parseTimetable(text); + }, + "brock-schedule": async (text) => { + const { parseTimetable } = await import("./brock/adapter"); + return parseTimetable(text); + }, + // GAPWISE_ADAPTER_REGISTRY: the CLI inserts new timetable adapters here. +}; + +export const demoTimetableLoaders: Record Promise> = { + "acorn-ics": async () => (await import("@/lib/demo-timetable")).DEMO_MEETINGS, + "carleton-ics": async () => { + const [{ DEMO_CARLETON_MEETINGS }, { normalizeCarletonMeeting }] = await Promise.all([ + import("./carleton/demo-timetable"), + import("./carleton/adapter"), + ]); + return DEMO_CARLETON_MEETINGS.flatMap(normalizeCarletonMeeting); + }, + "tmu-schedule": async () => { + const [{ DEMO_TMU_MEETINGS }, { normalizeTmuMeeting }] = await Promise.all([ + import("./tmu/demo-timetable"), + import("./tmu/adapter"), + ]); + return DEMO_TMU_MEETINGS.flatMap(normalizeTmuMeeting); + }, + "queens-schedule": async () => { + const [{ DEMO_QUEENS_MEETINGS }, { normalizeQueensMeeting }] = await Promise.all([ + import("./queens/demo-timetable"), + import("./queens/adapter"), + ]); + return DEMO_QUEENS_MEETINGS.flatMap(normalizeQueensMeeting); + }, + "laurier-schedule": async () => { + const [{ DEMO_LAURIER_MEETINGS }, { normalizeLaurierMeeting }] = await Promise.all([ + import("./laurier/demo-timetable"), + import("./laurier/adapter"), + ]); + return DEMO_LAURIER_MEETINGS.flatMap(normalizeLaurierMeeting); + }, + "york-schedule": async () => { + const [{ DEMO_YORK_MEETINGS }, { normalizeYorkMeeting }] = await Promise.all([ + import("./york/demo-timetable"), + import("./york/adapter"), + ]); + return DEMO_YORK_MEETINGS.flatMap(normalizeYorkMeeting); + }, + "mcmaster-schedule": async () => { + const [{ DEMO_MCMASTER_MEETINGS }, { normalizeMcMasterMeeting }] = await Promise.all([ + import("./mcmaster/demo-timetable"), + import("./mcmaster/adapter"), + ]); + return DEMO_MCMASTER_MEETINGS.flatMap(normalizeMcMasterMeeting); + }, + "western-schedule": async () => { + const [{ DEMO_WESTERN_MEETINGS }, { normalizeWesternMeeting }] = await Promise.all([ + import("./western/demo-timetable"), + import("./western/adapter"), + ]); + return DEMO_WESTERN_MEETINGS.flatMap(normalizeWesternMeeting); + }, + "guelph-schedule": async () => { + const [{ DEMO_GUELPH_MEETINGS }, { normalizeGuelphMeeting }] = await Promise.all([ + import("./guelph/demo-timetable"), + import("./guelph/adapter"), + ]); + return DEMO_GUELPH_MEETINGS.flatMap(normalizeGuelphMeeting); + }, + "uottawa-schedule": async () => { + const [{ DEMO_UOTTAWA_MEETINGS }, { normalizeUOttawaMeeting }] = await Promise.all([ + import("./uottawa/demo-timetable"), + import("./uottawa/adapter"), + ]); + return DEMO_UOTTAWA_MEETINGS.flatMap(normalizeUOttawaMeeting); + }, + "brock-schedule": async () => { + const [{ DEMO_BROCK_MEETINGS }, { normalizeBrockMeeting }] = await Promise.all([ + import("./brock/demo-timetable"), + import("./brock/adapter"), + ]); + return DEMO_BROCK_MEETINGS.flatMap(normalizeBrockMeeting); + }, + // GAPWISE_DEMO_LOADER_REGISTRY: the CLI inserts new demo loaders here. +}; + +export async function loadDemoTimetable(adapterId?: string): Promise { + const loader = adapterId ? demoTimetableLoaders[adapterId] : undefined; + if (loader) return loader(); + return (await import("@/lib/demo-timetable")).DEMO_MEETINGS; +} diff --git a/tests/fixtures/gapwise/universities.json b/tests/fixtures/gapwise/universities.json new file mode 100644 index 0000000..d4d138a --- /dev/null +++ b/tests/fixtures/gapwise/universities.json @@ -0,0 +1,203 @@ +{ + "schemaVersion": 1, + "universities": [ + { + "id": "uoft", + "name": "University of Toronto", + "shortName": "U of T", + "accentColor": "#4EA7FE", + "campusScope": "Mississauga, St. George, and Scarborough", + "hosts": ["gapwise.ca", "www.gapwise.ca"], + "campuses": ["utm", "utsg", "utsc"], + "defaultCampus": "utm", + "timetableAdapter": "acorn-ics", + "calendarSource": "ACORN", + "calendarInstructions": "Download the calendar export (.ics) from ACORN's Timetable & Exams, then choose it here.", + "calendarHelpUrl": "/acorn-import", + "enabledFeatures": { "routing": true, "liveLocation": true }, + "routableCampuses": ["utm"], + "dataPaths": ["data/utm", "data/utsg", "data/utsc"], + "status": "supported" + }, + { + "id": "carleton", + "name": "Carleton University", + "shortName": "Carleton", + "accentColor": "#E31B23", + "campusScope": "Ottawa campus", + "hosts": ["carleton.gapwise.ca"], + "campuses": ["carleton"], + "defaultCampus": "carleton", + "timetableAdapter": "carleton-ics", + "calendarSource": "Carleton Central", + "calendarInstructions": "Paste your schedule text from Carleton Central, upload an exported calendar (.ics) file, or try the sample timetable.", + "calendarHelpUrl": "https://central.carleton.ca/prod/bwysched.p_select_term?wsea_code=EXT", + "enabledFeatures": { "routing": true, "liveLocation": false }, + "routableCampuses": ["carleton"], + "dataPaths": ["universities/carleton/campus.json"], + "status": "supported" + }, + { + "id": "tmu", + "name": "Toronto Metropolitan University", + "shortName": "TMU", + "accentColor": "#0284c7", + "campusScope": "Downtown Toronto campus", + "hosts": ["tmu.gapwise.ca"], + "campuses": ["tmu"], + "defaultCampus": "tmu", + "timetableAdapter": "tmu-schedule", + "calendarSource": "MyServiceHub & Google Calendar", + "calendarInstructions": "Paste your schedule text from MyServiceHub (RAMSS) or upload your exported Google Calendar (.ics) file.", + "calendarHelpUrl": "https://www.torontomu.ca/servicehub/", + "enabledFeatures": { "routing": true, "liveLocation": false }, + "routableCampuses": ["tmu"], + "dataPaths": ["universities/tmu/campus.json"], + "status": "supported" + }, + { + "id": "queens", + "name": "Queen's University", + "shortName": "Queen's", + "accentColor": "#f59e0b", + "campusScope": "Kingston campus", + "hosts": ["queens.gapwise.ca"], + "campuses": ["queens"], + "defaultCampus": "queens", + "timetableAdapter": "queens-schedule", + "calendarSource": "SOLUS Student Center", + "calendarInstructions": "Upload your calendar (.ics) from SOLUS Class Schedule Subscription, or paste your schedule text.", + "calendarHelpUrl": "https://my.queensu.ca", + "enabledFeatures": { "routing": true, "liveLocation": false }, + "routableCampuses": ["queens"], + "dataPaths": ["universities/queens/campus.json"], + "status": "supported" + }, + { + "id": "laurier", + "name": "Wilfrid Laurier University", + "shortName": "Laurier", + "accentColor": "#8b5cf6", + "campusScope": "Waterloo campus", + "hosts": ["laurier.gapwise.ca"], + "campuses": ["waterloo"], + "defaultCampus": "waterloo", + "timetableAdapter": "laurier-schedule", + "calendarSource": "LORIS & MyLS", + "calendarInstructions": "Paste your schedule text from LORIS (Student Detail Schedule) or upload your exported calendar (.ics) file.", + "calendarHelpUrl": "https://web.wlu.ca/student-portal/", + "enabledFeatures": { "routing": true, "liveLocation": false }, + "routableCampuses": ["waterloo"], + "dataPaths": ["universities/laurier/campus.json"], + "status": "supported" + }, + { + "id": "york", + "name": "York University", + "shortName": "York", + "accentColor": "#E31837", + "campusScope": "Keele campus", + "hosts": ["york.gapwise.ca"], + "campuses": ["keele"], + "defaultCampus": "keele", + "timetableAdapter": "york-schedule", + "calendarSource": "Visual Schedule Builder & REM", + "calendarInstructions": "Paste your schedule text from the York Registration and Enrolment Module (REM) / Visual Schedule Builder (VSB), or upload an exported calendar (.ics) file.", + "calendarHelpUrl": "https://registrar.yorku.ca/enrol/guide", + "enabledFeatures": { "routing": true, "liveLocation": false }, + "routableCampuses": ["keele"], + "dataPaths": ["universities/york/campus.json"], + "status": "supported" + }, + { + "id": "mcmaster", + "name": "McMaster University", + "shortName": "McMaster", + "accentColor": "#7A003C", + "campusScope": "Hamilton campus", + "hosts": ["mcmaster.gapwise.ca"], + "campuses": ["mcmaster"], + "defaultCampus": "mcmaster", + "timetableAdapter": "mcmaster-schedule", + "calendarSource": "Mosaic & Outlook Calendar", + "calendarInstructions": "Upload your calendar (.ics) from Mosaic Timetable to Outlook, or paste your schedule text from Mosaic Class Schedule.", + "calendarHelpUrl": "https://mosaic.mcmaster.ca", + "enabledFeatures": { "routing": true, "liveLocation": false }, + "routableCampuses": ["mcmaster"], + "dataPaths": ["universities/mcmaster/campus.json"], + "status": "supported" + }, + { + "id": "western", + "name": "Western University", + "shortName": "Western", + "accentColor": "#4F2683", + "campusScope": "London campus", + "hosts": ["western.gapwise.ca"], + "campuses": ["western"], + "defaultCampus": "western", + "timetableAdapter": "western-schedule", + "calendarSource": "Draft My Schedule & Student Center", + "calendarInstructions": "Paste your schedule text from Student Center / Draft My Schedule, or upload an exported calendar (.ics) file.", + "calendarHelpUrl": "https://student.uwo.ca", + "enabledFeatures": { "routing": true, "liveLocation": false }, + "routableCampuses": ["western"], + "dataPaths": ["universities/western/campus.json"], + "status": "supported" + }, + { + "id": "guelph", + "name": "University of Guelph", + "shortName": "Guelph", + "accentColor": "#C20430", + "campusScope": "Guelph campus", + "hosts": ["guelph.gapwise.ca"], + "campuses": ["guelph"], + "defaultCampus": "guelph", + "timetableAdapter": "guelph-schedule", + "calendarSource": "WebAdvisor & Student Planning", + "calendarInstructions": "Paste your schedule text from WebAdvisor / Student Planning, or upload an exported calendar (.ics) file.", + "calendarHelpUrl": "https://webadvisor.uoguelph.ca", + "enabledFeatures": { "routing": true, "liveLocation": false }, + "routableCampuses": ["guelph"], + "dataPaths": ["universities/guelph/campus.json"], + "status": "supported" + }, + { + "id": "uottawa", + "name": "University of Ottawa", + "shortName": "uOttawa", + "accentColor": "#8F001A", + "campusScope": "Downtown Ottawa campus", + "hosts": ["uottawa.gapwise.ca"], + "campuses": ["uottawa"], + "defaultCampus": "uottawa", + "timetableAdapter": "uottawa-schedule", + "calendarSource": "uoZone & Enrolment", + "calendarInstructions": "Paste your schedule text from uoZone Class Schedule, or upload an exported calendar (.ics) file.", + "calendarHelpUrl": "https://uozone.uottawa.ca", + "enabledFeatures": { "routing": true, "liveLocation": false }, + "routableCampuses": ["uottawa"], + "dataPaths": ["universities/uottawa/campus.json"], + "status": "supported" + }, + { + "id": "brock", + "name": "Brock University", + "shortName": "Brock", + "accentColor": "#CC0000", + "campusScope": "St. Catharines campus", + "hosts": ["brock.gapwise.ca"], + "campuses": ["brock"], + "defaultCampus": "brock", + "timetableAdapter": "brock-schedule", + "calendarSource": "my.brocku.ca & Course Planning", + "calendarInstructions": "Paste your schedule text from my.brocku.ca Student Schedule, or upload an exported calendar (.ics) file.", + "calendarHelpUrl": "https://my.brocku.ca", + "enabledFeatures": { "routing": true, "liveLocation": false }, + "routableCampuses": ["brock"], + "dataPaths": ["universities/brock/campus.json"], + "status": "supported" + } + ] +}