Compare commits

...
26 Commits
Author SHA1 Message Date
Ian Gulliver 0e934e02cd Apply EXIF orientation when generating thumbnails 2026-08-16 16:29:03 -07:00
Ian Gulliver 072d99d412 Cap the service at one instance for in-memory cache coherency 2026-08-16 16:06:51 -07:00
Ian Gulliver 778dd33b20 Use dynamic viewport height and bottom safe area so mobile content clears the tab bar 2026-08-16 15:44:27 -07:00
Ian Gulliver 440b3f11de Point avatar and View Profile at own person page; drop the profile page 2026-08-16 14:13:54 -07:00
Ian Gulliver b9bb8629a8 Cache fonts and brand assets for a day 2026-08-16 13:52:55 -07:00
Ian Gulliver 2eaba6810c Add CSV download and hover copy glyphs for rows, columns, and fields to the email list 2026-08-16 13:35:55 -07:00
Ian Gulliver 2574ced672 Restyle map popup: full-bleed photo, padded body, no close header 2026-08-16 13:29:56 -07:00
Ian Gulliver a7f1961c5f Wire map page Update My Address to own person page in edit mode 2026-08-16 13:25:59 -07:00
Ian Gulliver 2b836367e1 Rename Section to Crew, stage the load pipeline, dedupe lookups and token minting, drop burned tools 2026-08-16 13:21:25 -07:00
Ian Gulliver 8c9c1a6cd6 Add credential-free sample mode with sample data, README, and doc reconciliation 2026-08-16 13:14:30 -07:00
Ian Gulliver 2b6e77c766 Add edit mode with granular self-service edits and child opt-out 2026-08-16 12:03:35 -07:00
Ian Gulliver 4cd128e242 Reword opt-out confirmation 2026-08-16 11:42:42 -07:00
Ian Gulliver 4abe9d281f Add self-service person-level opt-out with full removal at load 2026-08-16 11:34:32 -07:00
Ian Gulliver 4d70d6c2a8 Require directory membership to view the directory 2026-08-16 11:27:00 -07:00
Ian Gulliver 366e3bb8a2 Add server wrapper, blob fetch logging, sheet cell tool; drop importblobs 2026-08-16 11:22:59 -07:00
Ian Gulliver bbff936f39 Load the directory model from the layered Veracross import 2026-08-16 10:13:05 -07:00
Ian Gulliver 516ef2dea0 Add dumptab and writetab tools for sheet population 2026-08-16 10:13:04 -07:00
Ian Gulliver bbdeb3e91e Mirror Overrides columns in Change Log with previous values; add tab tooling 2026-08-16 09:52:24 -07:00
Ian Gulliver 77b0401a8b Redesign data model around layered Veracross import; add findsheet tool 2026-08-16 09:42:27 -07:00
Ian Gulliver 1465a4ef05 Add inline About Me editing on own records 2026-08-16 08:19:06 -07:00
Ian Gulliver 54a5b4615f Add self-service photo and pronunciation upload with archiving and change log 2026-08-16 08:01:34 -07:00
Ian Gulliver 272ce380bb Add installable-app manifest, icons, and iOS splash screens 2026-08-16 07:29:14 -07:00
Ian Gulliver b418dd19ee Add production deployment doc 2026-08-15 23:58:26 -07:00
Ian Gulliver 0f2291473f Add mobile chrome matching the original app 2026-08-15 23:58:26 -07:00
Ian Gulliver 2a800135b7 Route my-family straight to the family page and build crumbs from URL state 2026-08-15 23:43:35 -07:00
Ian Gulliver 8ddf729a92 Fix geocode map race between job feed and worker writes 2026-08-15 23:27:35 -07:00
83 changed files with 3827 additions and 874 deletions
+8
View File
@@ -0,0 +1,8 @@
.gcloudignore
.git
.gitignore
creds/
docs/
sampledata/
screenshots/
LICENSE.md
+1
View File
@@ -1,4 +1,5 @@
creds/
imports/
screenshots/
*.png
!web/**/*.png
+39
View File
@@ -0,0 +1,39 @@
# Heliosian
Web apps for the Helios school community (K-8), served as one static Go binary on Cloud Run. The first app is the school directory, "Helios Who?". Built in the open by community volunteers, mostly through coding agents; the repo contains no secrets and no real community data.
## Quick start
brew install go
go run .
Open http://localhost:8080. That's the whole setup: with no configuration the server loads the fictional community in `sampledata/`, signs every request in as a sample parent, and fakes geocoding — no credentials, no cloud project. Templates, static assets, and sample data are read from disk on every request, so edit a file and refresh; nothing needs restarting.
`brew install --cask google-chrome` additionally enables the screenshot tooling used to verify visual changes ([docs/screenshots.md](docs/screenshots.md)). No Node, no Docker. Go 1.26 or later.
To run against real community data instead, see [docs/dev.md](docs/dev.md).
## Layout
- `main.go` — entry point and app wiring
- `internal/auth` — Google sign-in and session cookies
- `internal/data` — tabular data sources: sample CSVs and Google Sheets
- `internal/directory` — the directory app: model load, handlers, self-service edits
- `internal/blob` — media from Google Drive, held in memory with thumbnails
- `internal/geocode` — address → coordinates for the map
- `web/` — page templates and static assets (frameworkless JavaScript)
- `sampledata/` — the fictional community served by default
- `tools/` — dev tooling: screenshots, browser driving, sheet inspection
- `docs/` — everything below
## Docs
- [goals.md](docs/goals.md) — what this is and the principles behind it
- [dev.md](docs/dev.md) — local development, including real-data mode
- [data.md](docs/data.md) — the directory data model and load pipeline
- [directory.md](docs/directory.md) — the directory app spec
- [design.md](docs/design.md) — palette, typography, brand
- [pwa.md](docs/pwa.md) — installable-app wiring
- [screenshots.md](docs/screenshots.md) — page capture for humans and agents
- [deploy.md](docs/deploy.md) — production deployment
- [plan.md](docs/plan.md) — what remains to build
+120 -22
View File
@@ -1,42 +1,140 @@
# Data model
The entities the directory serves. Structured data lives in the data source (see `docs/plan.md`); blobs (photos, audio) are URL references into blob storage.
The entities the directory serves, and how they are assembled. Structured data lives in one Google Sheet shared with the service account; blobs (photos, audio) are files in the media shared drive, discovered by naming convention. The organized model is held in memory — nothing computed is ever written back to the sheet.
## Spreadsheet layout
| Tab | Written by | Purpose |
|---|---|---|
| Veracross Import | import tool | The raw Veracross student export, rewritten wholesale by the import tool on each refresh. Read-only to the serving app; no local edit survives here. |
| Name to Email | hand | Maps a student's name to their school email for import rows where the email cell is empty or wrong, so every person can be keyed by email downstream. |
| Overrides | hand + app | The entire local layer: admin corrections, app-written self-service text, and added people, in canonical model columns keyed by email. |
| Change Log | app | Append-only audit trail mirroring the Overrides columns: one row per change, holding the previous values. |
School structure lives nowhere in the sheet: membership and the classroom and crew names themselves derive from person records, and the remaining fixed structure — band identities, grade progression, department order — is code constants (see Classrooms and grades).
## Load pipeline
1. Read the raw import.
2. Transform each import row into canonical person and household records (explosion, below). The Name to Email mapping applies during this step, since it fixes the key everything else uses.
3. Apply the Overrides tab onto the canonical records by email: unflagged rows patch existing records, flagged rows create new ones.
4. List the media drive and attach photo and pronunciation blobs to records by filename convention.
5. Hold the organized result in memory; the server refuses to start if the load fails, and the model reloads periodically.
Because Overrides rows are authored post-transform, their values skip import normalization — so canonical-value validation runs on every layer, not just the import.
## Veracross Import format
One row per student, 29 columns, with the student's households denormalized into the row: up to two households (separated parents), up to two adults each.
| Columns | Content |
|---|---|
| `entry_sort_name`, `student_full_name` | Sort key; student name in `Preferred (Legal) Last` form when a preferred name is set, plain `First Last` otherwise |
| `student_classifications` | JSON with exactly `grade_level` (`Kindergarten`, `Grade 1``Grade 8`) and `homeroom` (compound `Crew Classroom` string) |
| `student_email`, `student_phone_mobile` | Empty email for a substantial minority of students (roughly a fifth, across grades) — these rows require a Name to Email entry |
| `household_N_phone`, `household_N_address` | N ∈ {1, 2}; address arrives at whatever granularity the family shares with the school: street-level, city-only, or empty |
| `household_N_person_M_full_name`, `_email`, `_email_2`, `_phone_mobile`, `_phone_business` | M ∈ {1, 2}; adult emails are always `@heliosschool.org` but arrive mixed-case; `email_2` is unused in practice |
Staff do not appear in this export. They enter either through a dedicated staff import run through the same pipeline, or as flagged rows in Overrides — this choice is open.
## Transform
The explosion turns each import row into one student record plus up to four adult records and one or two household records:
- **Person identity**: email, lowercased, is the key everywhere. Rows with a blank or wrong `student_email` get theirs from Name to Email; a mapping that matches zero or multiple import rows is fatal.
- **Names**: `Preferred (Legal) Last` parses into preferred name, legal name, and display name; plain names pass through. Source rows with swapped or malformed name fields are repaired in Overrides, not by transform heuristics.
- **Roles**: derived from where a person appears — a row's student is a student, a household adult is a parent, staff sourcing marks staff. Combinations are valid (staff who are also parents).
- **Adults deduplicate** across sibling rows by email; conflicting values across a parent's appearances are fatal rather than silently last-one-wins.
- **Homeroom** splits positionally: classrooms are single-word bird family names, so the last word is the classroom and everything before it is the crew (`Great Blue Herons` → crew `Great Blue`, classroom `Herons`; a single-word homeroom like `Hummingbirds` is a crewless classroom). The school's own naming convention backs this — crew + classroom compounds are real species names, classrooms the one-word family.
- **Households** group by the set of adult emails in them, order-insensitively — `person_1`/`person_2` ordering is Veracross's choice and must not affect identity.
## Overrides
Canonical model columns, keyed by lowercased email, one row per person. Three kinds of content share the tab, distinguished only by authorship and one flag:
- **Corrections** (hand): fix anything the import gets wrong — swapped name fields, bad phone numbers — and carry person flags with no import source, like room-parent assignments and the new-to-Helios marker.
- **Self-service text** (app): facts, pronouns, preferred name, phone, address — the latter two hideable via the `-` clear — and the Opted Out flag. Every self-service edit warns that it doesn't affect the values shown in Veracross. The app writes these cells directly; moderating a contribution is the same act as any other correction. Photo and pronunciation uploads go straight to the media drive and never touch the sheet.
- **Additions** (hand, flagged): people with no import row at all. The flag inverts the source expectation.
Cell semantics are sparse: an empty cell contributes nothing, `-` clears the underlying value. An addition is just an override applied to an empty base record, so the merge logic is uniform; the flag selects the validation instead:
| Flag | Loader expects | Violation |
|---|---|---|
| unset | a matching import person | fatal: orphaned override |
| set | no matching import person | fatal: Veracross now covers this person — unflag the row and delete cells the import supplies |
Flagged rows must supply every field the model requires; unflagged rows can be a single cell. `-` on a flagged row is meaningless (nothing beneath to clear) and reported as useless.
Family-level fields (address, family photo caption, family phone) ride on a parent's row and apply to that parent's household, so a two-household student's families are addressed independently through their respective adults.
**Opted Out** removes the person entirely at load: their record, their membership in families and parent-contact lists, and any room-parent assignment all vanish from the model. Because viewing the directory requires being in it, opting out also locks the person out — they get a permissions error until the school clears the flag. People set it from their own page, parents set it for their kids (each with a confirmation spelling out the consequences), or an admin sets the cell by hand.
Every change to Overrides appends a Change Log row: timestamp, actor, the row's email, then the previous value of each column that changed — `-` marking a previously empty cell, untouched columns left blank. Media uploads are not logged here; the drive archive is their history.
## Media blobs
Photos and pronunciation recordings are files in the media shared drive, named by convention: `<email local part>-photo` and `<email local part>-pronunciation` for people, `<family key hash>-photo` and `<family key hash>-pronunciation` for families. Presence means existence — no sheet cell records a filename — and freshness comes from the file's modified time. Uploads replace the file and archive the previous version; superseded versions stay in an archive folder.
## Person
One record per person, all roles in one shape:
- **Key**: school email, lowercased. Everyone has one, including the youngest students (their addresses exist but they don't have access yet).
- **Names**: full name, legal name, preferred name.
- **Roles**: student, parent, and staff booleans — combinations are valid (staff members are often also parents). Display strings derive from the flags.
- **New to Helios**: flag marking people who just joined the community; drives the matching filter toggle.
- **Key**: school email, lowercased. Everyone has one including students whose import row omits it (supplied via Name to Email) — though the youngest students don't yet have access to theirs.
- **Names**: display name, legal name, preferred name, parsed from the import or overridden.
- **Roles**: student, parent, and staff booleans, derived from sourcing; combinations are valid. Display strings derive from the flags.
- **New to Helios**: override-carried flag marking people who just joined the community; drives the matching filter toggle.
- **Pronouns**: optional; a curated list plus a freeform escape hatch.
- **Pronunciation**: optional audio recording of the person's name.
- **Photo**: official portrait or personal upload; people may opt for an illustrated avatar instead.
- **Pronunciation**: optional audio recording of the person's name, from the media drive.
- **Photo**: official portrait or personal upload, from the media drive; people may opt for an illustrated avatar instead.
- **Facts**: optional about-me text — first-person blurbs for students, professional bios for staff.
- **Student fields**: grade (`Kindergarten`, `Grade 1``Grade 8`), classroom section, and the parent contact emails for the student.
- **Staff fields**: job title, department, grade band.
- **Student fields**: grade, classroom, and crew from the homeroom split; parent contact emails derive from the student's household adults.
- **Staff fields**: job title, department, grade band, and classroom/crew assignment for teaching staff.
- **Contact**: email always; phone optional.
- **Year rollover**: next-year grade and band, so the directory can flip to the new school year.
- **Year rollover**: next-year grade and band derive from the grade progression constant, so the directory can flip to the new school year.
## Family
A family groups adults and kids:
A household groups adults and kids; a student belongs to one household normally, two when parents keep separate households:
- **Key**: shared by all members. (Currently a parent's email; minting stable family IDs is a planned migration.)
- **Photo and caption**: the family photo plus a who's-who description naming everyone in it.
- **Pronunciation**: optional audio recording of the family name.
- **Address**: as much as the family chooses to share — full postal address or just city and state.
- **Phone**: optional family phone.
- **Key**: a hash of the sorted emails of every member — students and adults alike — so identity is order-insensitive and derives from nothing but membership. Any membership change (new student, student leaves, parent change) produces a new key, deliberately: the family's URL and photo association reset along with its composition.
- **Members**: the adults in the household and the students whose rows name it.
- **Photo and caption**: the family photo from the media drive plus a who's-who description naming everyone in it.
- **Pronunciation**: optional audio recording of the family name, from the media drive.
- **Address**: as much as the family chooses to share — full postal address or just city and state, seeded from the import and updatable via self-service.
- **Phone**: optional household phone.
## Classrooms and grades
- **Classroom**: name, mascot artwork, and the grade band it serves. Some classrooms subdivide into sections (teams); sections have their own logos.
- **Section**: classroom subdivision with up to a few teachers, a sort order, and named schedule blocks.
- **Grade band**: pairs of grades share a band with a combined identity — `Hummingbirds` (K), `Halcons` (1st/2nd), `Jayvens` (3rd/4th), `Cospreys` (5th/6th), `Hegrets` (7th/8th) — used for browsing, room-parent organization, and band-colored styling (see `docs/design.md`). A grade → next-grade mapping drives year rollover.
- **Room parents**: parent assignments per grade band.
- **Departments**: ordered list organizing the staff view into sections.
Membership and the classroom and crew names derive entirely from person records via the homeroom split; the remaining fixed structure — band identities, grade progression, department order — is code constants. The current shape:
| Band | Grades | Classrooms | Crews |
|---|---|---|---|
| Hummingbirds | K | Hummingbirds | — |
| Halcons | 12 | Falcons, Hawks | — |
| Jayvens | 34 | Jays, Ravens | — |
| Cospreys | 56 | Condors, Ospreys | Pinnacles/Big Sur, River/Sea |
| Hegrets | 78 | Egrets, Herons | Snowy/Great, Great Blue/Green |
- **Classroom**: mascot artwork lives on disk under the classroom's name; a classroom has crews exactly when its homerooms carry crew prefixes.
- **Crew**: classroom subdivision with its own logo; its teachers derive from staff records carrying a classroom/crew assignment.
- **Grade band**: pairs of grades share a band with a combined identity, used for browsing, room-parent organization, and band-colored styling (see `docs/design.md`). Grade → next-grade is positional in the ordered grade list, and next band follows from next grade.
- **Room parents**: parent assignments per grade band, carried as an Overrides column on the parent.
- **Departments**: membership derives from staff records; the display order organizing the staff view is a code constant.
## Validation
The loader hard-fails — no fallbacks, server refuses to start — on:
- a missing or duplicated expected header in any tab
- a duplicate key within a tab
- a Name to Email entry matching zero or multiple import rows
- an unflagged Overrides row matching no person (orphaned override)
- a flagged Overrides row colliding with an imported person
- conflicting values for the same adult across import rows
- an invalid canonical value from any layer
The import procedure is manual today: `tools/writetab` writes the Veracross CSV export into the Veracross Import tab (header-checked), and `tools/loadcheck` re-runs the full pipeline against the sheet and prints a model summary. A single import tool that also reports the local layer's health beyond the fatal checks — useless overrides (value identical to what the record has anyway), `-` on flagged rows, name mappings or additions that Veracross has since made redundant, and media files whose name matches no current person or family, including family blobs orphaned by a membership change — is planned (`docs/plan.md`). `tools/findsheet` lists the spreadsheets visible to the service account; `tools/sheets` dumps a sheet's tabs, headers, and rows.
## Sourcing
Records are imported from the school's systems and enriched by families themselves (photos, facts, pronunciation recordings, address preferences), with each contributed item carrying a last-updated stamp so refresh cadence can be enforced.
Records are imported from the school's systems and enriched by families themselves (photos, facts, pronunciation recordings, address preferences), with freshness read from media file timestamps and the change log so refresh cadence can be enforced.
+67
View File
@@ -0,0 +1,67 @@
# Production deployment
The server runs as Cloud Run service `heliosian` in project `gen-lang-client-0758114984`, region `us-west1`, at https://heliosian-326077318680.us-west1.run.app. Everything below is driven with the gcloud CLI (`brew install --cask gcloud-cli`) authenticated as the deploy identity:
gcloud auth activate-service-account --key-file=creds/service-account.json
gcloud config set project gen-lang-client-0758114984
## Image
The Dockerfile builds in two stages: a `golang` stage compiles the static binary (`CGO_ENABLED=0`), and `gcr.io/distroless/static-debian12` — CA certificates and tzdata, nothing else — carries the binary plus `web/`, whose templates and static assets are read from disk at runtime. `sampledata/` is deliberately excluded: production always sets `DIRECTORY_SHEET`, and a misconfigured server fails at startup rather than silently serving sample data. `creds/` never enters the image or the source upload (`.dockerignore`, `.gcloudignore`).
Cloud Build produces the image into Artifact Registry. Builds ship git HEAD, never the working tree — building the live tree can capture files mid-edit:
BUILDDIR=$(mktemp -d)
git archive HEAD | tar -x -C "$BUILDDIR"
gcloud builds submit --tag us-west1-docker.pkg.dev/gen-lang-client-0758114984/heliosian/heliosian "$BUILDDIR"
## Service
gcloud run deploy heliosian \
--image us-west1-docker.pkg.dev/gen-lang-client-0758114984/heliosian/heliosian:latest \
--region us-west1 --allow-unauthenticated \
--min-instances 1 --max-instances 1 --memory 2Gi --no-cpu-throttling \
--set-env-vars DIRECTORY_SHEET=<spreadsheet id>,GOOGLE_CLIENT_ID=<oauth client id> \
--set-secrets "/app/creds/service-account.json=heliosian-sa-key:latest,SESSION_KEY=heliosian-session-key:latest,GOOGLE_MAPS_SERVER_KEY=heliosian-geocoding-key:latest,GOOGLE_MAPS_BROWSER_KEY=heliosian-maps-browser-key:latest" \
--quiet
Each flag is load-bearing:
- `--min-instances 1` — startup preloads every media file from Drive before listening (about 90 seconds); far too slow for scale-to-zero.
- `--max-instances 1` — the directory model and blob store live in per-instance memory with no cross-instance coherency; a self-service edit refreshes only the instance that handled it, so a second instance would serve stale data.
- `--memory 2Gi` — the blob store holds all media and thumbnails in RAM. The startup log line `blob store: … MB in memory` reports the footprint; resize when it approaches the limit.
- `--no-cpu-throttling` — the directory model and blob store refresh on five-minute tickers between requests; default throttling would starve them.
- `--allow-unauthenticated` — the app enforces its own Google sign-in; Cloud Run must let everyone reach the login page.
Cloud Run injects `PORT`; the server honors it.
## Configuration
Plain environment variables:
- `DIRECTORY_SHEET` — the production spreadsheet id. The sheet is the single spreadsheet shared with the service account.
- `GOOGLE_CLIENT_ID` — the OAuth web client id; not a secret (it is embedded in the login page). The client secret from `creds/oauth-client.json` is never used by the server and lives nowhere in production.
Secret Manager secrets, delivered per `--set-secrets` above:
- `heliosian-sa-key``creds/service-account.json`, mounted as a file at `/app/creds/service-account.json` (the exact path the server opens, relative to `/app`)
- `heliosian-session-key` — session-cookie HMAC key (any long random string); losing or rotating it signs everyone out
- `heliosian-geocoding-key``creds/geocoding.key`
- `heliosian-maps-browser-key``creds/maps.key`
## IAM
`heliosian-test@gen-lang-client-0758114984.iam.gserviceaccount.com` serves two unrelated purposes:
- Data access: the spreadsheet (as editor — uploads write media cells and the Change Log tab) and the media shared drive (as content manager — uploads create and archive files) are shared with it in Drive/Sheets directly — never through project IAM.
- Deploy identity: project roles Editor, Service Account User, Cloud Run Admin, and Secret Manager Admin. The extra roles exist because Editor cannot set IAM policy on services or secrets.
The runtime identity is the default compute service account (`326077318680-compute@developer.gserviceaccount.com`) holding Secret Manager Secret Accessor on each secret individually. The basic Editor role deliberately cannot read secret payloads, so these explicit grants are the only thing standing between the service and a startup failure — the console's inherited-role rows on a secret's Permissions tab do not imply payload access.
## OAuth
The service URL belongs in the OAuth client's authorized JavaScript origins alongside `http://localhost:8080`; sign-in fails on any origin not listed, and edits take a few minutes to propagate. The browser maps key is rendered into every page, so it carries an HTTP-referer restriction for the service URL and localhost (see `docs/dev.md`).
## Verifying a deploy
The startup log (Cloud Run → Logs, or `gcloud logging read`) shows the full boot sequence: geocoding count, directory model load, the blob store footprint line, then `listening`. After any deploy, an existing session should still work — if everyone got signed out, `SESSION_KEY` stopped reaching the server.
+1 -1
View File
@@ -65,7 +65,7 @@ Original tile art (1254×1254 JPEGs for all nine classrooms and all nine grade t
- Pill-shaped search inputs and filter buttons; circular icon buttons for quick actions (message, mail, map, favorite).
- List rows with right chevrons; hairline dividers; generous whitespace.
- Detail pages break the white page with a full-width deep-teal band for family content.
- Inline separators: "▶" chains grade to team to subteam; "·" dots separate contact fragments.
- Inline separators: "▶" chains grade to classroom to crew; "·" dots separate contact fragments.
## Responsive chrome
+38 -25
View File
@@ -1,41 +1,54 @@
# Local development
Toolchain prerequisites: see [setup.md](setup.md).
## Run
go run .
The server listens on http://localhost:8080 (override with `PORT`). Templates, static assets, and sample data are read from disk on every request edit a file and refresh the browser; no restart needed.
http://localhost:8080 (override with `PORT`). With `DIRECTORY_SHEET` unset the server serves the fictional community in `sampledata/`, signs every request in as a sample parent, and geocodes with a deterministic fake — no credentials or configuration. Templates, static assets, and sample data are read from disk on every request; edit a file and refresh.
## Auth
Sample-mode limits: the map section needs a real Maps JavaScript key (`GOOGLE_MAPS_BROWSER_KEY`) to render tiles, and self-service edits and media uploads need real-data mode — there is no writable backend or blob store behind the sample CSVs.
Everything — pages, static assets, and the API — sits behind Google sign-in restricted to the school's Google Workspace domain. Unauthenticated requests get the login page (API paths get a 401). The OAuth 2.0 Web application client (authorized JavaScript origins must include `http://localhost:8080` for local development) is read from `creds/oauth-client.json` — the JSON downloaded from the Cloud console — or from `GOOGLE_CLIENT_ID` when set; the server refuses to start with neither. After Google sign-in the server issues its own HMAC-signed session cookie; set `SESSION_KEY` to keep sessions valid across restarts and instances (without it each start generates a random key).
`sampledata/` mirrors the production Sheets layout: one directory per app, one CSV per table, first row is the schema, served through the same data-source interface the Sheets backend implements. It stays fictional — real community data never goes here.
To capture authenticated pages with the screenshot tooling, launch the capture browser (`go run ./tools/capturebrowser`), sign in to the local server there once, and use `tools/browse` or `tools/screenshot -remote` — the session cookie lives in the capture profile. Plain `tools/screenshot` runs a fresh headless browser with no session and captures the login page.
## Maps
The map section geocodes family addresses server-side via the Google Geocoding API (results cached in memory per address) and renders in the browser with the Maps JavaScript API. Two API keys from a project with those APIs enabled, both required at startup:
- Server key — Geocoding API; restrict by server IP (or leave unrestricted for dev). Never rendered into pages. Read from `creds/geocoding.key`, or `GOOGLE_MAPS_SERVER_KEY` when set.
- Browser key — Maps JavaScript API; rendered into the page, so restrict by HTTP referer (localhost and the serving domain). Read from `creds/maps.key`, or `GOOGLE_MAPS_BROWSER_KEY` when set.
## Local data
The server reads local data from `sampledata/`, mirroring the production Sheets layout: one directory per app, one CSV file per table, first row is the schema. It goes through the same data-source interface production backends implement, so app code never knows which backend it is talking to.
In sample mode `tools/screenshot` captures pages directly, no session needed (see `docs/screenshots.md`).
## Real data
DIRECTORY_SHEET=<spreadsheet id> go run .
switches the directory app to the Google Sheets source. At startup the directory tables are read from the spreadsheet and normalized into the in-memory data model (see `docs/data.md`); the server refuses to start if that load fails, and the model reloads every five minutes. Requires the service account key at `creds/service-account.json` (the directory is gitignored) with the Sheets API enabled and the spreadsheet shared read-only with the service account. Real data never leaves the process: nothing is written to disk.
serves from the production spreadsheet and media drive (see `docs/data.md`) and turns on the full stack. The model loads at startup — the server refuses to start if the load fails — and reloads every five minutes. Real data never leaves the process: nothing is written to disk. Requirements:
## Layout
- **Sign-in** — everything sits behind Google sign-in restricted to the school's Workspace domain (API paths get a 401 instead of the login page). The OAuth web client is read from `creds/oauth-client.json` or `GOOGLE_CLIENT_ID`; its authorized JavaScript origins must include `http://localhost:8080`. The server issues its own HMAC-signed session cookie; set `SESSION_KEY` to keep sessions valid across restarts.
- **Service account** — key at `creds/service-account.json` (the directory is gitignored), Sheets API enabled, the spreadsheet shared with it as editor (self-service edits write cells and append to the Change Log tab), the media shared drive shared as content manager (uploads create files and archive old versions).
- **Maps** — a server key for the Geocoding API (`creds/geocoding.key` or `GOOGLE_MAPS_SERVER_KEY`; never rendered into pages, restrict by server IP or leave unrestricted for dev) and a browser key for the Maps JavaScript API (`creds/maps.key` or `GOOGLE_MAPS_BROWSER_KEY`; rendered into pages, restrict by HTTP referer). Geocoding results are cached in memory per address.
- `main.go` — server entry point and app routing
- `internal/data` — data source interface and the CSV sample-data implementation
- `internal/directory` — directory app handlers
- `web/directory` — directory app page templates and static assets
- `tools/screenshot` — dev-site page capture, see [screenshots.md](screenshots.md)
- `tools/columns` — print the column names of each directory table in the configured source
To capture authenticated real-data pages, launch the capture browser (`go run ./tools/capturebrowser`), sign in to the local server there once, and use `tools/browse` or `tools/screenshot -remote` — the session cookie lives in the capture profile.
## Setup
Development happens on macOS. Two Homebrew installs cover everything here and in `docs/screenshots.md`:
brew install go
brew install --cask google-chrome
- **Go** 1.26 or later — builds and runs the server and all tooling (`go run`, `go vet`).
- **Google Chrome** — launched headless by the screenshot tool from its standard install location; never opened by hand.
No Node, no Docker, and no cloud credentials are needed for local development. Repository layout is in the README.
## Tools
Each runs as `go run ./tools/<name>`. The sheet and drive tools authenticate with `creds/service-account.json`.
- `screenshot`, `capturebrowser`, `browse` — page capture and browser driving; see `docs/screenshots.md`
- `startserver` — launch the app detached, wait for it to listen, print the pid, log path, and a minted session cookie (needs `SESSION_KEY` and `DIRECTORY_SHEET`)
- `cookie` — print a signed session cookie for local API testing
- `loadcheck` — run the full load pipeline against a sheet and print a model summary
- `columns` — print each directory table's column names from the configured source
- `findsheet` — list spreadsheets visible to the service account
- `sheets` — dump a sheet's tabs, sizes, and header rows
- `dumptab` / `writetab` — copy one tab to a local CSV / write a local CSV into a tab, header-checked
- `createtabs` — create the directory sheet's local-layer tabs with their header rows
- `setcell` — set one cell in a tab by key column, appending the row if missing
- `probeblob` — time the download of a few drive media files
- `splash` — regenerate the iOS splash battery from the captured original page; see `docs/pwa.md`
+8 -15
View File
@@ -5,11 +5,11 @@ The directory ("Helios Who?") is the community's who's-who: students, parents, a
## Entities
- **Person** — first and last name; role (student, parent, staff); optional pronouns; optional nickname and pronunciation (an audio recording); photo (some people use an illustrated avatar instead); email; role-specific fields:
- *Students*: grade, classroom and team assignment (displayed as a chain, e.g. grade ▶ team ▶ subteam), optional free-text "about me" written by or about the kid.
- *Students*: grade, classroom, and crew (displayed as a chain, e.g. grade ▶ classroom ▶ crew), optional free-text "about me" written by or about the kid.
- *Parents*: their kids (shown as context wherever the parent appears), optional room-parent assignments.
- *Staff*: job title, displayed prominently; staff may have no family record.
- **Family** — the join between adults and kids: combined surname(s), family photo with a caption identifying everyone in it, an optional family-name pronunciation recording, member list split into adults and kids, address, phone. Lists show the city; the full address powers map actions. Families choose how much address to share (full postal address or just the city).
- **Classroom** — name and mascot artwork, the grade band it serves, and its students, staff, and parents. Classrooms nest teams/subteams that student rows reference.
- **Classroom** — name and mascot artwork, the grade band it serves, and its students, staff, and parents. Classrooms nest crews that student rows reference.
- **Grade** — K through 8, grouped into bands (K, 1st/2nd, 3rd/4th, ...) for browsing.
## Navigation
@@ -22,14 +22,14 @@ Sections:
Four tabs, each with search and filter:
- **Everyone** — grid of circular photos. Each card: role label with pronouns (e.g. "PARENT (SHE/HER)"), name, and a context line — kids' names for parents, grade/team chain for students, job title for staff.
- **Students** — larger cards, first name prominent over last name, grade/team chain, pronouns badge.
- **Everyone** — grid of circular photos. Each card: role label with pronouns (e.g. "PARENT (SHE/HER)"), name, and a context line — kids' names for parents, grade/classroom chain for students, job title for staff.
- **Students** — larger cards, first name prominent over last name, grade/classroom chain, pronouns badge.
- **Families** — family-photo cards with grade badges, surname combination, and kids' first names.
- **Staff** — grouped into sections (admin and office staff, teaching staff, ...), title over name.
### Person detail
Breadcrumb back to the list, favorite (heart) toggle, photo, role label with pronouns, name with nickname/pronunciation line, grade/team chain for students, email and address rows with quick actions (message, mail, map). Students add the "about me" paragraph. Below, a contrasting family band: the person's family name, a narrative caption of who's who, kid rows (grade/team, email), adult rows, and a link to the family page.
Breadcrumb back to the list, favorite (heart) toggle, photo, role label with pronouns, name with nickname/pronunciation line, grade/classroom chain for students, email and address rows with quick actions (message, mail, map). Students add the "about me" paragraph. Below, a contrasting family band: the person's family name, a narrative caption of who's who, kid rows (grade/team, email), adult rows, and a link to the family page.
### Family detail
@@ -37,11 +37,11 @@ Family photo with click-to-expand and its identifying caption, grade badges, fam
### Classrooms
Three tabs: browse classrooms by grade band (mascot art, student count, link to detail), the same grouped by classroom, and room parents (parent rows annotated with each of their kids' classroom and grade). Classroom detail shows the mascot, name, and tabbed member lists — students (grouped by team, with parents' names above each student and the about-me blurb inline), staff, and parents — with per-tab counts.
Three tabs: browse classrooms by grade band (mascot art, student count, link to detail), the same grouped by classroom, and room parents (parent rows annotated with each of their kids' classroom and grade). Classroom detail shows the mascot, name, and tabbed member lists — students (grouped by crew, with parents' names above each student and the about-me blurb inline), staff, and parents — with per-tab counts.
### My Family
A shortcut card to the signed-in user's own family page.
Goes straight to the signed-in user's own family page.
### Staff
@@ -55,14 +55,6 @@ A Google map of family locations: one brand-teal pin per geocoded family address
A copyable contact table for party planning and outreach: full name, email, role, grade, classroom. Tabs narrow to parents, students, both, or the user's bookmarked people. Filters select grades or classrooms.
### Data View
A raw tabular view over the underlying records, for power users.
### Share & About
Share the app by SMS or link, an explanation of why photos and facts are collected, a bug-report pointer, and an opt-out form for removing a person's information.
## Behaviors
- Everything is cross-linked: parents ↔ kids ↔ families ↔ classrooms; any person reference navigates to that person.
@@ -70,3 +62,4 @@ Share the app by SMS or link, an explanation of why photos and facts are collect
- Favorites/bookmarks mark people and feed the email list's bookmark tab.
- Photos lazy-load; full-size view on click where the photo is the subject (family pages).
- All data is community-only, behind sign-in; opt-out removes a person on request.
- Self-service: viewing your own record, your kids', or your family page shows inline edit affordances — photo upload, pronunciation recording or upload, About Me text, preferred name, phone, address — plus opt-out for yourself or your kids. Media uploads replace the Drive file and archive the previous version; sheet-backed edits write the Overrides tab and append a Change Log row (see `docs/data.md`).
+6 -10
View File
@@ -1,15 +1,11 @@
# Plan
What remains to build. Current behavior is documented in `docs/dev.md`, `docs/data.md`, `docs/directory.md`, `docs/design.md`, and `docs/pwa.md`.
## Directory app
- Remaining sections: My Family.
- Mobile chrome: brand-teal top bar and bottom tab navigation on narrow screens (see `docs/directory.md`); today only the desktop chrome is faithful.
- Installable-app plumbing: manifest, icons, and meta tags per `docs/pwa.md`.
- Self-service flows: photo and pronunciation upload, address update, opt-out.
What remains to build. Current behavior is documented in `docs/dev.md`, `docs/data.md`, `docs/directory.md`, `docs/design.md`, `docs/pwa.md`, and `docs/deploy.md`.
## Hosting and deployment
- Cloud Run service in the school's project, minimum one instance (media is held in memory; startup is too slow for scale-to-zero).
- Docker build producing the static binary in a minimal base image containing only tzinfo and CA certificates.
- Move from `gen-lang-client-0758114984` to the school's project: recreate the OAuth client there (Internal consent screen, only available inside the school's Workspace org, removes unverified-app friction), plus the service account, secrets, and service; re-share the spreadsheet and media drive with the new service account.
## Import tool
- One command that ingests a Veracross CSV export, rewrites the Veracross Import tab, re-runs the full pipeline, and reports the local layer's health beyond the fatal checks: useless overrides, `-` on flagged rows, name mappings or additions Veracross has made redundant, and media files matching no current person or family (including family blobs orphaned by a membership change). Replaces the manual writetab + loadcheck procedure in `docs/data.md`.
+3 -3
View File
@@ -11,10 +11,10 @@ The directory is used from phone home screens, so Heliosian ships as an installa
## What Heliosian serves
- `manifest.webmanifest` from the binary: name, short name, `display: standalone`, `start_url: /`, theme and background color `#014E54`, icons 192 and 512 as `any` plus maskable variants (maskable art keeps the lockup inside the safe zone on a full-bleed teal square).
- Base template meta: `theme-color`, `viewport` including `viewport-fit=cover`, the two `apple-mobile-web-app-*` tags, and a 180px `apple-touch-icon`.
- `manifest.webmanifest` at `/static/manifest.webmanifest` — under `/static/` because browsers fetch manifests without credentials and that path bypasses the sign-in wall. `scope` and `start_url` are `/`; name, `display: standalone`, theme and background color `#014E54`; icons 192 and 512 as `any` plus a 512 maskable (the maskable art keeps the lockup inside the safe zone on a full-bleed teal square). The server registers the `application/manifest+json` MIME type.
- Both page templates (app and login) carry the manifest link, `theme-color` (light surface on the app page, teal on login), `viewport` including `viewport-fit=cover` and `user-scalable=no`, the two `apple-mobile-web-app-*` tags, 16/32 favicons, and the `apple-touch-icon`.
- HTTPS comes with Cloud Run; installability requires it.
- Splash screens for iOS are pre-rendered at the device-size matrix like the original; until that exists, launches show a plain background, which is acceptable.
- Splash screens for iOS: both templates carry the original's full `apple-touch-startup-image` battery — 32 pre-rendered PNGs (the logo lockup on teal) covering every iPhone/iPad class in both orientations, served from `/static/brand/splash/`. `tools/splash` regenerates them by extracting the link matrix and images from the captured original page source.
- A service worker is optional for install on current Chromium and adds offline shell caching; if added, it stays minimal — cache the static shell, never cache directory data (community data must not persist on shared devices beyond the session's needs).
- `start_url` must resolve for a signed-out user by landing on the sign-in flow, then into the app.
-11
View File
@@ -1,11 +0,0 @@
# Dev environment setup
Development happens on macOS. Two Homebrew installs, and everything in `docs/dev.md` and `docs/screenshots.md` works:
brew install go
brew install --cask google-chrome
- **Go** 1.26 or later — builds and runs the server and all tooling (`go run`, `go vet`).
- **Google Chrome** — launched headless by the screenshot tool; never needs to be opened by hand. The tool finds it in its standard install location automatically.
No Node, no Docker, and no cloud credentials are needed for local development.
+2 -1
View File
@@ -5,6 +5,8 @@ go 1.26
require (
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f
github.com/chromedp/chromedp v0.16.0
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
golang.org/x/image v0.45.0
google.golang.org/api v0.293.0
)
@@ -31,7 +33,6 @@ require (
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/image v0.45.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.47.0 // indirect
+2 -2
View File
@@ -47,6 +47,8 @@ github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhA
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -78,8 +80,6 @@ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+24 -15
View File
@@ -39,6 +39,27 @@ func Email(r *http.Request) string {
return email
}
func Fixed(email string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), contextKey{}, email)))
})
}
func Public(path string) bool {
return path == "/auth/login" || strings.HasPrefix(path, "/static/")
}
func Token(key []byte, email string, expiry time.Time) string {
payload := fmt.Sprintf("%s|%d", email, expiry.Unix())
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + sign(key, payload)
}
func sign(key []byte, payload string) string {
mac := hmac.New(sha256.New, key)
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (a *Auth) Register(mux *http.ServeMux) {
mux.HandleFunc("POST /auth/login", a.login)
mux.HandleFunc("POST /auth/logout", a.logout)
@@ -46,7 +67,7 @@ func (a *Auth) Register(mux *http.ServeMux) {
func (a *Auth) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/auth/login" || strings.HasPrefix(r.URL.Path, "/static/") {
if Public(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
@@ -102,10 +123,9 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
http.Error(w, "account is not in the school domain", http.StatusForbidden)
return
}
expiry := time.Now().Add(sessionLength).Unix()
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: a.token(email, expiry),
Value: Token(a.key, email, time.Now().Add(sessionLength)),
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
@@ -120,17 +140,6 @@ func (a *Auth) logout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (a *Auth) token(email string, expiry int64) string {
payload := fmt.Sprintf("%s|%d", email, expiry)
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + a.sign(payload)
}
func (a *Auth) sign(payload string) string {
mac := hmac.New(sha256.New, a.key)
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (a *Auth) sessionEmail(r *http.Request) string {
cookie, err := r.Cookie(cookieName)
if err != nil {
@@ -145,7 +154,7 @@ func (a *Auth) sessionEmail(r *http.Request) string {
return ""
}
payload := string(decoded)
if !hmac.Equal([]byte(a.sign(payload)), []byte(parts[1])) {
if !hmac.Equal([]byte(sign(a.key, payload)), []byte(parts[1])) {
return ""
}
fields := strings.Split(payload, "|")
+143 -6
View File
@@ -18,12 +18,13 @@ import (
_ "image/gif"
_ "image/png"
"github.com/rwcarlsen/goexif/exif"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
"heliosian/internal/data"
"google.golang.org/api/drive/v3"
"google.golang.org/api/option"
"heliosian/internal/data"
)
const (
@@ -54,7 +55,7 @@ type Store struct {
func New() (*Store, error) {
service, err := drive.NewService(context.Background(),
option.WithCredentialsFile(data.KeyFile),
option.WithScopes(drive.DriveReadonlyScope))
option.WithScopes(drive.DriveScope))
if err != nil {
return nil, err
}
@@ -107,6 +108,9 @@ func (s *Store) refresh() error {
return fmt.Errorf("list %s: %w", folderName, err)
}
for _, f := range list.Files {
if f.MimeType == folderMime {
continue
}
base := strings.TrimSuffix(f.Name, path.Ext(f.Name))
if strings.HasSuffix(base, "-thumb") {
continue
@@ -140,7 +144,13 @@ func (s *Store) refresh() error {
defer wg.Done()
for key := range work {
l := listing[key]
fetchStart := time.Now()
body, err := s.download(l.id)
if err == nil {
log.Printf("blob fetch: %s %d bytes in %s", key, len(body), time.Since(fetchStart).Round(time.Millisecond))
} else {
log.Printf("[ERROR] blob fetch: %s: %v", key, err)
}
if err == nil && strings.HasPrefix(l.mimeType, "image/") {
var thumb []byte
thumb, err = thumbnail(body)
@@ -196,19 +206,85 @@ func (s *Store) refresh() error {
}
func (s *Store) subfolder(name string) (string, error) {
return s.subfolderIn(s.root, name, false)
}
func (s *Store) subfolderIn(parent, name string, create bool) (string, error) {
list, err := s.service.Files.List().
Q(fmt.Sprintf("name = '%s' and '%s' in parents and mimeType = '%s' and trashed = false", name, s.root, folderMime)).
Q(fmt.Sprintf("name = '%s' and '%s' in parents and mimeType = '%s' and trashed = false", name, parent, folderMime)).
SupportsAllDrives(true).IncludeItemsFromAllDrives(true).Corpora("allDrives").
Fields("files(id)").Do()
if err != nil {
return "", fmt.Errorf("find folder %s: %w", name, err)
}
if len(list.Files) == 0 && create {
folder, err := s.service.Files.Create(&drive.File{
Name: name,
MimeType: folderMime,
Parents: []string{parent},
}).SupportsAllDrives(true).Fields("id").Do()
if err != nil {
return "", fmt.Errorf("create folder %s: %w", name, err)
}
return folder.Id, nil
}
if len(list.Files) != 1 {
return "", fmt.Errorf("expected one %s folder, found %d", name, len(list.Files))
}
return list.Files[0].Id, nil
}
func (s *Store) Refresh() error {
return s.refresh()
}
func (s *Store) Has(key string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
_, ok := s.entries[key]
return ok
}
func (s *Store) Upload(folder, base, ext, mimeType string, content []byte) (string, error) {
folderID, err := s.subfolder(folder)
if err != nil {
return "", err
}
archived := ""
s.mu.RLock()
existing, exists := s.entries[folder+"/"+base]
s.mu.RUnlock()
if exists {
current, err := s.service.Files.Get(existing.id).SupportsAllDrives(true).Fields("name").Do()
if err != nil {
return "", fmt.Errorf("look up current %s/%s: %w", folder, base, err)
}
archiveID, err := s.subfolderIn(folderID, "archive", true)
if err != nil {
return "", err
}
archived = strings.TrimSuffix(current.Name, path.Ext(current.Name)) +
"-" + time.Now().UTC().Format("20060102-150405") + path.Ext(current.Name)
_, err = s.service.Files.Update(existing.id, &drive.File{Name: archived}).
AddParents(archiveID).RemoveParents(folderID).SupportsAllDrives(true).Do()
if err != nil {
return "", fmt.Errorf("archive %s/%s: %w", folder, base, err)
}
}
_, err = s.service.Files.Create(&drive.File{
Name: base + "." + ext,
MimeType: mimeType,
Parents: []string{folderID},
}).SupportsAllDrives(true).Media(bytes.NewReader(content)).Do()
if err != nil {
return "", fmt.Errorf("upload %s/%s: %w", folder, base, err)
}
if err := s.refresh(); err != nil {
return "", fmt.Errorf("refresh after upload: %w", err)
}
return archived, nil
}
func (s *Store) download(id string) ([]byte, error) {
resp, err := s.service.Files.Get(id).SupportsAllDrives(true).Download()
if err != nil {
@@ -245,16 +321,77 @@ func thumbnail(src []byte) ([]byte, error) {
if err != nil {
return nil, err
}
o := orientation(src)
bounds := img.Bounds()
if bounds.Dx() > thumbWidth {
height := bounds.Dy() * thumbWidth / bounds.Dx()
scaled := image.NewRGBA(image.Rect(0, 0, thumbWidth, height))
displayWidth := bounds.Dx()
if o >= 5 {
displayWidth = bounds.Dy()
}
if displayWidth > thumbWidth {
w := bounds.Dx() * thumbWidth / displayWidth
h := bounds.Dy() * thumbWidth / displayWidth
scaled := image.NewRGBA(image.Rect(0, 0, w, h))
draw.CatmullRom.Scale(scaled, scaled.Bounds(), img, bounds, draw.Over, nil)
img = scaled
}
img = reorient(img, o)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func orientation(src []byte) (o int) {
o = 1
defer func() { recover() }()
parsed, err := exif.Decode(bytes.NewReader(src))
if err != nil {
return
}
tag, err := parsed.Get(exif.Orientation)
if err != nil {
return
}
value, err := tag.Int(0)
if err != nil || value < 1 || value > 8 {
return
}
return value
}
func reorient(img image.Image, o int) image.Image {
if o == 1 {
return img
}
b := img.Bounds()
w, h := b.Dx(), b.Dy()
dw, dh := w, h
if o >= 5 {
dw, dh = h, w
}
out := image.NewRGBA(image.Rect(0, 0, dw, dh))
for y := range h {
for x := range w {
var dx, dy int
switch o {
case 2:
dx, dy = w-1-x, y
case 3:
dx, dy = w-1-x, h-1-y
case 4:
dx, dy = x, h-1-y
case 5:
dx, dy = y, x
case 6:
dx, dy = h-1-y, x
case 7:
dx, dy = h-1-y, w-1-x
case 8:
dx, dy = y, w-1-x
}
out.Set(dx, dy, img.At(b.Min.X+x, b.Min.Y+y))
}
}
return out
}
+11 -16
View File
@@ -3,40 +3,35 @@ package data
import (
"encoding/csv"
"fmt"
"os"
"path/filepath"
)
type Source interface {
Table(app, name string) ([]map[string]string, error)
Table(app, name string) ([]string, []map[string]string, error)
}
type Dir struct {
Root string
}
func (d Dir) Table(app, name string) ([]map[string]string, error) {
func (d Dir) Table(app, name string) ([]string, []map[string]string, error) {
f, err := os.Open(filepath.Join(d.Root, app, name+".csv"))
if err != nil {
return nil, err
return nil, nil, err
}
defer f.Close()
rows, err := csv.NewReader(f).ReadAll()
if err != nil {
return nil, err
return nil, nil, err
}
if len(rows) == 0 {
return nil, fmt.Errorf("table %s/%s has no header row", app, name)
values := make([][]interface{}, len(rows))
for i, row := range rows {
cells := make([]interface{}, len(row))
for j, cell := range row {
cells[j] = cell
}
header := rows[0]
records := []map[string]string{}
for _, row := range rows[1:] {
record := map[string]string{}
for i, column := range header {
record[column] = row[i]
values[i] = cells
}
records = append(records, record)
}
return records, nil
return parseTable(name, values)
}
+128 -10
View File
@@ -19,32 +19,150 @@ type Sheet struct {
func NewSheet(spreadsheets map[string]string) (*Sheet, error) {
service, err := sheets.NewService(context.Background(),
option.WithCredentialsFile(KeyFile),
option.WithScopes(sheets.SpreadsheetsReadonlyScope))
option.WithScopes(sheets.SpreadsheetsScope))
if err != nil {
return nil, err
}
return &Sheet{service: service, spreadsheets: spreadsheets}, nil
}
func (s *Sheet) Table(app, name string) ([]map[string]string, error) {
func (s *Sheet) Table(app, name string) ([]string, []map[string]string, error) {
id, ok := s.spreadsheets[app]
if !ok {
return nil, fmt.Errorf("no spreadsheet configured for app %q", app)
return nil, nil, fmt.Errorf("no spreadsheet configured for app %q", app)
}
resp, err := s.service.Spreadsheets.Values.Get(id, "'"+strings.ReplaceAll(name, "'", "''")+"'").Do()
resp, err := s.service.Spreadsheets.Values.Get(id, quoteTab(name)).Do()
if err != nil {
return nil, err
return nil, nil, err
}
return toRecords(resp.Values), nil
return parseTable(name, resp.Values)
}
func toRecords(values [][]interface{}) []map[string]string {
func (s *Sheet) Upsert(app, table, keyColumn, keyValue, column, value string) error {
id, ok := s.spreadsheets[app]
if !ok {
return fmt.Errorf("no spreadsheet configured for app %q", app)
}
quoted := quoteTab(table)
resp, err := s.service.Spreadsheets.Values.Get(id, quoted).Do()
if err != nil {
return err
}
if len(resp.Values) == 0 {
return fmt.Errorf("table %s is empty", table)
}
keyIdx, colIdx := -1, -1
for i, cell := range resp.Values[0] {
switch strings.TrimSpace(fmt.Sprint(cell)) {
case keyColumn:
keyIdx = i
case column:
colIdx = i
}
}
if keyIdx < 0 || colIdx < 0 {
return fmt.Errorf("table %s is missing column %q or %q", table, keyColumn, column)
}
ranges := []*sheets.ValueRange{}
for i, row := range resp.Values[1:] {
if keyIdx >= len(row) || !strings.EqualFold(strings.TrimSpace(fmt.Sprint(row[keyIdx])), keyValue) {
continue
}
ranges = append(ranges, &sheets.ValueRange{
Range: fmt.Sprintf("%s!%s%d", quoted, columnName(colIdx), i+2),
Values: [][]interface{}{{value}},
})
}
if len(ranges) == 0 {
width := max(keyIdx, colIdx) + 1
row := make([]interface{}, width)
for i := range row {
row[i] = ""
}
row[keyIdx] = keyValue
row[colIdx] = value
_, err := s.service.Spreadsheets.Values.Append(id, quoted, &sheets.ValueRange{
Values: [][]interface{}{row},
}).ValueInputOption("RAW").InsertDataOption("INSERT_ROWS").Do()
return err
}
_, err = s.service.Spreadsheets.Values.BatchUpdate(id, &sheets.BatchUpdateValuesRequest{
ValueInputOption: "RAW",
Data: ranges,
}).Do()
return err
}
func (s *Sheet) Append(app, table string, header, row []string) error {
id, ok := s.spreadsheets[app]
if !ok {
return fmt.Errorf("no spreadsheet configured for app %q", app)
}
meta, err := s.service.Spreadsheets.Get(id).Fields("sheets(properties(title))").Do()
if err != nil {
return err
}
exists := false
for _, sh := range meta.Sheets {
if sh.Properties.Title == table {
exists = true
break
}
}
quoted := quoteTab(table)
if !exists {
_, err := s.service.Spreadsheets.BatchUpdate(id, &sheets.BatchUpdateSpreadsheetRequest{
Requests: []*sheets.Request{{AddSheet: &sheets.AddSheetRequest{
Properties: &sheets.SheetProperties{Title: table},
}}},
}).Do()
if err != nil {
return fmt.Errorf("create table %s: %w", table, err)
}
if err := s.appendRow(id, quoted, header); err != nil {
return err
}
}
return s.appendRow(id, quoted, row)
}
func (s *Sheet) appendRow(id, quotedTable string, row []string) error {
values := make([]interface{}, len(row))
for i, cell := range row {
values[i] = cell
}
_, err := s.service.Spreadsheets.Values.Append(id, quotedTable, &sheets.ValueRange{
Values: [][]interface{}{values},
}).ValueInputOption("RAW").InsertDataOption("INSERT_ROWS").Do()
return err
}
func quoteTab(title string) string {
return "'" + strings.ReplaceAll(title, "'", "''") + "'"
}
func columnName(idx int) string {
name := ""
for idx >= 0 {
name = string(rune('A'+idx%26)) + name
idx = idx/26 - 1
}
return name
}
func parseTable(name string, values [][]interface{}) ([]string, []map[string]string, error) {
if len(values) == 0 {
return nil
return nil, nil, fmt.Errorf("table %s has no header row", name)
}
header := make([]string, len(values[0]))
seen := map[string]bool{}
for i, cell := range values[0] {
header[i] = strings.TrimSpace(fmt.Sprint(cell))
h := strings.TrimSpace(fmt.Sprint(cell))
if h != "" && seen[h] {
return nil, nil, fmt.Errorf("table %s has duplicate header %q", name, h)
}
seen[h] = true
header[i] = h
}
records := []map[string]string{}
for _, row := range values[1:] {
@@ -63,5 +181,5 @@ func toRecords(values [][]interface{}) []map[string]string {
records = append(records, record)
}
}
return records
return header, records, nil
}
+25 -13
View File
@@ -11,15 +11,21 @@ import (
const refreshInterval = 5 * time.Minute
type Geocoder interface {
Lookup(address string) (geocode.Point, error)
}
type Cache struct {
source data.Source
geocoder *geocode.Client
geocoder Geocoder
blobs BlobChecker
static BlobChecker
mu sync.RWMutex
model *Model
}
func NewCache(source data.Source, geocoder *geocode.Client) (*Cache, error) {
c := &Cache{source: source, geocoder: geocoder}
func NewCache(source data.Source, geocoder Geocoder, blobs, static BlobChecker) (*Cache, error) {
c := &Cache{source: source, geocoder: geocoder, blobs: blobs, static: static}
if err := c.refresh(); err != nil {
return nil, err
}
@@ -27,6 +33,10 @@ func NewCache(source data.Source, geocoder *geocode.Client) (*Cache, error) {
return c, nil
}
func (c *Cache) Refresh() error {
return c.refresh()
}
func (c *Cache) Model() *Model {
c.mu.RLock()
defer c.mu.RUnlock()
@@ -43,7 +53,7 @@ func (c *Cache) refreshLoop() {
func (c *Cache) refresh() error {
start := time.Now()
model, err := LoadModel(c.source)
model, err := LoadModel(c.source, c.blobs, c.static)
if err != nil {
return err
}
@@ -51,8 +61,8 @@ func (c *Cache) refresh() error {
c.mu.Lock()
c.model = model
c.mu.Unlock()
log.Printf("loaded directory model: %d people, %d families, %d classrooms, %d sections in %s",
len(model.People), len(model.Families), len(model.Classrooms), len(model.Sections),
log.Printf("loaded directory model: %d people, %d families, %d classrooms, %d crews in %s",
len(model.People), len(model.Families), len(model.Classrooms), len(model.Crews),
time.Since(start).Round(time.Millisecond))
return nil
}
@@ -63,6 +73,12 @@ func (c *Cache) geocodeFamilies(model *Model) {
key string
address string
}
pending := []job{}
for key, family := range model.Families {
if family.Address != "" {
pending = append(pending, job{key: key, address: family.Address})
}
}
jobs := make(chan job)
var wg sync.WaitGroup
var mu sync.Mutex
@@ -87,14 +103,10 @@ func (c *Cache) geocodeFamilies(model *Model) {
}
}()
}
total := 0
for key, family := range model.Families {
if family.Address != "" {
total++
jobs <- job{key: key, address: family.Address}
}
for _, j := range pending {
jobs <- j
}
close(jobs)
wg.Wait()
log.Printf("geocoded %d of %d family addresses in %s", located, total, time.Since(start).Round(time.Millisecond))
log.Printf("geocoded %d of %d family addresses in %s", located, len(pending), time.Since(start).Round(time.Millisecond))
}
+29 -2
View File
@@ -6,12 +6,13 @@ import (
"html/template"
"log"
"net/http"
"net/url"
"strings"
"heliosian/internal/auth"
)
var sections = []string{"people", "classrooms", "my-family", "staff", "map", "email-list"}
var sections = []string{"people", "classrooms", "staff", "map", "email-list"}
var legacy = map[string]string{
"people": "/people",
@@ -27,12 +28,26 @@ type app struct {
mapsKey string
}
func MemberGate(cache *Cache, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if auth.Public(r.URL.Path) || r.URL.Path == "/auth/logout" {
next.ServeHTTP(w, r)
return
}
if !cache.Model().Member(strings.ToLower(auth.Email(r))) {
http.Error(w, "account is not in the directory", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func Register(mux *http.ServeMux, cache *Cache, mapsKey string) {
a := app{cache: cache, mapsKey: mapsKey}
for _, section := range sections {
mux.HandleFunc("GET /"+section, a.page)
}
mux.HandleFunc("GET /profile", a.page)
mux.HandleFunc("GET /my-family", a.myFamily)
mux.HandleFunc("GET /people/{email}", a.page)
mux.HandleFunc("GET /families/{key}", a.page)
mux.HandleFunc("GET /classrooms/{name}", a.page)
@@ -41,6 +56,18 @@ func Register(mux *http.ServeMux, cache *Cache, mapsKey string) {
mux.HandleFunc("GET /api/directory/model", a.model)
}
func (a app) myFamily(w http.ResponseWriter, r *http.Request) {
model := a.cache.Model()
email := auth.Email(r)
if p := model.Person(email); p != nil && p.FamilyKey != "" {
if _, ok := model.Families[p.FamilyKey]; ok {
http.Redirect(w, r, "/families/"+url.PathEscape(p.FamilyKey), http.StatusFound)
return
}
}
http.Error(w, "no family record for "+email, http.StatusNotFound)
}
func (a app) legacyRedirect(w http.ResponseWriter, r *http.Request) {
first, _, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/dl/"), "/")
target, ok := legacy[first]
+664 -144
View File
@@ -1,8 +1,12 @@
package directory
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"heliosian/internal/data"
@@ -10,195 +14,707 @@ import (
const appName = "directory"
func LoadModel(source data.Source) (*Model, error) {
tables := map[string][]map[string]string{}
for _, name := range []string{"Basic Directory", "Staff Details", "Classrooms", "Schedules", "Grade Lookup", "Room Parents", "Departments"} {
rows, err := source.Table(appName, name)
if err != nil {
var gradeOrder = []string{
"Kindergarten", "Grade 1", "Grade 2", "Grade 3", "Grade 4",
"Grade 5", "Grade 6", "Grade 7", "Grade 8",
}
var gradeBands = map[string]string{
"Kindergarten": "Hummingbirds",
"Grade 1": "Halcons",
"Grade 2": "Halcons",
"Grade 3": "Jayvens",
"Grade 4": "Jayvens",
"Grade 5": "Cospreys",
"Grade 6": "Cospreys",
"Grade 7": "Hegrets",
"Grade 8": "Hegrets",
}
var departmentOrder = []string{
"Admin and Office Staff",
"Co-Curriculars and Specialists",
"Classroom Teachers",
"Facilities Staff",
}
var importColumns = []string{
"entry_sort_name", "student_full_name", "student_classifications", "student_email", "student_phone_mobile",
}
var overrideColumns = []string{
"Email", "Added", "Full Name", "Legal Name", "Preferred Name",
"Is Student", "Is Parent", "Is Staff", "New to Helios", "Pronouns", "Facts",
"Grade", "Classroom", "Crew", "Phone", "Job Title", "Department", "Grade Band", "Room Parent",
"Address", "Family Phone", "Family Photo Caption", "Opted Out",
}
type BlobChecker interface {
Has(key string) bool
}
type parsedName struct {
display, legal, preferred string
}
var nameForm = regexp.MustCompile(`^(.+?) \((.+?)\) (.+)$`)
func parseName(raw string) parsedName {
raw = strings.Join(strings.Fields(raw), " ")
m := nameForm.FindStringSubmatch(raw)
if m == nil {
return parsedName{display: raw, legal: raw}
}
return parsedName{display: m[1] + " " + m[3], legal: m[2] + " " + m[3], preferred: m[1]}
}
func splitHomeroom(homeroom string) (classroom, crew string) {
fields := strings.Fields(homeroom)
if len(fields) == 1 {
return homeroom, ""
}
return fields[len(fields)-1], strings.Join(fields[:len(fields)-1], " ")
}
func normName(raw string) string {
return strings.ToLower(strings.Join(strings.Fields(raw), " "))
}
type household struct {
adults []string
kids []string
address string
phone string
}
type familyCells struct {
address, phone, caption string
hasAddress, hasPhone, hasCaption bool
}
func requireColumns(table string, header, wanted []string) error {
present := map[string]bool{}
for _, h := range header {
present[h] = true
}
for _, w := range wanted {
if !present[w] {
return fmt.Errorf("table %s is missing column %q", table, w)
}
}
return nil
}
func familyHash(members []string) string {
sorted := append([]string{}, members...)
sort.Strings(sorted)
sum := sha256.Sum256([]byte(strings.Join(sorted, "\n")))
return hex.EncodeToString(sum[:])[:16]
}
type loader struct {
blobs BlobChecker
static BlobChecker
importRows []map[string]string
overrideRows []map[string]string
nameToEmail map[string]string
people map[string]*Person
order []string
households map[string]*household
householdOrder []string
personHouseholds map[string][]string
familyKeys map[string]string
familyOverrides map[string]familyCells
roomParents map[string][]string
optedOut map[string]bool
model *Model
}
func LoadModel(source data.Source, blobs, static BlobChecker) (*Model, error) {
l := &loader{
blobs: blobs,
static: static,
people: map[string]*Person{},
households: map[string]*household{},
personHouseholds: map[string][]string{},
familyKeys: map[string]string{},
familyOverrides: map[string]familyCells{},
roomParents: map[string][]string{},
optedOut: map[string]bool{},
model: &Model{Families: map[string]Family{}, RoomParents: map[string][]string{}},
}
steps := []func() error{
func() error { return l.readTables(source) },
l.transformImport,
l.applyOverrides,
l.buildFamilies,
l.removeOptedOut,
l.attachBlobs,
l.sortPeople,
l.deriveClassrooms,
l.deriveStructure,
}
for _, step := range steps {
if err := step(); err != nil {
return nil, err
}
tables[name] = rows
}
return l.model, nil
}
model := &Model{Families: map[string]Family{}, RoomParents: map[string][]string{}}
staffByEmail := map[string]map[string]string{}
for _, row := range tables["Staff Details"] {
email := strings.ToLower(row["Email Lower"])
if email != "" {
staffByEmail[email] = row
func (l *loader) readTables(source data.Source) error {
importHeader, importRows, err := source.Table(appName, "Veracross Import")
if err != nil {
return err
}
if err := requireColumns("Veracross Import", importHeader, importColumns); err != nil {
return err
}
l.importRows = importRows
mapHeader, mapRows, err := source.Table(appName, "Name to Email")
if err != nil {
return err
}
if err := requireColumns("Name to Email", mapHeader, []string{"Name", "Email"}); err != nil {
return err
}
overrideHeader, overrideRows, err := source.Table(appName, "Overrides")
if err != nil {
return err
}
if err := requireColumns("Overrides", overrideHeader, overrideColumns); err != nil {
return err
}
l.overrideRows = overrideRows
l.nameToEmail = map[string]string{}
for _, row := range mapRows {
name, email := normName(row["Name"]), strings.ToLower(row["Email"])
if name == "" || email == "" {
return fmt.Errorf("name to email row %v is incomplete", row)
}
if _, ok := l.nameToEmail[name]; ok {
return fmt.Errorf("name to email has duplicate name %q", row["Name"])
}
l.nameToEmail[name] = email
}
return nil
}
type familyAcc struct {
family Family
hasParent bool
hasStudent bool
func (l *loader) addAdult(rawName, email, phone string) error {
n := parseName(rawName)
if p, ok := l.people[email]; ok {
if p.FullName != n.display || p.LegalName != n.legal || p.PreferredName != n.preferred {
return fmt.Errorf("adult %s has conflicting names %q and %q", email, p.FullName, rawName)
}
if p.Phone != "" && phone != "" && p.Phone != phone {
return fmt.Errorf("adult %s has conflicting phones %q and %q", email, p.Phone, phone)
}
if p.Phone == "" {
p.Phone = phone
}
p.IsParent = true
return nil
}
l.people[email] = &Person{
Email: email, FullName: n.display, LegalName: n.legal, PreferredName: n.preferred,
Phone: phone, IsParent: true,
}
l.order = append(l.order, email)
return nil
}
func (l *loader) transformImport() error {
mappingUses := map[string]int{}
for _, row := range l.importRows {
rawName := row["student_full_name"]
if rawName == "" {
return fmt.Errorf("import row %v has no student name", row)
}
var classifications struct {
GradeLevel string `json:"grade_level"`
Homeroom string `json:"homeroom"`
}
if err := json.Unmarshal([]byte(row["student_classifications"]), &classifications); err != nil {
return fmt.Errorf("student %s classifications: %w", rawName, err)
}
if gradeBands[classifications.GradeLevel] == "" {
return fmt.Errorf("student %s has unknown grade %q", rawName, classifications.GradeLevel)
}
if classifications.Homeroom == "" {
return fmt.Errorf("student %s has no homeroom", rawName)
}
classroom, crew := splitHomeroom(classifications.Homeroom)
email := strings.ToLower(row["student_email"])
if mapped, ok := l.nameToEmail[normName(rawName)]; ok {
email = mapped
mappingUses[normName(rawName)]++
}
families := map[string]*familyAcc{}
byEmail := map[string]Person{}
for _, row := range tables["Basic Directory"] {
email := strings.ToLower(row["Email Lower"])
if email == "" {
continue
return fmt.Errorf("student %s has no email and no name to email entry", rawName)
}
p := Person{
Email: email,
FullName: row["Full Name"],
LegalName: row["Legal Name"],
PreferredName: row["Preferred Name"],
IsStaff: row["Is Staff?"] == "TRUE",
IsParent: row["Is Parent?"] == "TRUE",
IsStudent: row["Is Student?"] == "TRUE",
IsNew: row["Is Totally New"] == "TRUE",
Pronouns: row["Pronouns"],
Facts: row["Facts"],
PronunciationURL: blobURL("people", email, "pronunciation", row["Pronunciation"]),
PhotoURL: blobURL("people", email, "photo", row["Primary Photo"]),
Grade: row["Grade"],
Classroom: row["Class"],
Section: row["Section"],
Phone: row["Phone Number"],
FamilyKey: strings.ToLower(row["Family Key"]),
if _, ok := l.people[email]; ok {
return fmt.Errorf("student email %s appears twice", email)
}
for _, contact := range strings.Split(row["Parent Contact Emails"], ",") {
if contact = strings.ToLower(strings.TrimSpace(contact)); contact != "" {
p.ParentContactEmails = append(p.ParentContactEmails, contact)
n := parseName(rawName)
student := &Person{
Email: email, FullName: n.display, LegalName: n.legal, PreferredName: n.preferred,
IsStudent: true, Grade: classifications.GradeLevel, Classroom: classroom, Crew: crew,
Phone: row["student_phone_mobile"],
}
}
if details, ok := staffByEmail[email]; ok {
p.JobTitle = details["Job Title"]
p.Department = details["Department"]
p.GradeBand = details["Grade Band"]
}
model.People = append(model.People, p)
byEmail[email] = p
l.people[email] = student
l.order = append(l.order, email)
if p.FamilyKey == "" {
for _, hn := range []string{"1", "2"} {
adults := []string{}
for _, pn := range []string{"1", "2"} {
prefix := "household_" + hn + "_person_" + pn + "_"
if row[prefix+"full_name"] == "" {
continue
}
acc, ok := families[p.FamilyKey]
adultEmail := strings.ToLower(row[prefix+"email"])
if adultEmail == "" {
return fmt.Errorf("adult %q of student %s has no email", row[prefix+"full_name"], rawName)
}
phone := row[prefix+"phone_mobile"]
if phone == "" {
phone = row[prefix+"phone_business"]
}
if err := l.addAdult(row[prefix+"full_name"], adultEmail, phone); err != nil {
return err
}
adults = append(adults, adultEmail)
}
if len(adults) == 0 {
continue
}
address, phone := row["household_"+hn+"_address"], row["household_"+hn+"_phone"]
setKey := strings.Join(func() []string {
s := append([]string{}, adults...)
sort.Strings(s)
return s
}(), "\n")
hh, ok := l.households[setKey]
if !ok {
acc = &familyAcc{family: Family{Key: p.FamilyKey}}
families[p.FamilyKey] = acc
hh = &household{adults: adults, address: address, phone: phone}
l.households[setKey] = hh
l.householdOrder = append(l.householdOrder, setKey)
for _, a := range adults {
if len(l.personHouseholds[a]) > 0 {
return fmt.Errorf("adult %s belongs to more than one household", a)
}
if acc.family.Address == "" && row["Address 1"] != "" {
acc.family.Address = row["Address 1"]
if row["Address 2"] != "" {
acc.family.Address += ", " + row["Address 2"]
l.personHouseholds[a] = append(l.personHouseholds[a], setKey)
}
} else if hh.address != address || hh.phone != phone {
return fmt.Errorf("household of %v has conflicting address or phone across rows", adults)
}
if acc.family.PhotoURL == "" {
acc.family.PhotoURL = blobURL("families", p.FamilyKey, "photo", row["Family Photo"])
hh.kids = append(hh.kids, email)
l.personHouseholds[email] = append(l.personHouseholds[email], setKey)
student.ParentContactEmails = append(student.ParentContactEmails, adults...)
}
if acc.family.PhotoCaption == "" {
acc.family.PhotoCaption = row["Family Photo Description"]
}
if acc.family.PronunciationURL == "" {
acc.family.PronunciationURL = blobURL("families", p.FamilyKey, "pronunciation", row["Family Pronunciation"])
}
if p.IsParent {
acc.hasParent = true
acc.family.AdultEmails = append(acc.family.AdultEmails, email)
}
if p.IsStudent {
acc.hasStudent = true
acc.family.KidEmails = append(acc.family.KidEmails, email)
}
}
for key, acc := range families {
if !acc.hasParent && !acc.hasStudent {
continue
}
acc.family.Name = familyName(acc.family, byEmail)
model.Families[key] = acc.family
}
sort.Slice(model.People, func(i, j int) bool {
si, sj := surname(model.People[i].FullName), surname(model.People[j].FullName)
for name := range l.nameToEmail {
switch mappingUses[name] {
case 0:
return fmt.Errorf("name to email entry %q matches no import row", name)
case 1:
default:
return fmt.Errorf("name to email entry %q matches %d import rows", name, mappingUses[name])
}
}
return nil
}
func (l *loader) applyOverrides() error {
bandSet := map[string]bool{}
for _, band := range gradeBands {
bandSet[band] = true
}
seen := map[string]bool{}
for _, row := range l.overrideRows {
email := strings.ToLower(row["Email"])
if email == "" {
return fmt.Errorf("overrides row %v has no email", row)
}
if seen[email] {
return fmt.Errorf("overrides has duplicate email %s", email)
}
seen[email] = true
added := row["Added"] == "TRUE"
p, exists := l.people[email]
if added && exists {
return fmt.Errorf("overrides row %s is flagged added but the import covers this person", email)
}
if !added && !exists {
return fmt.Errorf("overrides row %s matches no imported person", email)
}
if added {
p = &Person{Email: email}
l.people[email] = p
l.order = append(l.order, email)
}
apply := func(cell string, field *string) {
switch cell {
case "":
case "-":
*field = ""
default:
*field = cell
}
}
applyBool := func(column string, field *bool) error {
switch row[column] {
case "":
case "-", "FALSE":
*field = false
case "TRUE":
*field = true
default:
return fmt.Errorf("overrides row %s has invalid %s %q", email, column, row[column])
}
return nil
}
apply(row["Full Name"], &p.FullName)
apply(row["Legal Name"], &p.LegalName)
apply(row["Preferred Name"], &p.PreferredName)
for column, field := range map[string]*bool{
"Is Student": &p.IsStudent, "Is Parent": &p.IsParent, "Is Staff": &p.IsStaff, "New to Helios": &p.IsNew,
} {
if err := applyBool(column, field); err != nil {
return err
}
}
apply(row["Pronouns"], &p.Pronouns)
apply(row["Facts"], &p.Facts)
if cell := row["Grade"]; cell != "" && cell != "-" && !added && gradeBands[cell] == "" {
return fmt.Errorf("overrides row %s has unknown grade %q", email, cell)
}
apply(row["Grade"], &p.Grade)
apply(row["Classroom"], &p.Classroom)
apply(row["Crew"], &p.Crew)
apply(row["Phone"], &p.Phone)
apply(row["Job Title"], &p.JobTitle)
apply(row["Department"], &p.Department)
if cell := row["Grade Band"]; cell != "" && cell != "-" && !bandSet[cell] {
return fmt.Errorf("overrides row %s has unknown grade band %q", email, cell)
}
apply(row["Grade Band"], &p.GradeBand)
if cell := row["Room Parent"]; cell != "" && cell != "-" {
if !bandSet[cell] {
return fmt.Errorf("overrides row %s has unknown room parent band %q", email, cell)
}
l.roomParents[cell] = append(l.roomParents[cell], email)
}
cells := familyCells{}
if cell := row["Address"]; cell != "" {
cells.hasAddress = true
if cell != "-" {
cells.address = cell
}
}
if cell := row["Family Phone"]; cell != "" {
cells.hasPhone = true
if cell != "-" {
cells.phone = cell
}
}
if cell := row["Family Photo Caption"]; cell != "" {
cells.hasCaption = true
if cell != "-" {
cells.caption = cell
}
}
if cells.hasAddress || cells.hasPhone || cells.hasCaption {
l.familyOverrides[email] = cells
}
switch row["Opted Out"] {
case "", "-", "FALSE":
case "TRUE":
l.optedOut[email] = true
default:
return fmt.Errorf("overrides row %s has invalid Opted Out %q", email, row["Opted Out"])
}
if added {
if p.FullName == "" {
return fmt.Errorf("added row %s has no full name", email)
}
if !p.IsStudent && !p.IsParent && !p.IsStaff {
return fmt.Errorf("added row %s has no role", email)
}
}
}
return nil
}
func (l *loader) buildFamilies() error {
for _, setKey := range l.householdOrder {
hh := l.households[setKey]
members := append(append([]string{}, hh.adults...), hh.kids...)
key := familyHash(members)
l.familyKeys[setKey] = key
l.model.Families[key] = Family{
Key: key,
Address: hh.address,
Phone: hh.phone,
AdultEmails: hh.adults,
KidEmails: hh.kids,
}
}
for email, sets := range l.personHouseholds {
if p := l.people[email]; p.FamilyKey == "" {
p.FamilyKey = l.familyKeys[sets[0]]
}
}
for email, cells := range l.familyOverrides {
p := l.people[email]
if !p.IsParent {
return fmt.Errorf("overrides row %s has family cells but %s is not a parent", email, email)
}
sets := l.personHouseholds[email]
if len(sets) != 1 {
return fmt.Errorf("overrides row %s has family cells but %s has no household", email, email)
}
key := l.familyKeys[sets[0]]
family := l.model.Families[key]
if cells.hasAddress {
family.Address = cells.address
}
if cells.hasPhone {
family.Phone = cells.phone
}
if cells.hasCaption {
family.PhotoCaption = cells.caption
}
l.model.Families[key] = family
}
return nil
}
func (l *loader) removeOptedOut() error {
for email := range l.optedOut {
delete(l.people, email)
}
kept := []string{}
for _, email := range l.order {
if !l.optedOut[email] {
kept = append(kept, email)
}
}
l.order = kept
for key, family := range l.model.Families {
family.AdultEmails = without(family.AdultEmails, l.optedOut)
family.KidEmails = without(family.KidEmails, l.optedOut)
if len(family.AdultEmails)+len(family.KidEmails) == 0 {
delete(l.model.Families, key)
continue
}
family.Name = familyNameFor(family, l.people)
l.model.Families[key] = family
}
for _, p := range l.people {
p.ParentContactEmails = without(p.ParentContactEmails, l.optedOut)
}
for band, emails := range l.roomParents {
l.roomParents[band] = without(emails, l.optedOut)
}
return nil
}
func (l *loader) attachBlobs() error {
if l.blobs == nil {
return nil
}
for _, p := range l.people {
local, _, _ := strings.Cut(p.Email, "@")
if l.blobs.Has("people/" + local + "-photo") {
p.PhotoURL = "/blob/people/" + local + "-photo"
}
if l.blobs.Has("people/" + local + "-pronunciation") {
p.PronunciationURL = "/blob/people/" + local + "-pronunciation"
}
}
for key, family := range l.model.Families {
if l.blobs.Has("families/" + key + "-photo") {
family.PhotoURL = "/blob/families/" + key + "-photo"
}
if l.blobs.Has("families/" + key + "-pronunciation") {
family.PronunciationURL = "/blob/families/" + key + "-pronunciation"
}
l.model.Families[key] = family
}
return nil
}
func (l *loader) sortPeople() error {
for _, email := range l.order {
l.model.People = append(l.model.People, *l.people[email])
}
sort.Slice(l.model.People, func(i, j int) bool {
si, sj := surname(l.model.People[i].FullName), surname(l.model.People[j].FullName)
if si != sj {
return si < sj
}
return model.People[i].FullName < model.People[j].FullName
return l.model.People[i].FullName < l.model.People[j].FullName
})
l.model.byEmail = map[string]int{}
for i, p := range l.model.People {
l.model.byEmail[p.Email] = i
}
return nil
}
for _, row := range tables["Classrooms"] {
if row["Class"] == "" {
type classroomInfo struct {
crews map[string]bool
minGrade int
bands map[string]bool
}
func (l *loader) deriveClassrooms() error {
model := l.model
classrooms := map[string]*classroomInfo{}
for _, p := range model.People {
if p.Classroom == "" || !p.IsStudent || gradeBands[p.Grade] == "" {
continue
}
info, ok := classrooms[p.Classroom]
if !ok {
info = &classroomInfo{crews: map[string]bool{}, minGrade: len(gradeOrder), bands: map[string]bool{}}
classrooms[p.Classroom] = info
}
if p.Crew != "" {
info.crews[p.Crew] = true
}
for i, g := range gradeOrder {
if g == p.Grade && i < info.minGrade {
info.minGrade = i
}
}
info.bands[gradeBands[p.Grade]] = true
}
classroomNames := []string{}
for name := range classrooms {
classroomNames = append(classroomNames, name)
}
sort.Slice(classroomNames, func(i, j int) bool {
ci, cj := classrooms[classroomNames[i]], classrooms[classroomNames[j]]
if ci.minGrade != cj.minGrade {
return ci.minGrade < cj.minGrade
}
return classroomNames[i] < classroomNames[j]
})
for _, name := range classroomNames {
info := classrooms[name]
if len(info.bands) != 1 {
return fmt.Errorf("classroom %s spans multiple grade bands", name)
}
imageURL := ""
if row["Classroom Image"] != "" {
imageURL = "/static/brand/classrooms/classroom-" + strings.ToLower(row["Class"]) + ".jpg"
imageKey := "brand/classrooms/classroom-" + strings.ToLower(name) + ".jpg"
if l.static.Has(imageKey) {
imageURL = "/static/" + imageKey
}
model.Classrooms = append(model.Classrooms, Classroom{
Name: row["Class"],
Name: name,
ImageURL: imageURL,
HasSections: row["Has Sections"] == "TRUE",
HasCrews: len(info.crews) > 0,
})
}
for _, row := range tables["Schedules"] {
if row["Classroom"] == "" {
for _, p := range model.People {
if !p.IsStaff || p.Classroom == "" {
continue
}
section := Section{Classroom: row["Classroom"], Name: row["Section"], GradeBand: row["Grade Band"]}
for _, column := range []string{"Teacher 1", "Teacher 2", "Teacher 3"} {
if row[column] != "" {
section.Teachers = append(section.Teachers, row[column])
info, ok := classrooms[p.Classroom]
if !ok {
return fmt.Errorf("staff %s is assigned to unknown classroom %q", p.Email, p.Classroom)
}
if p.Crew != "" && !info.crews[p.Crew] {
return fmt.Errorf("staff %s is assigned to unknown crew %q of %s", p.Email, p.Crew, p.Classroom)
}
}
model.Sections = append(model.Sections, section)
for _, name := range classroomNames {
info := classrooms[name]
band := ""
for b := range info.bands {
band = b
}
crews := []string{}
for crew := range info.crews {
crews = append(crews, crew)
}
sort.Strings(crews)
if len(crews) == 0 {
crews = []string{""}
}
for _, crewName := range crews {
crew := Crew{Classroom: name, Name: crewName, GradeBand: band}
for _, p := range model.People {
if p.IsStaff && p.Classroom == name && p.Crew == crewName {
crew.Teachers = append(crew.Teachers, p.Email)
}
}
model.Crews = append(model.Crews, crew)
}
}
return nil
}
for _, row := range tables["Grade Lookup"] {
if row["Current Grade"] == "" {
func (l *loader) deriveStructure() error {
for i, grade := range gradeOrder {
g := Grade{Name: grade, Band: gradeBands[grade]}
if i+1 < len(gradeOrder) {
g.NextName = gradeOrder[i+1]
g.NextBand = gradeBands[g.NextName]
}
l.model.Grades = append(l.model.Grades, g)
}
for band, emails := range l.roomParents {
l.model.RoomParents[bandLabel(band)] = emails
}
l.model.Departments = append(l.model.Departments, departmentOrder...)
return nil
}
func bandLabel(band string) string {
if band == "Hummingbirds" {
return "K"
}
labels := []string{}
for _, grade := range gradeOrder {
if gradeBands[grade] != band {
continue
}
model.Grades = append(model.Grades, Grade{
Name: row["Current Grade"],
NextName: row["Next Grade"],
Band: row["Current Gradeband"],
NextBand: row["Next Gradeband"],
})
number := strings.TrimPrefix(grade, "Grade ")
switch number {
case "1":
labels = append(labels, "1st")
case "2":
labels = append(labels, "2nd")
case "3":
labels = append(labels, "3rd")
default:
labels = append(labels, number+"th")
}
}
return strings.Join(labels, " / ")
}
for _, row := range tables["Room Parents"] {
band, email := row["Gradeband"], strings.ToLower(row["Email Address"])
if band == "" || email == "" {
continue
func without(list []string, drop map[string]bool) []string {
kept := []string{}
for _, item := range list {
if !drop[item] {
kept = append(kept, item)
}
model.RoomParents[band] = append(model.RoomParents[band], email)
}
type department struct {
name string
order float64
}
departments := []department{}
for _, row := range tables["Departments"] {
if row["Department"] == "" {
continue
}
order, err := strconv.ParseFloat(row["Order"], 64)
if err != nil {
order = float64(len(departments))
}
departments = append(departments, department{name: row["Department"], order: order})
}
sort.SliceStable(departments, func(i, j int) bool { return departments[i].order < departments[j].order })
for _, d := range departments {
model.Departments = append(model.Departments, d.name)
}
return model, nil
}
func blobURL(folder, email, kind, source string) string {
if source == "" {
return ""
}
local, _, _ := strings.Cut(email, "@")
return "/blob/" + folder + "/" + local + "-" + kind
return kept
}
func surname(fullName string) string {
@@ -209,12 +725,16 @@ func surname(fullName string) string {
return fields[len(fields)-1]
}
func familyName(f Family, byEmail map[string]Person) string {
func familyNameFor(f Family, people map[string]*Person) string {
members := append(append([]string{}, f.KidEmails...), f.AdultEmails...)
seen := map[string]bool{}
names := []string{}
for _, email := range members {
s := surname(byEmail[email].FullName)
p, ok := people[email]
if !ok {
continue
}
s := surname(p.FullName)
if s == "" || seen[s] {
continue
}
+25 -13
View File
@@ -15,7 +15,7 @@ type Person struct {
PhotoURL string `json:"photoUrl,omitempty"`
Grade string `json:"grade,omitempty"`
Classroom string `json:"classroom,omitempty"`
Section string `json:"section,omitempty"`
Crew string `json:"crew,omitempty"`
Phone string `json:"phone,omitempty"`
FamilyKey string `json:"familyKey,omitempty"`
ParentContactEmails []string `json:"parentContactEmails,omitempty"`
@@ -28,6 +28,7 @@ type Family struct {
Key string `json:"key"`
Name string `json:"name,omitempty"`
Address string `json:"address,omitempty"`
Phone string `json:"phone,omitempty"`
Lat float64 `json:"lat,omitempty"`
Lng float64 `json:"lng,omitempty"`
PhotoURL string `json:"photoUrl,omitempty"`
@@ -40,10 +41,10 @@ type Family struct {
type Classroom struct {
Name string `json:"name"`
ImageURL string `json:"imageUrl,omitempty"`
HasSections bool `json:"hasSections"`
HasCrews bool `json:"hasCrews"`
}
type Section struct {
type Crew struct {
Classroom string `json:"classroom"`
Name string `json:"name,omitempty"`
Teachers []string `json:"teachers,omitempty"`
@@ -57,21 +58,32 @@ type Grade struct {
NextBand string `json:"nextBand,omitempty"`
}
func (m *Model) DisplayName(email string) string {
for _, p := range m.People {
if p.Email == email {
return p.FullName
}
}
return email
}
type Model struct {
People []Person `json:"people"`
Families map[string]Family `json:"families"`
Classrooms []Classroom `json:"classrooms"`
Sections []Section `json:"sections"`
Crews []Crew `json:"crews"`
Grades []Grade `json:"grades"`
RoomParents map[string][]string `json:"roomParents"`
Departments []string `json:"departments"`
byEmail map[string]int
}
func (m *Model) Person(email string) *Person {
i, ok := m.byEmail[email]
if !ok {
return nil
}
return &m.People[i]
}
func (m *Model) Member(email string) bool {
return m.Person(email) != nil
}
func (m *Model) DisplayName(email string) string {
if p := m.Person(email); p != nil {
return p.FullName
}
return email
}
+313
View File
@@ -0,0 +1,313 @@
package directory
import (
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"heliosian/internal/auth"
"heliosian/internal/blob"
"heliosian/internal/data"
)
const changeLogTable = "Change Log"
var changeLogHeader = append([]string{"Timestamp", "Actor"}, overrideColumns...)
func changeLogRow(actor, email string, previous map[string]string) []string {
row := make([]string, len(changeLogHeader))
row[0] = time.Now().UTC().Format(time.RFC3339)
row[1] = actor
for i, column := range changeLogHeader {
if column == "Email" {
row[i] = email
} else if value, ok := previous[column]; ok {
if value == "" {
value = "-"
}
row[i] = value
}
}
return row
}
var photoExtensions = map[string]string{
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
}
var audioExtensions = map[string]string{
"audio/webm": "webm",
"video/webm": "webm",
"audio/mp4": "m4a",
"video/mp4": "m4a",
"audio/x-m4a": "m4a",
"audio/mpeg": "mp3",
"audio/ogg": "ogg",
"audio/wav": "wav",
}
type uploader struct {
cache *Cache
sheet *data.Sheet
store *blob.Store
}
func RegisterUpload(mux *http.ServeMux, cache *Cache, sheet *data.Sheet, store *blob.Store) {
u := uploader{cache: cache, sheet: sheet, store: store}
mux.HandleFunc("POST /api/directory/upload", u.upload)
mux.HandleFunc("POST /api/directory/facts", u.facts)
mux.HandleFunc("POST /api/directory/optout", u.optOut)
mux.HandleFunc("POST /api/directory/edit", u.edit)
}
func clearable(value string) string {
if value == "" {
return "-"
}
return value
}
func (u uploader) applyOverride(w http.ResponseWriter, actor, email, action string, cells, previous map[string]string) bool {
for column, cell := range cells {
if err := u.sheet.Upsert(appName, "Overrides", "Email", email, column, cell); err != nil {
serverError(w, fmt.Errorf("set %s for %s: %w", column, email, err))
return false
}
}
logRow := changeLogRow(actor, email, previous)
if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil {
serverError(w, fmt.Errorf("append change log after %s for %s: %w", action, email, err))
return false
}
if err := u.cache.Refresh(); err != nil {
serverError(w, fmt.Errorf("refresh model after %s: %w", action, err))
return false
}
return true
}
func (u uploader) edit(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
key := strings.ToLower(strings.TrimSpace(r.FormValue("key")))
field := r.FormValue("field")
value := strings.TrimSpace(r.FormValue("value"))
me := auth.Email(r)
model := u.cache.Model()
person := model.Person(key)
if person == nil {
http.Error(w, "no such person", http.StatusBadRequest)
return
}
cells := map[string]string{}
previous := map[string]string{}
switch field {
case "preferred-name":
if !mayEdit(model, me, "person", key) {
http.Error(w, "not allowed to edit this record", http.StatusForbidden)
return
}
if value == "" || len(value) > 80 {
http.Error(w, "bad preferred name", http.StatusBadRequest)
return
}
base := person.LegalName
if base == "" {
base = person.FullName
}
cells["Preferred Name"] = value
cells["Full Name"] = value + " " + surname(base)
previous["Preferred Name"] = person.PreferredName
previous["Full Name"] = person.FullName
case "phone":
if !mayEdit(model, me, "person", key) {
http.Error(w, "not allowed to edit this record", http.StatusForbidden)
return
}
if len(value) > 40 {
http.Error(w, "bad phone number", http.StatusBadRequest)
return
}
cells["Phone"] = clearable(value)
previous["Phone"] = person.Phone
case "address":
if key != strings.ToLower(me) || !person.IsParent {
http.Error(w, "not allowed to edit this record", http.StatusForbidden)
return
}
family, ok := model.Families[person.FamilyKey]
if !ok {
http.Error(w, "no family record", http.StatusBadRequest)
return
}
if len(value) > 200 {
http.Error(w, "bad address", http.StatusBadRequest)
return
}
cells["Address"] = clearable(value)
previous["Address"] = family.Address
default:
http.Error(w, "bad field", http.StatusBadRequest)
return
}
if !u.applyOverride(w, me, key, field+" edit", cells, previous) {
return
}
log.Printf("edit: %s set %s on %s", me, field, key)
w.WriteHeader(http.StatusNoContent)
}
func (u uploader) optOut(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
me := auth.Email(r)
key := strings.ToLower(strings.TrimSpace(r.FormValue("key")))
if key == "" {
http.Error(w, "bad opt out request", http.StatusBadRequest)
return
}
if !mayEdit(u.cache.Model(), me, "person", key) {
http.Error(w, "not allowed to edit this record", http.StatusForbidden)
return
}
if !u.applyOverride(w, me, key, "opt out", map[string]string{"Opted Out": "TRUE"}, map[string]string{"Opted Out": ""}) {
return
}
log.Printf("optout: %s removed %s from the directory", me, key)
w.WriteHeader(http.StatusNoContent)
}
func (u uploader) facts(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
key := strings.ToLower(strings.TrimSpace(r.FormValue("key")))
facts := strings.TrimSpace(r.FormValue("facts"))
if key == "" || len(facts) > 4000 {
http.Error(w, "bad facts request", http.StatusBadRequest)
return
}
me := auth.Email(r)
model := u.cache.Model()
if !mayEdit(model, me, "person", key) {
http.Error(w, "not allowed to edit this record", http.StatusForbidden)
return
}
old := ""
if p := model.Person(key); p != nil {
old = p.Facts
}
if !u.applyOverride(w, me, key, "facts update", map[string]string{"Facts": facts}, map[string]string{"Facts": old}) {
return
}
log.Printf("facts: %s set %s (%d chars)", me, key, len(facts))
w.WriteHeader(http.StatusNoContent)
}
func (u uploader) upload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 30<<20)
if err := r.ParseMultipartForm(30 << 20); err != nil {
http.Error(w, "upload too large or malformed", http.StatusBadRequest)
return
}
target := r.FormValue("target")
key := strings.ToLower(strings.TrimSpace(r.FormValue("key")))
kind := r.FormValue("kind")
if (target != "person" && target != "family") || (kind != "photo" && kind != "pronunciation") || key == "" {
http.Error(w, "bad upload request", http.StatusBadRequest)
return
}
me := auth.Email(r)
model := u.cache.Model()
if !mayEdit(model, me, target, key) {
http.Error(w, "not allowed to edit this record", http.StatusForbidden)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "missing file", http.StatusBadRequest)
return
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil || len(content) == 0 {
http.Error(w, "unreadable file", http.StatusBadRequest)
return
}
mimeType, ext, err := mediaType(kind, content, header.Header.Get("Content-Type"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
folder := "people"
if target == "family" {
folder = "families"
}
local, _, _ := strings.Cut(key, "@")
base := local + "-" + kind
name := base + "." + ext
archived, err := u.store.Upload(folder, base, ext, mimeType, content)
if err != nil {
serverError(w, err)
return
}
if err := u.cache.Refresh(); err != nil {
serverError(w, fmt.Errorf("refresh model after upload: %w", err))
return
}
log.Printf("upload: %s set %s %s %s (archived %q)", me, target, key, name, archived)
w.WriteHeader(http.StatusNoContent)
}
func mayEdit(model *Model, me, target, key string) bool {
mine := model.Person(me)
if mine == nil {
return false
}
if target == "family" {
return mine.FamilyKey != "" && mine.FamilyKey == key
}
if key == me {
return true
}
family, ok := model.Families[mine.FamilyKey]
if !ok {
return false
}
for _, kid := range family.KidEmails {
if kid == key {
return true
}
}
return false
}
func mediaType(kind string, content []byte, declared string) (string, string, error) {
if kind == "photo" {
sniffed := http.DetectContentType(content)
ext, ok := photoExtensions[sniffed]
if !ok {
return "", "", fmt.Errorf("unsupported photo type %s", sniffed)
}
return sniffed, ext, nil
}
base, _, _ := strings.Cut(declared, ";")
base = strings.TrimSpace(strings.ToLower(base))
ext, ok := audioExtensions[base]
if !ok {
return "", "", fmt.Errorf("unsupported audio type %s", declared)
}
if strings.HasPrefix(base, "video/") {
base = "audio/" + strings.TrimPrefix(base, "video/")
}
return base, ext, nil
}
+13
View File
@@ -0,0 +1,13 @@
package geocode
import "crypto/sha256"
type Fake struct{}
func (Fake) Lookup(address string) (Point, error) {
sum := sha256.Sum256([]byte(address))
return Point{
Lat: 37.5 + (float64(sum[0])/255-0.5)*0.12,
Lng: -122.45 + (float64(sum[1])/255-0.5)*0.12,
}, nil
}
+54 -23
View File
@@ -5,8 +5,10 @@ import (
"crypto/rand"
"encoding/json"
"log"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"heliosian/internal/auth"
@@ -16,17 +18,7 @@ import (
"heliosian/internal/geocode"
)
func directorySource() data.Source {
sheetID := os.Getenv("DIRECTORY_SHEET")
if sheetID == "" {
return data.Dir{Root: "sampledata"}
}
source, err := data.NewSheet(map[string]string{"directory": sheetID})
if err != nil {
log.Fatalf("[ERROR] load directory sheet: %v", err)
}
return source
}
const sampleUser = "jordan.whitfield@heliosschool.org"
func sessionKey() []byte {
if key := os.Getenv("SESSION_KEY"); key != "" {
@@ -59,9 +51,20 @@ func clientID() string {
return parsed.Web.ClientID
}
func noCache(next http.Handler) http.Handler {
type staticFiles struct{}
func (staticFiles) Has(key string) bool {
_, err := os.Stat(filepath.Join("web/static", filepath.FromSlash(key)))
return err == nil
}
func cacheControl(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/static/fonts/") || strings.HasPrefix(r.URL.Path, "/static/brand/") {
w.Header().Set("Cache-Control", "public, max-age=86400")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
next.ServeHTTP(w, r)
})
}
@@ -82,29 +85,57 @@ func mapsKey(envName, file string) string {
}
func main() {
authn := auth.New(clientID(), sessionKey())
serverKey := mapsKey("GOOGLE_MAPS_SERVER_KEY", "creds/geocoding.key")
browserKey := mapsKey("GOOGLE_MAPS_BROWSER_KEY", "creds/maps.key")
cache, err := directory.NewCache(directorySource(), geocode.New(serverKey))
if err := mime.AddExtensionType(".webmanifest", "application/manifest+json"); err != nil {
log.Fatalf("[ERROR] register manifest mime type: %v", err)
}
sheetID := os.Getenv("DIRECTORY_SHEET")
var source data.Source
var geocoder directory.Geocoder = geocode.Fake{}
browserKey := os.Getenv("GOOGLE_MAPS_BROWSER_KEY")
var store *blob.Store
var blobs directory.BlobChecker
if sheetID == "" {
source = data.Dir{Root: "sampledata"}
log.Printf("DIRECTORY_SHEET not set, serving sample data as %s", sampleUser)
} else {
sheet, err := data.NewSheet(map[string]string{"directory": sheetID})
if err != nil {
log.Fatalf("[ERROR] load directory sheet: %v", err)
}
source = sheet
geocoder = geocode.New(mapsKey("GOOGLE_MAPS_SERVER_KEY", "creds/geocoding.key"))
browserKey = mapsKey("GOOGLE_MAPS_BROWSER_KEY", "creds/maps.key")
store, err = blob.New()
if err != nil {
log.Fatalf("[ERROR] blob store: %v", err)
}
blobs = store
}
cache, err := directory.NewCache(source, geocoder, blobs, staticFiles{})
if err != nil {
log.Fatalf("[ERROR] load directory data: %v", err)
}
mux := http.NewServeMux()
authn.Register(mux)
directory.Register(mux, cache, browserKey)
if os.Getenv("DIRECTORY_SHEET") != "" {
store, err := blob.New()
if err != nil {
log.Fatalf("[ERROR] blob store: %v", err)
}
if store != nil {
blob.Register(mux, store)
directory.RegisterUpload(mux, cache, source.(*data.Sheet), store)
}
mux.Handle("GET /{$}", http.RedirectHandler("/people", http.StatusFound))
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
var handler http.Handler = directory.MemberGate(cache, mux)
if sheetID == "" {
mux.Handle("POST /auth/logout", http.RedirectHandler("/", http.StatusSeeOther))
handler = auth.Fixed(sampleUser, handler)
} else {
authn := auth.New(clientID(), sessionKey())
authn.Register(mux)
handler = authn.Wrap(handler)
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("listening on http://localhost:%s", port)
log.Fatal(http.ListenAndServe(":"+port, noCache(authn.Wrap(mux))))
log.Fatal(http.ListenAndServe(":"+port, cacheControl(handler)))
}
+1
View File
@@ -0,0 +1 @@
Timestamp,Actor,Email,Added,Full Name,Legal Name,Preferred Name,Is Student,Is Parent,Is Staff,New to Helios,Pronouns,Facts,Grade,Classroom,Crew,Phone,Job Title,Department,Grade Band,Room Parent,Address,Family Phone,Family Photo Caption,Opted Out
1 Timestamp Actor Email Added Full Name Legal Name Preferred Name Is Student Is Parent Is Staff New to Helios Pronouns Facts Grade Classroom Crew Phone Job Title Department Grade Band Room Parent Address Family Phone Family Photo Caption Opted Out
+3
View File
@@ -0,0 +1,3 @@
Name,Email
Mia Torres,mia.torres@heliosschool.org
Sam Whitfield,sam.whitfield@heliosschool.org
1 Name Email
2 Mia Torres mia.torres@heliosschool.org
3 Sam Whitfield sam.whitfield@heliosschool.org
+29
View File
@@ -0,0 +1,29 @@
Email,Added,Full Name,Legal Name,Preferred Name,Is Student,Is Parent,Is Staff,New to Helios,Pronouns,Facts,Grade,Classroom,Crew,Phone,Job Title,Department,Grade Band,Room Parent,Address,Family Phone,Family Photo Caption,Opted Out
dana.hawkins@heliosschool.org,,,,,,,TRUE,,she/her,,,,,,Art Teacher,Co-Curriculars and Specialists,,,,,,
jordan.whitfield@heliosschool.org,,,,,,,,,they/them,,,,,,,,,Jayvens,,,,
asha.chandra@heliosschool.org,,,,,,,,,,,,,,,,,,Cospreys,,,,
harper.quinn@heliosschool.org,,,,,,,,,she/her,"Plays goalie in the coastside soccer league, and is building a solar-powered go-kart.",,,,,,,,,,,,
owen.park@heliosschool.org,,,,,,,,,,Can name every bird on the bluff trail. Future marine biologist.,,,,,,,,,,,,
felix.osei@heliosschool.org,,,,,,,,,he/him,,,,,,,,,,,,,
noor.haddad@heliosschool.org,,,,,,,,TRUE,,,,,,,,,,,,,,
layla.haddad@heliosschool.org,,,,,,,,TRUE,,,,,,,,,,,,,,
colin.quinn@heliosschool.org,,,,,,,,,,,,,,-,,,,,,,,
elena.torres@heliosschool.org,,,,,,,,,,,,,,,,,,,,,"Marco, Mia, and Elena at Mavericks Beach.",
deepa.natarajan@heliosschool.org,,,,,,,,,,,,,,,,,,,,650-555-0199,,
grace.kim@heliosschool.org,TRUE,Grace Kim,,,,,TRUE,,she/her,,,,,,Head of School,Admin and Office Staff,,,,,,
bill.ryder@heliosschool.org,TRUE,Bill Ryder,,,,,TRUE,,,,,,,,Office Manager,Admin and Office Staff,,,,,,
ruth.amari@heliosschool.org,TRUE,Ruth Amari,,,,,TRUE,,,"Twelve years teaching kindergarten, keeper of the class worm farm.",,Hummingbirds,,,Kindergarten Teacher,Classroom Teachers,Hummingbirds,,,,,
miguel.santos@heliosschool.org,TRUE,Miguel Santos,,,,,TRUE,,,,,Hawks,,,1st Grade Teacher,Classroom Teachers,Halcons,,,,,
alice.fontaine@heliosschool.org,TRUE,Alice Fontaine,,,,,TRUE,,,,,Falcons,,,2nd Grade Teacher,Classroom Teachers,Halcons,,,,,
peter.okafor@heliosschool.org,TRUE,Peter Okafor,,,,,TRUE,,,,,Jays,,,3rd Grade Teacher,Classroom Teachers,Jayvens,,,,,
susan.byrne@heliosschool.org,TRUE,Susan Byrne,,,,,TRUE,,,,,Ravens,,,4th Grade Teacher,Classroom Teachers,Jayvens,,,,,
hana.ito@heliosschool.org,TRUE,Hana Ito,,,,,TRUE,,,,,Condors,Pinnacles,,5th/6th Grade Teacher,Classroom Teachers,Cospreys,,,,,
marcus.bell@heliosschool.org,TRUE,Marcus Bell,,,,,TRUE,,,,,Condors,Big Sur,,5th/6th Grade Teacher,Classroom Teachers,Cospreys,,,,,
lena.vogel@heliosschool.org,TRUE,Lena Vogel,,,,,TRUE,,,,,Ospreys,River,,5th/6th Grade Teacher,Classroom Teachers,Cospreys,,,,,
tom.grady@heliosschool.org,TRUE,Tom Grady,,,,,TRUE,,,,,Ospreys,Sea,,5th/6th Grade Teacher,Classroom Teachers,Cospreys,,,,,
ivy.chen@heliosschool.org,TRUE,Ivy Chen,,,,,TRUE,,she/her,,,Egrets,Snowy,,Humanities Teacher,Classroom Teachers,Hegrets,,,,,
raj.malhotra@heliosschool.org,TRUE,Raj Malhotra,,,,,TRUE,,,,,Egrets,Great,,Science Teacher,Classroom Teachers,Hegrets,,,,,
kate.doyle@heliosschool.org,TRUE,Kate Doyle,,,,,TRUE,,,,,Herons,Great Blue,,Humanities Teacher,Classroom Teachers,Hegrets,,,,,
omar.farouk@heliosschool.org,TRUE,Omar Farouk,,,,,TRUE,,he/him,,,Herons,Green,,Science Teacher,Classroom Teachers,Hegrets,,,,,
noa.adler@heliosschool.org,TRUE,Noa Adler,,,,,TRUE,,,,,,,,Music Teacher,Co-Curriculars and Specialists,,,,,,
hank.morrow@heliosschool.org,TRUE,Hank Morrow,,,,,TRUE,,,,,,,,Facilities Manager,Facilities Staff,,,,,,
1 Email Added Full Name Legal Name Preferred Name Is Student Is Parent Is Staff New to Helios Pronouns Facts Grade Classroom Crew Phone Job Title Department Grade Band Room Parent Address Family Phone Family Photo Caption Opted Out
2 dana.hawkins@heliosschool.org TRUE she/her Art Teacher Co-Curriculars and Specialists
3 jordan.whitfield@heliosschool.org they/them Jayvens
4 asha.chandra@heliosschool.org Cospreys
5 harper.quinn@heliosschool.org she/her Plays goalie in the coastside soccer league, and is building a solar-powered go-kart.
6 owen.park@heliosschool.org Can name every bird on the bluff trail. Future marine biologist.
7 felix.osei@heliosschool.org he/him
8 noor.haddad@heliosschool.org TRUE
9 layla.haddad@heliosschool.org TRUE
10 colin.quinn@heliosschool.org -
11 elena.torres@heliosschool.org Marco, Mia, and Elena at Mavericks Beach.
12 deepa.natarajan@heliosschool.org 650-555-0199
13 grace.kim@heliosschool.org TRUE Grace Kim TRUE she/her Head of School Admin and Office Staff
14 bill.ryder@heliosschool.org TRUE Bill Ryder TRUE Office Manager Admin and Office Staff
15 ruth.amari@heliosschool.org TRUE Ruth Amari TRUE Twelve years teaching kindergarten, keeper of the class worm farm. Hummingbirds Kindergarten Teacher Classroom Teachers Hummingbirds
16 miguel.santos@heliosschool.org TRUE Miguel Santos TRUE Hawks 1st Grade Teacher Classroom Teachers Halcons
17 alice.fontaine@heliosschool.org TRUE Alice Fontaine TRUE Falcons 2nd Grade Teacher Classroom Teachers Halcons
18 peter.okafor@heliosschool.org TRUE Peter Okafor TRUE Jays 3rd Grade Teacher Classroom Teachers Jayvens
19 susan.byrne@heliosschool.org TRUE Susan Byrne TRUE Ravens 4th Grade Teacher Classroom Teachers Jayvens
20 hana.ito@heliosschool.org TRUE Hana Ito TRUE Condors Pinnacles 5th/6th Grade Teacher Classroom Teachers Cospreys
21 marcus.bell@heliosschool.org TRUE Marcus Bell TRUE Condors Big Sur 5th/6th Grade Teacher Classroom Teachers Cospreys
22 lena.vogel@heliosschool.org TRUE Lena Vogel TRUE Ospreys River 5th/6th Grade Teacher Classroom Teachers Cospreys
23 tom.grady@heliosschool.org TRUE Tom Grady TRUE Ospreys Sea 5th/6th Grade Teacher Classroom Teachers Cospreys
24 ivy.chen@heliosschool.org TRUE Ivy Chen TRUE she/her Egrets Snowy Humanities Teacher Classroom Teachers Hegrets
25 raj.malhotra@heliosschool.org TRUE Raj Malhotra TRUE Egrets Great Science Teacher Classroom Teachers Hegrets
26 kate.doyle@heliosschool.org TRUE Kate Doyle TRUE Herons Great Blue Humanities Teacher Classroom Teachers Hegrets
27 omar.farouk@heliosschool.org TRUE Omar Farouk TRUE he/him Herons Green Science Teacher Classroom Teachers Hegrets
28 noa.adler@heliosschool.org TRUE Noa Adler TRUE Music Teacher Co-Curriculars and Specialists
29 hank.morrow@heliosschool.org TRUE Hank Morrow TRUE Facilities Manager Facilities Staff
+14
View File
@@ -0,0 +1,14 @@
entry_sort_name,student_full_name,student_classifications,student_email,student_phone_mobile,household_1_phone,household_1_address,household_1_person_1_full_name,household_1_person_1_email,household_1_person_1_email_2,household_1_person_1_phone_mobile,household_1_person_1_phone_business,household_1_person_2_full_name,household_1_person_2_email,household_1_person_2_email_2,household_1_person_2_phone_mobile,household_1_person_2_phone_business,household_2_phone,household_2_address,household_2_person_1_full_name,household_2_person_1_email,household_2_person_1_email_2,household_2_person_1_phone_mobile,household_2_person_1_phone_business,household_2_person_2_full_name,household_2_person_2_email,household_2_person_2_email_2,household_2_person_2_phone_mobile,household_2_person_2_phone_business
"Torres, Mia",Mia Torres,"{""grade_level"": ""Kindergarten"", ""homeroom"": ""Hummingbirds""}",,,650-555-0141,"214 Alder Ln, Half Moon Bay, CA 94019",Elena Torres,elena.torres@heliosschool.org,,650-555-0142,,Marco Torres,marco.torres@heliosschool.org,,650-555-0143,,,,,,,,,,,,,
"Baxter, Leo",Leo (Leonardo) Baxter,"{""grade_level"": ""Grade 1"", ""homeroom"": ""Hawks""}",leo.baxter@heliosschool.org,,,"88 Cypress Ave, El Granada, CA 94018",April Baxter,April.Baxter@heliosschool.org,,650-555-0151,,,,,,,,,,,,,,,,,,
"Natarajan, Priya",Priya Natarajan,"{""grade_level"": ""Grade 2"", ""homeroom"": ""Falcons""}",priya.natarajan@heliosschool.org,,,"402 Seabreeze Ct, Half Moon Bay, CA 94019",Deepa Natarajan,deepa.natarajan@heliosschool.org,,650-555-0128,,Karthik Natarajan,karthik.natarajan@heliosschool.org,,,650-555-0129,,,,,,,,,,,,
"Whitfield, Sam",Sam Whitfield,"{""grade_level"": ""Grade 3"", ""homeroom"": ""Jays""}",,,650-555-0176,"17 Pelican Point Rd, Moss Beach, CA 94038",Jordan Whitfield,jordan.whitfield@heliosschool.org,,650-555-0177,,Robin Whitfield,robin.whitfield@heliosschool.org,,650-555-0178,,,,,,,,,,,,,
"Haddad, Noor",Noor Haddad,"{""grade_level"": ""Grade 4"", ""homeroom"": ""Ravens""}",noor.haddad@heliosschool.org,,,"Pacifica, CA",Layla Haddad,layla.haddad@heliosschool.org,,650-555-0134,,,,,,,,,,,,,,,,,,
"Park, Owen",Owen Park,"{""grade_level"": ""Grade 5"", ""homeroom"": ""Pinnacles Condors""}",owen.park@heliosschool.org,,,"590 Coral Reef Ave, Half Moon Bay, CA 94019",Mina Park,mina.park@heliosschool.org,,650-555-0111,,Daniel Park,daniel.park@heliosschool.org,,650-555-0112,,,,,,,,,,,,,
"Marchetti, Lucia",Lucia Marchetti,"{""grade_level"": ""Grade 5"", ""homeroom"": ""Big Sur Condors""}",lucia.marchetti@heliosschool.org,,,"31 Miramar Dr, Half Moon Bay, CA 94019",Sofia Marchetti,sofia.marchetti@heliosschool.org,,650-555-0117,,Paolo Marchetti,paolo.marchetti@heliosschool.org,,650-555-0118,,,,,,,,,,,,,
"Whitfield, Ella",Ella Whitfield,"{""grade_level"": ""Grade 6"", ""homeroom"": ""River Ospreys""}",ella.whitfield@heliosschool.org,,650-555-0176,"17 Pelican Point Rd, Moss Beach, CA 94038",Jordan Whitfield,jordan.whitfield@heliosschool.org,,650-555-0177,,Robin Whitfield,robin.whitfield@heliosschool.org,,650-555-0178,,,,,,,,,,,,,
"Chandra, Dev",Dev Chandra,"{""grade_level"": ""Grade 6"", ""homeroom"": ""Sea Ospreys""}",dev.chandra@heliosschool.org,,650-555-0120,"76 Kelp Hollow Rd, Montara, CA 94037",Asha Chandra,asha.chandra@heliosschool.org,,650-555-0121,,,,,,,,"1109 Palmetto Ave, Pacifica, CA 94044",Rohan Chandra,rohan.chandra@heliosschool.org,,650-555-0122,,,,,,
"Quinn, Harper",Harper Quinn,"{""grade_level"": ""Grade 7"", ""homeroom"": ""Snowy Egrets""}",harper.quinn@heliosschool.org,650-555-0303,,"9 Spindrift Way, El Granada, CA 94018",Dana Hawkins,dana.hawkins@heliosschool.org,,650-555-0160,,Colin Quinn,colin.quinn@heliosschool.org,,650-555-0161,,,,,,,,,,,,,
"Alvarez, Mateo",Mateo Alvarez,"{""grade_level"": ""Grade 7"", ""homeroom"": ""Great Egrets""}",mateo.alvarez@heliosschool.org,650-555-0304,,"245 Le Conte Ave, Pacifica, CA 94044",Carmen Alvarez,carmen.alvarez@heliosschool.org,,650-555-0166,,,,,,,,,,,,,,,,,,
"Lindqvist, Zoe",Zoe Lindqvist,"{""grade_level"": ""Grade 8"", ""homeroom"": ""Great Blue Herons""}",zoe.lindqvist@heliosschool.org,650-555-0305,,"630 Etheldore St, Moss Beach, CA 94038",Freja Lindqvist,freja.lindqvist@heliosschool.org,,650-555-0171,,Anders Lindqvist,anders.lindqvist@heliosschool.org,,650-555-0172,,,,,,,,,,,,,
"Osei, Felix",Felix Osei,"{""grade_level"": ""Grade 8"", ""homeroom"": ""Green Herons""}",felix.osei@heliosschool.org,650-555-0306,,,Abena Osei,abena.osei@heliosschool.org,,650-555-0181,,,,,,,,,,,,,,,,,,
1 entry_sort_name student_full_name student_classifications student_email student_phone_mobile household_1_phone household_1_address household_1_person_1_full_name household_1_person_1_email household_1_person_1_email_2 household_1_person_1_phone_mobile household_1_person_1_phone_business household_1_person_2_full_name household_1_person_2_email household_1_person_2_email_2 household_1_person_2_phone_mobile household_1_person_2_phone_business household_2_phone household_2_address household_2_person_1_full_name household_2_person_1_email household_2_person_1_email_2 household_2_person_1_phone_mobile household_2_person_1_phone_business household_2_person_2_full_name household_2_person_2_email household_2_person_2_email_2 household_2_person_2_phone_mobile household_2_person_2_phone_business
2 Torres, Mia Mia Torres {"grade_level": "Kindergarten", "homeroom": "Hummingbirds"} 650-555-0141 214 Alder Ln, Half Moon Bay, CA 94019 Elena Torres elena.torres@heliosschool.org 650-555-0142 Marco Torres marco.torres@heliosschool.org 650-555-0143
3 Baxter, Leo Leo (Leonardo) Baxter {"grade_level": "Grade 1", "homeroom": "Hawks"} leo.baxter@heliosschool.org 88 Cypress Ave, El Granada, CA 94018 April Baxter April.Baxter@heliosschool.org 650-555-0151
4 Natarajan, Priya Priya Natarajan {"grade_level": "Grade 2", "homeroom": "Falcons"} priya.natarajan@heliosschool.org 402 Seabreeze Ct, Half Moon Bay, CA 94019 Deepa Natarajan deepa.natarajan@heliosschool.org 650-555-0128 Karthik Natarajan karthik.natarajan@heliosschool.org 650-555-0129
5 Whitfield, Sam Sam Whitfield {"grade_level": "Grade 3", "homeroom": "Jays"} 650-555-0176 17 Pelican Point Rd, Moss Beach, CA 94038 Jordan Whitfield jordan.whitfield@heliosschool.org 650-555-0177 Robin Whitfield robin.whitfield@heliosschool.org 650-555-0178
6 Haddad, Noor Noor Haddad {"grade_level": "Grade 4", "homeroom": "Ravens"} noor.haddad@heliosschool.org Pacifica, CA Layla Haddad layla.haddad@heliosschool.org 650-555-0134
7 Park, Owen Owen Park {"grade_level": "Grade 5", "homeroom": "Pinnacles Condors"} owen.park@heliosschool.org 590 Coral Reef Ave, Half Moon Bay, CA 94019 Mina Park mina.park@heliosschool.org 650-555-0111 Daniel Park daniel.park@heliosschool.org 650-555-0112
8 Marchetti, Lucia Lucia Marchetti {"grade_level": "Grade 5", "homeroom": "Big Sur Condors"} lucia.marchetti@heliosschool.org 31 Miramar Dr, Half Moon Bay, CA 94019 Sofia Marchetti sofia.marchetti@heliosschool.org 650-555-0117 Paolo Marchetti paolo.marchetti@heliosschool.org 650-555-0118
9 Whitfield, Ella Ella Whitfield {"grade_level": "Grade 6", "homeroom": "River Ospreys"} ella.whitfield@heliosschool.org 650-555-0176 17 Pelican Point Rd, Moss Beach, CA 94038 Jordan Whitfield jordan.whitfield@heliosschool.org 650-555-0177 Robin Whitfield robin.whitfield@heliosschool.org 650-555-0178
10 Chandra, Dev Dev Chandra {"grade_level": "Grade 6", "homeroom": "Sea Ospreys"} dev.chandra@heliosschool.org 650-555-0120 76 Kelp Hollow Rd, Montara, CA 94037 Asha Chandra asha.chandra@heliosschool.org 650-555-0121 1109 Palmetto Ave, Pacifica, CA 94044 Rohan Chandra rohan.chandra@heliosschool.org 650-555-0122
11 Quinn, Harper Harper Quinn {"grade_level": "Grade 7", "homeroom": "Snowy Egrets"} harper.quinn@heliosschool.org 650-555-0303 9 Spindrift Way, El Granada, CA 94018 Dana Hawkins dana.hawkins@heliosschool.org 650-555-0160 Colin Quinn colin.quinn@heliosschool.org 650-555-0161
12 Alvarez, Mateo Mateo Alvarez {"grade_level": "Grade 7", "homeroom": "Great Egrets"} mateo.alvarez@heliosschool.org 650-555-0304 245 Le Conte Ave, Pacifica, CA 94044 Carmen Alvarez carmen.alvarez@heliosschool.org 650-555-0166
13 Lindqvist, Zoe Zoe Lindqvist {"grade_level": "Grade 8", "homeroom": "Great Blue Herons"} zoe.lindqvist@heliosschool.org 650-555-0305 630 Etheldore St, Moss Beach, CA 94038 Freja Lindqvist freja.lindqvist@heliosschool.org 650-555-0171 Anders Lindqvist anders.lindqvist@heliosschool.org 650-555-0172
14 Osei, Felix Felix Osei {"grade_level": "Grade 8", "homeroom": "Green Herons"} felix.osei@heliosschool.org 650-555-0306 Abena Osei abena.osei@heliosschool.org 650-555-0181
+4 -20
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"log"
"os"
"sort"
"heliosian/internal/data"
)
@@ -22,28 +21,13 @@ func main() {
}
source = s
}
for _, name := range []string{"Basic Directory", "Staff Details", "Classrooms", "Schedules", "Grade Lookup", "Room Parents", "Departments"} {
rows, err := source.Table("directory", name)
for _, name := range []string{"Veracross Import", "Name to Email", "Overrides", "Change Log"} {
header, rows, err := source.Table("directory", name)
if err != nil {
log.Fatalf("[ERROR] table %s: %v", name, err)
}
if len(rows) == 0 {
fmt.Printf("%s: no rows\n", name)
continue
}
seen := map[string]bool{}
columns := []string{}
for _, row := range rows {
for column := range row {
if !seen[column] {
seen[column] = true
columns = append(columns, column)
}
}
}
sort.Strings(columns)
fmt.Printf("%s:\n", name)
for _, column := range columns {
fmt.Printf("%s (%d rows):\n", name, len(rows))
for _, column := range header {
fmt.Printf(" %s\n", column)
}
}
+22
View File
@@ -0,0 +1,22 @@
// Command cookie prints a signed session cookie for local api testing.
package main
import (
"flag"
"fmt"
"log"
"os"
"time"
"heliosian/internal/auth"
)
func main() {
email := flag.String("email", "", "session email address")
flag.Parse()
key := os.Getenv("SESSION_KEY")
if key == "" || *email == "" {
log.Fatal("[ERROR] SESSION_KEY and -email are required")
}
fmt.Println(auth.Token([]byte(key), *email, time.Now().Add(24*time.Hour)))
}
+88
View File
@@ -0,0 +1,88 @@
// Command createtabs creates the directory sheet's local-layer tabs with their header rows.
package main
import (
"context"
"flag"
"log"
"strings"
"heliosian/internal/data"
"google.golang.org/api/option"
"google.golang.org/api/sheets/v4"
)
var tabs = []struct {
title string
header []string
}{
{"Name to Email", []string{"Name", "Email"}},
{"Overrides", []string{
"Email", "Added",
"Full Name", "Legal Name", "Preferred Name",
"Is Student", "Is Parent", "Is Staff",
"New to Helios", "Pronouns", "Facts",
"Grade", "Classroom", "Crew",
"Phone", "Job Title", "Department", "Grade Band", "Room Parent",
"Address", "Family Phone", "Family Photo Caption", "Opted Out",
}},
{"Change Log", []string{
"Timestamp", "Actor",
"Email", "Added",
"Full Name", "Legal Name", "Preferred Name",
"Is Student", "Is Parent", "Is Staff",
"New to Helios", "Pronouns", "Facts",
"Grade", "Classroom", "Crew",
"Phone", "Job Title", "Department", "Grade Band", "Room Parent",
"Address", "Family Phone", "Family Photo Caption", "Opted Out",
}},
}
func main() {
sheet := flag.String("sheet", "", "spreadsheet id")
flag.Parse()
if *sheet == "" {
log.Fatal("[ERROR] -sheet <spreadsheet id> is required")
}
svc, err := sheets.NewService(context.Background(),
option.WithCredentialsFile(data.KeyFile),
option.WithScopes(sheets.SpreadsheetsScope))
if err != nil {
log.Fatalf("[ERROR] create sheets client: %v", err)
}
meta, err := svc.Spreadsheets.Get(*sheet).Fields("sheets(properties(title))").Do()
if err != nil {
log.Fatalf("[ERROR] get spreadsheet: %v", err)
}
existing := map[string]bool{}
for _, s := range meta.Sheets {
existing[s.Properties.Title] = true
}
for _, t := range tabs {
if existing[t.title] {
log.Fatalf("[ERROR] tab %q already exists", t.title)
}
}
for _, t := range tabs {
_, err := svc.Spreadsheets.BatchUpdate(*sheet, &sheets.BatchUpdateSpreadsheetRequest{
Requests: []*sheets.Request{{AddSheet: &sheets.AddSheetRequest{
Properties: &sheets.SheetProperties{Title: t.title},
}}},
}).Do()
if err != nil {
log.Fatalf("[ERROR] create tab %q: %v", t.title, err)
}
values := make([]interface{}, len(t.header))
for i, h := range t.header {
values[i] = h
}
quoted := "'" + strings.ReplaceAll(t.title, "'", "''") + "'"
_, err = svc.Spreadsheets.Values.Update(*sheet, quoted+"!1:1", &sheets.ValueRange{
Values: [][]interface{}{values},
}).ValueInputOption("RAW").Do()
if err != nil {
log.Fatalf("[ERROR] write header of %q: %v", t.title, err)
}
log.Printf("created tab %q with %d columns", t.title, len(t.header))
}
}
+58
View File
@@ -0,0 +1,58 @@
// Command dumptab writes one sheet tab to a local CSV file.
package main
import (
"context"
"encoding/csv"
"flag"
"fmt"
"log"
"os"
"strings"
"heliosian/internal/data"
"google.golang.org/api/option"
"google.golang.org/api/sheets/v4"
)
func main() {
sheet := flag.String("sheet", "", "spreadsheet id")
tab := flag.String("tab", "", "tab title")
out := flag.String("out", "", "output csv path")
flag.Parse()
if *sheet == "" || *tab == "" || *out == "" {
log.Fatal("[ERROR] -sheet, -tab, and -out are required")
}
svc, err := sheets.NewService(context.Background(),
option.WithCredentialsFile(data.KeyFile),
option.WithScopes(sheets.SpreadsheetsReadonlyScope))
if err != nil {
log.Fatalf("[ERROR] create sheets client: %v", err)
}
resp, err := svc.Spreadsheets.Values.Get(*sheet, "'"+strings.ReplaceAll(*tab, "'", "''")+"'").Do()
if err != nil {
log.Fatalf("[ERROR] read tab %s: %v", *tab, err)
}
f, err := os.Create(*out)
if err != nil {
log.Fatalf("[ERROR] create %s: %v", *out, err)
}
w := csv.NewWriter(f)
for _, row := range resp.Values {
record := make([]string, len(row))
for i, cell := range row {
record[i] = fmt.Sprint(cell)
}
if err := w.Write(record); err != nil {
log.Fatalf("[ERROR] write row: %v", err)
}
}
w.Flush()
if err := w.Error(); err != nil {
log.Fatalf("[ERROR] flush csv: %v", err)
}
if err := f.Close(); err != nil {
log.Fatalf("[ERROR] close %s: %v", *out, err)
}
log.Printf("wrote %d rows to %s", len(resp.Values), *out)
}
+34
View File
@@ -0,0 +1,34 @@
// Command findsheet lists spreadsheets visible to the service account.
package main
import (
"context"
"fmt"
"log"
"heliosian/internal/data"
"google.golang.org/api/drive/v3"
"google.golang.org/api/option"
)
func main() {
svc, err := drive.NewService(context.Background(),
option.WithCredentialsFile(data.KeyFile),
option.WithScopes(drive.DriveReadonlyScope))
if err != nil {
log.Fatalf("[ERROR] create drive client: %v", err)
}
resp, err := svc.Files.List().
Q("mimeType = 'application/vnd.google-apps.spreadsheet'").
Corpora("allDrives").
IncludeItemsFromAllDrives(true).
SupportsAllDrives(true).
Fields("files(id, name, modifiedTime, driveId)").
Do()
if err != nil {
log.Fatalf("[ERROR] list spreadsheets: %v", err)
}
for _, f := range resp.Files {
fmt.Printf("%s %s (modified %s, drive %s)\n", f.Id, f.Name, f.ModifiedTime, f.DriveId)
}
}
-302
View File
@@ -1,302 +0,0 @@
// Command importblobs copies directory media into the drive folder under sane names.
package main
import (
"bytes"
"context"
"flag"
"fmt"
"io"
"log"
"net/http"
"path"
"strings"
"sync"
"time"
"heliosian/internal/data"
"heliosian/internal/directory"
"google.golang.org/api/drive/v3"
"google.golang.org/api/googleapi"
"google.golang.org/api/option"
)
const folderMime = "application/vnd.google-apps.folder"
var extensions = map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/gif": ".gif",
"audio/mpeg": ".mp3",
"audio/mp3": ".mp3",
"audio/mp4": ".m4a",
"audio/wav": ".wav",
}
type task struct {
folderName string
base string
url string
}
func localPart(email string) string {
name, _, _ := strings.Cut(email, "@")
return name
}
func findRoot(svc *drive.Service) string {
drives, err := svc.Drives.List().Do()
if err != nil {
log.Fatalf("[ERROR] list shared drives: %v", err)
}
if len(drives.Drives) == 1 {
log.Printf("using shared drive %q (%s)", drives.Drives[0].Name, drives.Drives[0].Id)
return drives.Drives[0].Id
}
if len(drives.Drives) > 1 {
for _, d := range drives.Drives {
log.Printf("candidate shared drive: %s (%s)", d.Name, d.Id)
}
log.Fatalf("[ERROR] service account can see %d shared drives; pass -folder", len(drives.Drives))
}
list, err := svc.Files.List().
Q("mimeType = '" + folderMime + "' and sharedWithMe = true and trashed = false").
Fields("files(id, name)").Do()
if err != nil {
log.Fatalf("[ERROR] list shared folders: %v", err)
}
if len(list.Files) != 1 {
for _, f := range list.Files {
log.Printf("candidate folder: %s (%s)", f.Name, f.Id)
}
log.Fatalf("[ERROR] expected one shared drive or one shared folder, found %d folders; pass -folder", len(list.Files))
}
log.Printf("using folder %q (%s); note: uploads into personal drives fail on service account quota", list.Files[0].Name, list.Files[0].Id)
return list.Files[0].Id
}
func ensureFolder(svc *drive.Service, parent, name string) string {
list, err := svc.Files.List().
Q(fmt.Sprintf("name = '%s' and '%s' in parents and mimeType = '%s' and trashed = false", name, parent, folderMime)).
SupportsAllDrives(true).IncludeItemsFromAllDrives(true).Corpora("allDrives").
Fields("files(id)").Do()
if err != nil {
log.Fatalf("[ERROR] find folder %s: %v", name, err)
}
if len(list.Files) > 0 {
return list.Files[0].Id
}
created, err := svc.Files.Create(&drive.File{Name: name, MimeType: folderMime, Parents: []string{parent}}).
SupportsAllDrives(true).Fields("id").Do()
if err != nil {
log.Fatalf("[ERROR] create folder %s: %v", name, err)
}
return created.Id
}
func listBases(svc *drive.Service, folderID string) map[string]bool {
bases := map[string]bool{}
deleted := 0
token := ""
for {
call := svc.Files.List().
Q(fmt.Sprintf("'%s' in parents and trashed = false", folderID)).
SupportsAllDrives(true).IncludeItemsFromAllDrives(true).Corpora("allDrives").
Fields("nextPageToken, files(id, name)").PageSize(1000)
if token != "" {
call = call.PageToken(token)
}
list, err := call.Do()
if err != nil {
log.Fatalf("[ERROR] list folder contents: %v", err)
}
for _, f := range list.Files {
base := strings.TrimSuffix(f.Name, path.Ext(f.Name))
if strings.HasSuffix(base, "-thumb") {
_, err := svc.Files.Update(f.Id, &drive.File{Trashed: true}).SupportsAllDrives(true).Do()
if err != nil {
log.Fatalf("[ERROR] trash stale thumb %s: %v", f.Name, err)
}
deleted++
continue
}
bases[base] = true
}
if list.NextPageToken == "" {
if deleted > 0 {
log.Printf("deleted %d stale thumbs", deleted)
}
return bases
}
token = list.NextPageToken
}
}
func folderStats(svc *drive.Service, folderID string) (int64, int64) {
var files, bytes int64
token := ""
for {
call := svc.Files.List().
Q(fmt.Sprintf("'%s' in parents and trashed = false", folderID)).
SupportsAllDrives(true).IncludeItemsFromAllDrives(true).Corpora("allDrives").
Fields("nextPageToken, files(size)").PageSize(1000)
if token != "" {
call = call.PageToken(token)
}
list, err := call.Do()
if err != nil {
log.Fatalf("[ERROR] list folder for stats: %v", err)
}
for _, f := range list.Files {
files++
bytes += f.Size
}
if list.NextPageToken == "" {
return files, bytes
}
token = list.NextPageToken
}
}
func fetch(client *http.Client, url string) ([]byte, string, error) {
resp, err := client.Get(url)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("status %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", err
}
contentType, _, _ := strings.Cut(resp.Header.Get("Content-Type"), ";")
return body, strings.TrimSpace(contentType), nil
}
func extension(contentType, url string) string {
if ext, ok := extensions[contentType]; ok {
return ext
}
if ext := path.Ext(strings.SplitN(path.Base(url), "?", 2)[0]); ext != "" && len(ext) <= 5 {
return ext
}
return ".bin"
}
func main() {
sheetID := flag.String("sheet", "", "directory spreadsheet id")
folderID := flag.String("folder", "", "drive folder id (default: the folder shared with the service account)")
flag.Parse()
if *sheetID == "" {
log.Fatal("[ERROR] -sheet is required")
}
source, err := data.NewSheet(map[string]string{"directory": *sheetID})
if err != nil {
log.Fatalf("[ERROR] sheet source: %v", err)
}
model, err := directory.LoadModel(source)
if err != nil {
log.Fatalf("[ERROR] load model: %v", err)
}
svc, err := drive.NewService(context.Background(),
option.WithCredentialsFile(data.KeyFile),
option.WithScopes(drive.DriveScope))
if err != nil {
log.Fatalf("[ERROR] drive client: %v", err)
}
root := *folderID
if root == "" {
root = findRoot(svc)
}
folders := map[string]string{
"people": ensureFolder(svc, root, "people"),
"families": ensureFolder(svc, root, "families"),
}
tasks := []task{}
for _, p := range model.People {
if p.PhotoURL != "" {
tasks = append(tasks, task{"people", localPart(p.Email) + "-photo", p.PhotoURL})
}
if p.PronunciationURL != "" {
tasks = append(tasks, task{"people", localPart(p.Email) + "-pronunciation", p.PronunciationURL})
}
}
for key, f := range model.Families {
if f.PhotoURL != "" {
tasks = append(tasks, task{"families", localPart(key) + "-photo", f.PhotoURL})
}
if f.PronunciationURL != "" {
tasks = append(tasks, task{"families", localPart(key) + "-pronunciation", f.PronunciationURL})
}
}
pending := []task{}
skipped := 0
for folderName, id := range folders {
bases := listBases(svc, id)
for _, t := range tasks {
if t.folderName != folderName {
continue
}
if bases[t.base] {
skipped++
} else {
pending = append(pending, t)
}
}
}
log.Printf("%d media files in model, %d already imported, %d to fetch", len(tasks), skipped, len(pending))
client := &http.Client{Timeout: 60 * time.Second}
var mu sync.Mutex
uploaded := 0
work := make(chan task)
var wg sync.WaitGroup
for range 6 {
wg.Add(1)
go func() {
defer wg.Done()
for t := range work {
body, contentType, err := fetch(client, t.url)
if err != nil {
log.Fatalf("[ERROR] fetch %s/%s: %v", t.folderName, t.base, err)
}
name := t.base + extension(contentType, t.url)
_, err = svc.Files.Create(&drive.File{Name: name, Parents: []string{folders[t.folderName]}}).
Media(bytes.NewReader(body), googleapi.ContentType(contentType)).
SupportsAllDrives(true).Fields("id").Do()
if err != nil {
log.Fatalf("[ERROR] upload %s/%s: %v", t.folderName, name, err)
}
mu.Lock()
uploaded++
if uploaded%50 == 0 {
log.Printf("uploaded %d/%d", uploaded, len(pending))
}
mu.Unlock()
}
}()
}
for _, t := range pending {
work <- t
}
close(work)
wg.Wait()
log.Printf("done: %d uploaded, %d skipped", uploaded, skipped)
var totalFiles, totalBytes int64
for _, folderName := range []string{"people", "families"} {
files, bytes := folderStats(svc, folders[folderName])
totalFiles += files
totalBytes += bytes
log.Printf("%s: %d files, %.1f MB", folderName, files, float64(bytes)/1e6)
}
log.Printf("total: %d files, %d bytes (%.1f MB)", totalFiles, totalBytes, float64(totalBytes)/1e6)
}
+88
View File
@@ -0,0 +1,88 @@
// Command loadcheck loads the directory model from a sheet and prints a summary.
package main
import (
"flag"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"heliosian/internal/data"
"heliosian/internal/directory"
)
type staticFiles struct{}
func (staticFiles) Has(key string) bool {
_, err := os.Stat(filepath.Join("web/static", filepath.FromSlash(key)))
return err == nil
}
func main() {
sheet := flag.String("sheet", "", "spreadsheet id")
flag.Parse()
if *sheet == "" {
log.Fatal("[ERROR] -sheet <spreadsheet id> is required")
}
source, err := data.NewSheet(map[string]string{"directory": *sheet})
if err != nil {
log.Fatalf("[ERROR] sheet source: %v", err)
}
model, err := directory.LoadModel(source, nil, staticFiles{})
if err != nil {
log.Fatalf("[ERROR] load model: %v", err)
}
students, parents, staff, isNew := 0, 0, 0, 0
for _, p := range model.People {
if p.IsStudent {
students++
}
if p.IsParent {
parents++
}
if p.IsStaff {
staff++
}
if p.IsNew {
isNew++
}
}
fmt.Printf("people: %d (students %d, parents %d, staff %d, new %d)\n",
len(model.People), students, parents, staff, isNew)
fmt.Printf("families: %d\n", len(model.Families))
twoHousehold := map[string]int{}
for _, f := range model.Families {
for _, kid := range f.KidEmails {
twoHousehold[kid]++
}
}
for kid, n := range twoHousehold {
if n > 1 {
fmt.Printf(" student in %d households: %s\n", n, kid)
}
}
fmt.Println("classrooms:")
for _, c := range model.Classrooms {
fmt.Printf(" %s (image %v, crews %v)\n", c.Name, c.ImageURL != "", c.HasCrews)
}
fmt.Println("crews:")
for _, c := range model.Crews {
fmt.Printf(" %s | %s | %s | teachers %v\n", c.Classroom, c.Name, c.GradeBand, c.Teachers)
}
bands := []string{}
for band := range model.RoomParents {
bands = append(bands, band)
}
sort.Strings(bands)
fmt.Println("room parents:")
for _, band := range bands {
fmt.Printf(" %s: %d\n", band, len(model.RoomParents[band]))
}
fmt.Println("departments:", model.Departments)
fmt.Println("grades:")
for _, g := range model.Grades {
fmt.Printf(" %s -> %s (%s -> %s)\n", g.Name, g.NextName, g.Band, g.NextBand)
}
}
+41
View File
@@ -0,0 +1,41 @@
// Command probeblob times the download of a few drive media files.
package main
import (
"context"
"fmt"
"io"
"log"
"time"
"heliosian/internal/data"
"google.golang.org/api/drive/v3"
"google.golang.org/api/option"
)
func main() {
svc, err := drive.NewService(context.Background(),
option.WithCredentialsFile(data.KeyFile),
option.WithScopes(drive.DriveScope))
if err != nil {
log.Fatalf("[ERROR] drive client: %v", err)
}
list, err := svc.Files.List().
Q("mimeType != 'application/vnd.google-apps.folder' and trashed = false").
SupportsAllDrives(true).IncludeItemsFromAllDrives(true).Corpora("allDrives").
Fields("files(id, name, size)").PageSize(5).Do()
if err != nil {
log.Fatalf("[ERROR] list: %v", err)
}
for _, f := range list.Files {
start := time.Now()
resp, err := svc.Files.Get(f.Id).SupportsAllDrives(true).Download()
if err != nil {
fmt.Printf("%s (%d bytes): request error after %s: %v\n", f.Name, f.Size, time.Since(start).Round(time.Millisecond), err)
continue
}
n, err := io.Copy(io.Discard, resp.Body)
resp.Body.Close()
fmt.Printf("%s: %d bytes in %s (err %v)\n", f.Name, n, time.Since(start).Round(time.Millisecond), err)
}
}
+23 -4
View File
@@ -7,8 +7,10 @@ import (
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp"
)
@@ -17,6 +19,8 @@ func main() {
out := flag.String("out", "screenshots/capture.png", "output png path")
wait := flag.String("wait", "body", "css selector that must be visible before capturing")
remote := flag.Bool("remote", false, "attach to the capture browser on localhost:9222 instead of launching headless chrome")
cookie := flag.String("cookie", "", "name=value cookie to set for localhost before navigating")
click := flag.String("click", "", "css selector to click after the wait selector appears")
flag.Parse()
ctx := context.Background()
if *remote {
@@ -28,14 +32,29 @@ func main() {
defer cancelBrowser()
ctx, cancelTimeout := context.WithTimeout(ctx, 30*time.Second)
defer cancelTimeout()
actions := []chromedp.Action{chromedp.EmulateViewport(1280, 800)}
if *cookie != "" {
name, value, ok := strings.Cut(*cookie, "=")
if !ok {
log.Fatal("[ERROR] -cookie must be name=value")
}
actions = append(actions, chromedp.ActionFunc(func(ctx context.Context) error {
return network.SetCookie(name, value).WithDomain("localhost").WithPath("/").Do(ctx)
}))
}
var png []byte
err := chromedp.Run(ctx,
chromedp.EmulateViewport(1280, 800),
actions = append(actions,
chromedp.Navigate(*url),
chromedp.WaitVisible(*wait, chromedp.ByQuery),
chromedp.FullScreenshot(&png, 90),
)
if err != nil {
if *click != "" {
actions = append(actions,
chromedp.Click(*click, chromedp.ByQuery),
chromedp.Sleep(500*time.Millisecond),
)
}
actions = append(actions, chromedp.FullScreenshot(&png, 90))
if err := chromedp.Run(ctx, actions...); err != nil {
log.Fatalf("[ERROR] capture %s: %v", *url, err)
}
if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil {
+30
View File
@@ -0,0 +1,30 @@
// Command setcell sets one cell in a sheet tab by key column, appending the row if missing.
package main
import (
"flag"
"log"
"heliosian/internal/data"
)
func main() {
sheet := flag.String("sheet", "", "spreadsheet id")
tab := flag.String("tab", "", "tab title")
keyCol := flag.String("keycol", "Email", "key column name")
key := flag.String("key", "", "key value")
col := flag.String("col", "", "column to set")
value := flag.String("value", "", "value to write")
flag.Parse()
if *sheet == "" || *tab == "" || *key == "" || *col == "" {
log.Fatal("[ERROR] -sheet, -tab, -key, and -col are required")
}
source, err := data.NewSheet(map[string]string{"directory": *sheet})
if err != nil {
log.Fatalf("[ERROR] sheet source: %v", err)
}
if err := source.Upsert("directory", *tab, *keyCol, *key, *col, *value); err != nil {
log.Fatalf("[ERROR] set %s[%s=%s].%s: %v", *tab, *keyCol, *key, *col, err)
}
log.Printf("set %s[%s=%s].%s = %q", *tab, *keyCol, *key, *col, *value)
}
+62
View File
@@ -0,0 +1,62 @@
// Command splash extracts the original app's ios splash screens into web/static/brand/splash.
package main
import (
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"regexp"
"strings"
)
func main() {
raw, err := os.ReadFile("screenshots/brand/page-source.html")
if err != nil {
log.Fatalf("[ERROR] read page source: %v", err)
}
blobs := regexp.MustCompile(`decodeURIComponent\("([^"]+)"\)`).FindAllStringSubmatch(string(raw), -1)
links := [][]string{}
linkRE := regexp.MustCompile(`<link rel="apple-touch-startup-image" media="([^"]+)" href="([^"]+)"`)
for _, blob := range blobs {
decoded, err := url.PathUnescape(blob[1])
if err != nil {
log.Fatalf("[ERROR] decode blob: %v", err)
}
decoded = strings.ReplaceAll(decoded, `\"`, `"`)
links = append(links, linkRE.FindAllStringSubmatch(decoded, -1)...)
}
if len(links) == 0 {
log.Fatal("[ERROR] no splash links found in page source")
}
mediaRE := regexp.MustCompile(`device-width: (\d+)px\) and \(device-height: (\d+)px\) and \(-webkit-device-pixel-ratio: (\d+)\) and \(orientation: (\w+)\)`)
if err := os.MkdirAll("web/static/brand/splash", 0o755); err != nil {
log.Fatalf("[ERROR] create splash dir: %v", err)
}
for _, link := range links {
media, href := link[1], link[2]
mm := mediaRE.FindStringSubmatch(media)
if mm == nil {
log.Fatalf("[ERROR] unparsed media query: %s", media)
}
name := fmt.Sprintf("splash-%sx%s-%sx-%s.png", mm[1], mm[2], mm[3], mm[4])
resp, err := http.Get(strings.ReplaceAll(href, " ", "%20"))
if err != nil {
log.Fatalf("[ERROR] fetch %s: %v", href, err)
}
if resp.StatusCode != http.StatusOK {
log.Fatalf("[ERROR] fetch %s: %s", href, resp.Status)
}
data, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
log.Fatalf("[ERROR] read %s: %v", href, err)
}
if err := os.WriteFile("web/static/brand/splash/"+name, data, 0o644); err != nil {
log.Fatalf("[ERROR] write %s: %v", name, err)
}
fmt.Printf("<link rel=\"apple-touch-startup-image\" media=\"%s\" href=\"/static/brand/splash/%s\">\n", media, name)
}
}
+72
View File
@@ -0,0 +1,72 @@
// Command startserver launches the app, waits for it to listen, prints the pid and an auth header, and leaves it running.
package main
import (
"flag"
"fmt"
"log"
"os"
"os/exec"
"strings"
"time"
"heliosian/internal/auth"
)
func main() {
email := flag.String("email", "ian.gulliver@heliosschool.org", "session email for the minted cookie")
flag.Parse()
key := os.Getenv("SESSION_KEY")
if key == "" {
log.Fatal("[ERROR] SESSION_KEY is required (the server and the minted cookie must share it)")
}
if os.Getenv("DIRECTORY_SHEET") == "" {
log.Fatal("[ERROR] DIRECTORY_SHEET is required")
}
logFile, err := os.Create("/tmp/heliosian-server.log")
if err != nil {
log.Fatalf("[ERROR] create server log: %v", err)
}
cmd := exec.Command("go", "run", ".")
cmd.Stdout = logFile
cmd.Stderr = logFile
if err := cmd.Start(); err != nil {
log.Fatalf("[ERROR] start server: %v", err)
}
deadline := time.Now().Add(10 * time.Minute)
for {
if time.Now().After(deadline) {
cmd.Process.Kill()
log.Fatalf("[ERROR] server did not start within 10 minutes; log: /tmp/heliosian-server.log")
}
content, err := os.ReadFile("/tmp/heliosian-server.log")
if err != nil {
log.Fatalf("[ERROR] read server log: %v", err)
}
if strings.Contains(string(content), "listening on ") {
break
}
if cmd.ProcessState != nil || !processAlive(cmd.Process.Pid) {
fmt.Print(string(content))
log.Fatal("[ERROR] server exited before listening")
}
time.Sleep(time.Second)
}
cookie := auth.Token([]byte(key), *email, time.Now().Add(24*time.Hour))
content, _ := os.ReadFile("/tmp/heliosian-server.log")
for _, line := range strings.Split(strings.TrimSpace(string(content)), "\n") {
fmt.Println(line)
}
fmt.Printf("pid: %d\n", cmd.Process.Pid)
fmt.Println("log: /tmp/heliosian-server.log")
fmt.Printf("header: Cookie: session=%s\n", cookie)
cmd.Process.Release()
}
func processAlive(pid int) bool {
return exec.Command("kill", "-0", fmt.Sprint(pid)).Run() == nil
}
+86
View File
@@ -0,0 +1,86 @@
// Command writetab writes a local CSV into an empty sheet tab below its matching header row.
package main
import (
"context"
"encoding/csv"
"flag"
"fmt"
"log"
"os"
"strings"
"heliosian/internal/data"
"google.golang.org/api/option"
"google.golang.org/api/sheets/v4"
)
func main() {
sheet := flag.String("sheet", "", "spreadsheet id")
tab := flag.String("tab", "", "tab title")
in := flag.String("in", "", "input csv path (first row must match the tab header)")
appendRows := flag.Bool("append", false, "append below existing rows instead of requiring an empty tab")
flag.Parse()
if *sheet == "" || *tab == "" || *in == "" {
log.Fatal("[ERROR] -sheet, -tab, and -in are required")
}
f, err := os.Open(*in)
if err != nil {
log.Fatalf("[ERROR] open %s: %v", *in, err)
}
records, err := csv.NewReader(f).ReadAll()
if err != nil {
log.Fatalf("[ERROR] read %s: %v", *in, err)
}
if err := f.Close(); err != nil {
log.Fatalf("[ERROR] close %s: %v", *in, err)
}
if len(records) < 2 {
log.Fatalf("[ERROR] %s has no data rows", *in)
}
svc, err := sheets.NewService(context.Background(),
option.WithCredentialsFile(data.KeyFile),
option.WithScopes(sheets.SpreadsheetsScope))
if err != nil {
log.Fatalf("[ERROR] create sheets client: %v", err)
}
quoted := "'" + strings.ReplaceAll(*tab, "'", "''") + "'"
resp, err := svc.Spreadsheets.Values.Get(*sheet, quoted).Do()
if err != nil {
log.Fatalf("[ERROR] read tab %s: %v", *tab, err)
}
if len(resp.Values) == 0 {
log.Fatalf("[ERROR] tab %s has no header row", *tab)
}
if !*appendRows && len(resp.Values) != 1 {
log.Fatalf("[ERROR] tab %s has %d rows; want exactly the header row", *tab, len(resp.Values))
}
header := make([]string, len(resp.Values[0]))
for i, cell := range resp.Values[0] {
header[i] = strings.TrimSpace(fmt.Sprint(cell))
}
if strings.Join(header, "\x00") != strings.Join(records[0], "\x00") {
log.Fatalf("[ERROR] header mismatch:\n tab: %q\n csv: %q", header, records[0])
}
values := make([][]interface{}, len(records)-1)
for i, rec := range records[1:] {
row := make([]interface{}, len(rec))
for j, cell := range rec {
row[j] = cell
}
values[i] = row
}
if *appendRows {
_, err = svc.Spreadsheets.Values.Append(*sheet, quoted, &sheets.ValueRange{
Values: values,
}).ValueInputOption("RAW").InsertDataOption("INSERT_ROWS").Do()
} else {
_, err = svc.Spreadsheets.Values.Update(*sheet, quoted+"!A2", &sheets.ValueRange{
Values: values,
}).ValueInputOption("RAW").Do()
}
if err != nil {
log.Fatalf("[ERROR] write rows: %v", err)
}
log.Printf("wrote %d rows to %s", len(values), *tab)
}
+66 -2
View File
@@ -2,24 +2,88 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
<meta name="theme-color" content="#F6F6F6">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Helios Who?</title>
<link rel="manifest" href="/static/manifest.webmanifest">
<link rel="icon" type="image/png" sizes="16x16" href="/static/brand/favicon-16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/favicon-32.png">
<link rel="icon" href="/static/brand/icon-192.png">
<link rel="apple-touch-icon" href="/static/brand/apple-touch-icon.png">
<link rel="apple-touch-startup-image" media="(device-width: 1366px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/static/brand/splash/splash-1366x1024-2x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/static/brand/splash/splash-1024x1366-2x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 1194px) and (device-height: 834px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/static/brand/splash/splash-1194x834-2x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/static/brand/splash/splash-834x1194-2x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 1112px) and (device-height: 834px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/static/brand/splash/splash-1112x834-2x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/static/brand/splash/splash-834x1112-2x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 1080px) and (device-height: 810px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/static/brand/splash/splash-1080x810-2x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/static/brand/splash/splash-810x1080-2x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 768px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/static/brand/splash/splash-1024x768-2x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/static/brand/splash/splash-768x1024-2x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 1133px) and (device-height: 744px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/static/brand/splash/splash-1133x744-2x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/static/brand/splash/splash-744x1133-2x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/static/brand/splash/splash-430x932-3x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/static/brand/splash/splash-393x852-3x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/static/brand/splash/splash-428x926-3x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/static/brand/splash/splash-390x844-3x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/static/brand/splash/splash-414x896-3x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/static/brand/splash/splash-375x812-3x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/static/brand/splash/splash-414x896-2x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/static/brand/splash/splash-414x736-3x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/static/brand/splash/splash-375x667-2x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/static/brand/splash/splash-320x568-2x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 932px) and (device-height: 430px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/static/brand/splash/splash-932x430-3x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 852px) and (device-height: 393px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/static/brand/splash/splash-852x393-3x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 926px) and (device-height: 428px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/static/brand/splash/splash-926x428-3x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 844px) and (device-height: 390px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/static/brand/splash/splash-844x390-3x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 896px) and (device-height: 414px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/static/brand/splash/splash-896x414-3x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 812px) and (device-height: 375px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/static/brand/splash/splash-812x375-3x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 896px) and (device-height: 414px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/static/brand/splash/splash-896x414-2x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 736px) and (device-height: 414px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/static/brand/splash/splash-736x414-3x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 667px) and (device-height: 375px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/static/brand/splash/splash-667x375-2x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 568px) and (device-height: 320px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/static/brand/splash/splash-568x320-2x-landscape.png">
<link rel="stylesheet" href="/static/fonts/fonts.css">
<link rel="stylesheet" href="/static/directory/style.css">
</head>
<body data-user-email="{{.UserEmail}}" data-maps-key="{{.MapsKey}}">
<header class="mobile-top">
<button class="mobile-menu-btn" id="mobile-menu-btn" aria-label="Menu"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></svg></button>
<a class="mobile-back" id="mobile-back" hidden><svg viewBox="0 0 24 24"><path d="M19 12H5m7-7-7 7 7 7"/></svg></a>
<div class="mobile-title" id="mobile-title"></div>
</header>
<aside class="sidebar">
<div class="brand"><img src="/static/brand/icon-192.png" alt=""><span>Helios Who?</span></div>
<nav id="nav"></nav>
<div class="user-menu" id="user-menu" hidden>
<a href="/profile">View Profile</a>
<a href="/people/{{.UserEmail}}">View Profile</a>
<form method="post" action="/auth/logout"><button>Sign Out</button></form>
</div>
<div class="user" id="user"><span class="user-avatar">{{.UserInitial}}</span><span>{{.UserName}}</span></div>
</aside>
<main id="main"></main>
<nav class="mobile-tabs" id="mobile-tabs"></nav>
<div class="drawer-overlay" id="drawer-overlay" hidden></div>
<aside class="drawer" id="drawer" hidden>
<div class="drawer-head">
<img src="/static/brand/icon-192.png" alt=""><span>Helios Who?</span>
<button class="drawer-close" id="drawer-close" aria-label="Close"><svg viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
<div class="drawer-user">
<div class="drawer-user-menu-anchor">
<div class="user-menu" id="drawer-user-menu" hidden>
<a href="/people/{{.UserEmail}}">View Profile</a>
<form method="post" action="/auth/logout"><button>Sign Out</button></form>
</div>
</div>
<div class="drawer-user-info">
<div class="drawer-user-name">{{.UserName}}</div>
<div class="drawer-user-email">{{.UserEmail}}</div>
</div>
<button class="drawer-user-more" id="drawer-user-more" aria-label="Account"><svg viewBox="0 0 24 24"><circle cx="12" cy="5" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="12" cy="19" r="1.6"/></svg></button>
</div>
</aside>
<script src="/static/directory/app.js"></script>
</body>
</html>
+39 -1
View File
@@ -2,10 +2,48 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
<meta name="theme-color" content="#014E54">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Helios Who?</title>
<link rel="manifest" href="/static/manifest.webmanifest">
<link rel="icon" type="image/png" sizes="16x16" href="/static/brand/favicon-16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/favicon-32.png">
<link rel="icon" href="/static/brand/icon-192.png">
<link rel="apple-touch-icon" href="/static/brand/apple-touch-icon.png">
<link rel="apple-touch-startup-image" media="(device-width: 1366px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/static/brand/splash/splash-1366x1024-2x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/static/brand/splash/splash-1024x1366-2x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 1194px) and (device-height: 834px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/static/brand/splash/splash-1194x834-2x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/static/brand/splash/splash-834x1194-2x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 1112px) and (device-height: 834px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/static/brand/splash/splash-1112x834-2x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/static/brand/splash/splash-834x1112-2x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 1080px) and (device-height: 810px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/static/brand/splash/splash-1080x810-2x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/static/brand/splash/splash-810x1080-2x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 768px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/static/brand/splash/splash-1024x768-2x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/static/brand/splash/splash-768x1024-2x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 1133px) and (device-height: 744px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/static/brand/splash/splash-1133x744-2x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/static/brand/splash/splash-744x1133-2x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/static/brand/splash/splash-430x932-3x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/static/brand/splash/splash-393x852-3x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/static/brand/splash/splash-428x926-3x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/static/brand/splash/splash-390x844-3x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/static/brand/splash/splash-414x896-3x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/static/brand/splash/splash-375x812-3x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/static/brand/splash/splash-414x896-2x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/static/brand/splash/splash-414x736-3x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/static/brand/splash/splash-375x667-2x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/static/brand/splash/splash-320x568-2x-portrait.png">
<link rel="apple-touch-startup-image" media="(device-width: 932px) and (device-height: 430px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/static/brand/splash/splash-932x430-3x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 852px) and (device-height: 393px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/static/brand/splash/splash-852x393-3x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 926px) and (device-height: 428px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/static/brand/splash/splash-926x428-3x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 844px) and (device-height: 390px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/static/brand/splash/splash-844x390-3x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 896px) and (device-height: 414px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/static/brand/splash/splash-896x414-3x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 812px) and (device-height: 375px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/static/brand/splash/splash-812x375-3x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 896px) and (device-height: 414px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/static/brand/splash/splash-896x414-2x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 736px) and (device-height: 414px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/static/brand/splash/splash-736x414-3x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 667px) and (device-height: 375px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/static/brand/splash/splash-667x375-2x-landscape.png">
<link rel="apple-touch-startup-image" media="(device-width: 568px) and (device-height: 320px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/static/brand/splash/splash-568x320-2x-landscape.png">
<link rel="stylesheet" href="/static/fonts/fonts.css">
<style>
body {
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

File diff suppressed because it is too large Load Diff
+622 -20
View File
@@ -17,7 +17,7 @@ body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
display: flex;
height: 100vh;
height: 100dvh;
overflow: hidden;
color: var(--ink);
background: #fff;
@@ -49,14 +49,14 @@ body {
border-radius: 8px;
}
nav {
#nav {
display: flex;
flex-direction: column;
gap: 1px;
flex: 1;
}
nav a {
#nav a {
display: flex;
align-items: center;
gap: 12px;
@@ -69,7 +69,7 @@ nav a {
border-radius: 8px;
}
nav a svg {
#nav a svg {
width: 20px;
height: 20px;
stroke: currentColor;
@@ -80,11 +80,11 @@ nav a svg {
flex-shrink: 0;
}
nav a:hover {
#nav a:hover {
background: rgba(255, 255, 255, 0.06);
}
nav a.active {
#nav a.active {
background: var(--sidebar-active);
color: #fff;
}
@@ -421,16 +421,28 @@ main {
stroke-linejoin: round;
}
.gm-style .gm-style-iw-c {
padding: 0 !important;
border-radius: 14px;
}
.gm-style .gm-style-iw-d {
overflow: hidden !important;
padding: 0;
}
.map-popup {
max-width: 220px;
max-width: 260px;
font-family: Inter, system-ui, sans-serif;
}
.map-popup-photo {
width: 100%;
border-radius: 8px;
display: block;
margin-bottom: 8px;
}
.map-popup-body {
padding: 12px 14px 14px;
}
.map-popup-name {
@@ -460,6 +472,10 @@ main {
padding-bottom: 2px;
}
.email-holder {
overflow-x: auto;
}
.email-table {
width: 100%;
border-collapse: collapse;
@@ -478,6 +494,7 @@ main {
.email-table td {
border: 1px solid var(--line);
padding: 9px 12px;
position: relative;
}
.email-table tbody tr:nth-child(even) {
@@ -508,6 +525,62 @@ main {
text-align: center;
}
.copy-glyph {
border: 0;
background: #fff;
padding: 2px;
cursor: pointer;
color: #98a0a6;
display: inline-flex;
border-radius: 4px;
opacity: 0;
}
.copy-glyph svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 2;
}
.copy-glyph:hover,
.copy-glyph.copied {
color: var(--brand);
}
.email-table td > .copy-glyph {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
}
.email-table th .copy-glyph {
background: none;
margin-left: 6px;
vertical-align: middle;
}
.email-table td:hover > .copy-glyph,
.email-table th:hover > .copy-glyph,
.copy-glyph.copied {
opacity: 1;
}
.email-table tbody tr:hover .email-num > span {
visibility: hidden;
}
.email-table tbody tr:hover .email-num > .copy-glyph {
opacity: 1;
}
.email-download {
text-decoration: none;
cursor: pointer;
}
.row-heart {
border: 0;
background: none;
@@ -539,16 +612,6 @@ main {
fill: currentColor;
}
.profile-heading {
font-size: 24px;
font-weight: 700;
margin: 26px 0 10px;
}
.profile-heading:first-child {
margin-top: 4px;
}
.controls {
display: flex;
gap: 10px;
@@ -1089,6 +1152,114 @@ a.list-row:hover {
margin: 18px 0 4px;
}
.photo-wrap {
position: relative;
}
.detail-photo-empty {
aspect-ratio: 1;
background: #eef1f3;
}
.photo-wrap > .edit-icon {
position: absolute;
right: 10px;
bottom: 10px;
}
.edit-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: 0;
color: var(--ink);
padding: 0;
}
.edit-icon svg {
width: 19px;
height: 19px;
stroke: currentColor;
fill: none;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.edit-icon:hover {
color: var(--brand);
}
.edit-icon.recording {
background: #c62828;
color: #fff;
}
.edit-icon.recording:hover {
color: #fff;
}
.pronounce-edit {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
margin-top: 14px;
}
.pronounce-actions {
display: flex;
gap: 12px;
}
.record-preview {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
width: 100%;
}
.record-preview audio {
width: 100%;
}
.media-button {
padding: 7px 16px;
border-radius: 8px;
border: 1px solid var(--line);
background: #fff;
font: inherit;
font-size: 13px;
font-weight: 600;
color: var(--ink);
cursor: pointer;
}
.media-button.primary {
background: var(--brand);
border-color: var(--brand);
color: #fff;
}
.media-status {
color: var(--muted);
font-size: 12.5px;
margin-top: 8px;
text-align: center;
}
.media-status.error {
color: #b3261e;
}
.pronounce-label {
text-align: center;
color: var(--muted);
@@ -1105,6 +1276,81 @@ a.list-row:hover {
font-size: 16px;
font-weight: 700;
margin: 40px 0 10px;
display: flex;
align-items: center;
gap: 12px;
}
.edit-icon.inline {
width: 30px;
height: 30px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
}
.edit-icon.inline svg {
width: 15px;
height: 15px;
}
.about-editor {
width: 100%;
min-height: 130px;
font: inherit;
font-size: 14px;
line-height: 1.55;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
resize: vertical;
}
.about-buttons {
display: flex;
gap: 10px;
margin-top: 10px;
}
.detail-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.detail-name {
display: flex;
align-items: center;
gap: 12px;
}
.editable-value {
display: flex;
align-items: center;
gap: 10px;
}
.field-editor {
margin: 8px 0;
}
.field-editor input {
width: 100%;
max-width: 360px;
font: inherit;
font-size: 14px;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: 8px;
}
.field-note {
font-size: 12px;
color: var(--muted);
margin-top: 6px;
}
.about-status {
text-align: left;
}
.about-text {
@@ -1251,7 +1497,121 @@ a.list-row:hover {
}
}
.mobile-top,
.mobile-tabs,
.drawer,
.drawer-overlay,
.card-more-wrap {
display: none;
}
.more-wrap {
position: relative;
}
.more-menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
min-width: 180px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
padding: 6px 0;
z-index: 25;
}
.more-item {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 16px;
font-size: 14px;
font-weight: 500;
color: var(--ink);
cursor: pointer;
}
.more-item:hover {
background: #f6f6f7;
}
.more-item.active {
color: var(--brand);
}
.more-item svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
flex-shrink: 0;
}
.card-more {
border: 0;
background: none;
padding: 4px;
color: #9aa4ab;
cursor: pointer;
display: flex;
}
.card-more svg {
width: 18px;
height: 18px;
fill: currentColor;
}
.card-menu {
position: absolute;
top: 26px;
right: 0;
background: #fff;
border-radius: 10px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.2);
z-index: 20;
}
.card-menu-item {
display: flex;
align-items: center;
gap: 10px;
border: 0;
background: none;
padding: 12px 16px;
font: inherit;
font-size: 14px;
color: var(--ink);
cursor: pointer;
white-space: nowrap;
}
.card-menu-item svg {
width: 17px;
height: 17px;
stroke: currentColor;
fill: none;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (max-width: 900px) {
body {
display: block;
}
.sidebar {
display: none;
}
main {
height: 100dvh;
padding-top: 48px;
padding-bottom: calc(62px + env(safe-area-inset-bottom));
}
.container {
padding-left: 16px;
padding-right: 16px;
@@ -1263,7 +1623,249 @@ a.list-row:hover {
.student-grid {
grid-template-columns: 1fr;
}
.search {
.mobile-top {
display: flex;
align-items: center;
position: fixed;
top: 0;
left: 0;
right: 0;
height: 48px;
background: var(--sidebar);
z-index: 30;
padding: 0 6px;
}
.mobile-title {
position: absolute;
left: 48px;
right: 48px;
text-align: center;
color: #fff;
font-weight: 600;
font-size: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-menu-btn[hidden],
.mobile-back[hidden] {
display: none;
}
.mobile-menu-btn,
.mobile-back {
background: none;
border: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
z-index: 31;
}
.mobile-menu-btn svg,
.mobile-back svg {
width: 22px;
height: 22px;
stroke: rgba(255, 255, 255, 0.85);
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.mobile-tabs {
display: flex;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: calc(62px + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
background: #fff;
border-top: 1px solid var(--line);
z-index: 30;
}
.mobile-tabs a {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
color: #8a939b;
text-decoration: none;
font-size: 10.5px;
}
.mobile-tabs a svg {
width: 22px;
height: 22px;
stroke: currentColor;
fill: none;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.mobile-tabs a.active {
color: var(--sidebar);
}
.drawer {
display: block;
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 285px;
background: #fff;
z-index: 50;
box-shadow: 0 0 40px rgba(0, 0, 0, 0.25);
}
.drawer[hidden],
.drawer-overlay[hidden] {
display: none;
}
.drawer-overlay {
display: block;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 40;
}
.drawer-head {
display: flex;
align-items: center;
gap: 10px;
padding: 16px;
font-weight: 700;
font-size: 16px;
}
.drawer-head img {
width: 32px;
height: 32px;
border-radius: 8px;
}
.drawer-close {
margin-left: auto;
background: none;
border: 0;
cursor: pointer;
color: #5b6572;
display: flex;
}
.drawer-close svg {
width: 20px;
height: 20px;
stroke: currentColor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
}
.drawer-user {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
padding: 18px 16px;
}
.drawer-user-name {
font-weight: 700;
font-size: 15px;
}
.drawer-user-email {
color: var(--muted);
font-size: 12.5px;
margin-top: 2px;
}
.drawer-user-more {
margin-left: auto;
background: none;
border: 0;
cursor: pointer;
color: #5b6572;
display: flex;
}
.drawer-user-more svg {
width: 20px;
height: 20px;
fill: currentColor;
}
.drawer-user-menu-anchor {
position: absolute;
right: 14px;
bottom: 64px;
width: 190px;
height: 0;
}
.drawer .user-menu {
left: auto;
right: 0;
bottom: 0;
}
.crumbs {
display: none;
}
.detail-top {
justify-content: flex-end;
min-height: 44px;
}
.detail-photo {
width: 160px;
margin: 0 auto;
display: block;
}
.detail-name {
font-size: 26px;
overflow-wrap: anywhere;
}
.contact-row {
gap: 10px;
}
.contact-row > :first-child {
min-width: 0;
}
.contact-value {
overflow-wrap: anywhere;
font-size: 14px;
}
.contact-actions {
flex-shrink: 0;
}
.band-photo {
max-width: 200px;
margin: 0 auto;
}
.content-header {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.controls {
width: 100%;
}
.search {
flex: 1;
width: auto;
}
.search input {
width: 100%;
}
.filter-button span,
.filter-button > svg:last-child {
display: none;
}
.card-more-wrap {
display: block;
position: absolute;
top: 6px;
right: 6px;
}
a.person-card,
a.student-card {
position: relative;
}
.map-canvas {
height: 62vh;
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "Helios Who?",
"short_name": "Helios Who?",
"description": "Please log in with your @heliosschool.org email address",
"display": "standalone",
"id": "/",
"scope": "/",
"start_url": "/",
"theme_color": "#014E54",
"background_color": "#014E54",
"icons": [
{"src": "/static/brand/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
{"src": "/static/brand/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"},
{"src": "/static/brand/maskable-icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}
]
}