Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e934e02cd | ||
|
|
072d99d412 | ||
|
|
778dd33b20 | ||
|
|
440b3f11de | ||
|
|
b9bb8629a8 | ||
|
|
2eaba6810c | ||
|
|
2574ced672 | ||
|
|
a7f1961c5f | ||
|
|
2b836367e1 | ||
|
|
8c9c1a6cd6 | ||
|
|
2b6e77c766 | ||
|
|
4cd128e242 | ||
|
|
4abe9d281f | ||
|
|
4d70d6c2a8 | ||
|
|
366e3bb8a2 | ||
|
|
bbff936f39 | ||
|
|
516ef2dea0 | ||
|
|
bbdeb3e91e | ||
|
|
77b0401a8b | ||
|
|
1465a4ef05 | ||
|
|
54a5b4615f | ||
|
|
272ce380bb | ||
|
|
b418dd19ee | ||
|
|
0f2291473f | ||
|
|
2a800135b7 | ||
|
|
8ddf729a92 | ||
|
|
fd52c948db | ||
|
|
c56310d219 |
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
creds/
|
||||
docs/
|
||||
sampledata/
|
||||
screenshots/
|
||||
Dockerfile
|
||||
LICENSE.md
|
||||
@@ -0,0 +1,8 @@
|
||||
.gcloudignore
|
||||
.git
|
||||
.gitignore
|
||||
creds/
|
||||
docs/
|
||||
sampledata/
|
||||
screenshots/
|
||||
LICENSE.md
|
||||
@@ -1,4 +1,5 @@
|
||||
creds/
|
||||
imports/
|
||||
screenshots/
|
||||
*.png
|
||||
!web/**/*.png
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM golang:1.26 AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -o /heliosian .
|
||||
|
||||
FROM gcr.io/distroless/static-debian12
|
||||
WORKDIR /app
|
||||
COPY --from=build /heliosian /app/heliosian
|
||||
COPY web /app/web
|
||||
ENTRYPOINT ["/app/heliosian"]
|
||||
@@ -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
|
||||
@@ -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 | 1–2 | Falcons, Hawks | — |
|
||||
| Jayvens | 3–4 | Jays, Ravens | — |
|
||||
| Cospreys | 5–6 | Condors, Ospreys | Pinnacles/Big Sur, River/Sea |
|
||||
| Hegrets | 7–8 | 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,34 +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.
|
||||
|
||||
## 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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -49,20 +49,12 @@ The staff list as a top-level section — same content as the People staff tab.
|
||||
|
||||
### Map
|
||||
|
||||
A map of family locations, plus an "update my address" self-service action.
|
||||
A Google map of family locations: one brand-teal pin per geocoded family address, a popup card (family photo, name, address, family-page link) on pin click, and search and filters narrowing the pins. Below the map, an "update my address" self-service action.
|
||||
|
||||
### Email List
|
||||
|
||||
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`).
|
||||
|
||||
@@ -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, Map.
|
||||
- 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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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, "|")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
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 := make([][]interface{}, len(rows))
|
||||
for i, row := range rows {
|
||||
cells := make([]interface{}, len(row))
|
||||
for j, cell := range row {
|
||||
cells[j] = cell
|
||||
}
|
||||
records = append(records, record)
|
||||
values[i] = cells
|
||||
}
|
||||
return records, nil
|
||||
return parseTable(name, values)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -6,18 +6,26 @@ import (
|
||||
"time"
|
||||
|
||||
"heliosian/internal/data"
|
||||
"heliosian/internal/geocode"
|
||||
)
|
||||
|
||||
const refreshInterval = 5 * time.Minute
|
||||
|
||||
type Cache struct {
|
||||
source data.Source
|
||||
mu sync.RWMutex
|
||||
model *Model
|
||||
type Geocoder interface {
|
||||
Lookup(address string) (geocode.Point, error)
|
||||
}
|
||||
|
||||
func NewCache(source data.Source) (*Cache, error) {
|
||||
c := &Cache{source: source}
|
||||
type Cache struct {
|
||||
source data.Source
|
||||
geocoder Geocoder
|
||||
blobs BlobChecker
|
||||
static BlobChecker
|
||||
mu sync.RWMutex
|
||||
model *Model
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -25,6 +33,10 @@ func NewCache(source data.Source) (*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()
|
||||
@@ -41,15 +53,60 @@ 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
|
||||
}
|
||||
c.geocodeFamilies(model)
|
||||
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
|
||||
}
|
||||
|
||||
func (c *Cache) geocodeFamilies(model *Model) {
|
||||
start := time.Now()
|
||||
type job struct {
|
||||
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
|
||||
located := 0
|
||||
for range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := range jobs {
|
||||
point, err := c.geocoder.Lookup(j.address)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] %v", err)
|
||||
continue
|
||||
}
|
||||
mu.Lock()
|
||||
family := model.Families[j.key]
|
||||
family.Lat = point.Lat
|
||||
family.Lng = point.Lng
|
||||
model.Families[j.key] = family
|
||||
located++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, j := range pending {
|
||||
jobs <- j
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
log.Printf("geocoded %d of %d family addresses in %s", located, len(pending), time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -23,15 +24,30 @@ var legacy = map[string]string{
|
||||
}
|
||||
|
||||
type app struct {
|
||||
cache *Cache
|
||||
cache *Cache
|
||||
mapsKey string
|
||||
}
|
||||
|
||||
func Register(mux *http.ServeMux, cache *Cache) {
|
||||
a := app{cache: cache}
|
||||
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)
|
||||
@@ -40,6 +56,18 @@ func Register(mux *http.ServeMux, cache *Cache) {
|
||||
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]
|
||||
@@ -60,6 +88,7 @@ func (a app) page(w http.ResponseWriter, r *http.Request) {
|
||||
"UserName": name,
|
||||
"UserInitial": strings.ToUpper(name[:1]),
|
||||
"UserEmail": auth.Email(r),
|
||||
"MapsKey": a.mapsKey,
|
||||
}
|
||||
if err := t.Execute(w, data); err != nil {
|
||||
log.Printf("[ERROR] render directory page: %v", err)
|
||||
|
||||
@@ -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{}}
|
||||
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
|
||||
|
||||
staffByEmail := map[string]map[string]string{}
|
||||
for _, row := range tables["Staff Details"] {
|
||||
email := strings.ToLower(row["Email Lower"])
|
||||
if email != "" {
|
||||
staffByEmail[email] = row
|
||||
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
|
||||
}
|
||||
families := map[string]*familyAcc{}
|
||||
byEmail := map[string]Person{}
|
||||
for _, row := range tables["Basic Directory"] {
|
||||
email := strings.ToLower(row["Email Lower"])
|
||||
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)]++
|
||||
}
|
||||
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 == "" {
|
||||
continue
|
||||
}
|
||||
acc, ok := families[p.FamilyKey]
|
||||
if !ok {
|
||||
acc = &familyAcc{family: Family{Key: p.FamilyKey}}
|
||||
families[p.FamilyKey] = acc
|
||||
}
|
||||
if acc.family.Address == "" && row["Address 1"] != "" {
|
||||
acc.family.Address = row["Address 1"]
|
||||
if row["Address 2"] != "" {
|
||||
acc.family.Address += ", " + row["Address 2"]
|
||||
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
|
||||
}
|
||||
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 acc.family.PhotoURL == "" {
|
||||
acc.family.PhotoURL = blobURL("families", p.FamilyKey, "photo", row["Family Photo"])
|
||||
}
|
||||
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)
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
hh.kids = append(hh.kids, email)
|
||||
l.personHouseholds[email] = append(l.personHouseholds[email], setKey)
|
||||
student.ParentContactEmails = append(student.ParentContactEmails, adults...)
|
||||
}
|
||||
}
|
||||
for key, acc := range families {
|
||||
if !acc.hasParent && !acc.hasStudent {
|
||||
|
||||
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
|
||||
}
|
||||
acc.family.Name = familyName(acc.family, byEmail)
|
||||
model.Families[key] = acc.family
|
||||
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
|
||||
}
|
||||
|
||||
sort.Slice(model.People, func(i, j int) bool {
|
||||
si, sj := surname(model.People[i].FullName), surname(model.People[j].FullName)
|
||||
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
|
||||
})
|
||||
|
||||
for _, row := range tables["Classrooms"] {
|
||||
if row["Class"] == "" {
|
||||
continue
|
||||
}
|
||||
imageURL := ""
|
||||
if row["Classroom Image"] != "" {
|
||||
imageURL = "/static/brand/classrooms/classroom-" + strings.ToLower(row["Class"]) + ".jpg"
|
||||
}
|
||||
model.Classrooms = append(model.Classrooms, Classroom{
|
||||
Name: row["Class"],
|
||||
ImageURL: imageURL,
|
||||
HasSections: row["Has Sections"] == "TRUE",
|
||||
})
|
||||
l.model.byEmail = map[string]int{}
|
||||
for i, p := range l.model.People {
|
||||
l.model.byEmail[p.Email] = i
|
||||
}
|
||||
|
||||
for _, row := range tables["Schedules"] {
|
||||
if row["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])
|
||||
}
|
||||
}
|
||||
model.Sections = append(model.Sections, section)
|
||||
}
|
||||
|
||||
for _, row := range tables["Grade Lookup"] {
|
||||
if row["Current Grade"] == "" {
|
||||
continue
|
||||
}
|
||||
model.Grades = append(model.Grades, Grade{
|
||||
Name: row["Current Grade"],
|
||||
NextName: row["Next Grade"],
|
||||
Band: row["Current Gradeband"],
|
||||
NextBand: row["Next Gradeband"],
|
||||
})
|
||||
}
|
||||
|
||||
for _, row := range tables["Room Parents"] {
|
||||
band, email := row["Gradeband"], strings.ToLower(row["Email Address"])
|
||||
if band == "" || email == "" {
|
||||
continue
|
||||
}
|
||||
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
|
||||
return nil
|
||||
}
|
||||
|
||||
func blobURL(folder, email, kind, source string) string {
|
||||
if source == "" {
|
||||
return ""
|
||||
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
|
||||
}
|
||||
local, _, _ := strings.Cut(email, "@")
|
||||
return "/blob/" + folder + "/" + local + "-" + kind
|
||||
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 := ""
|
||||
imageKey := "brand/classrooms/classroom-" + strings.ToLower(name) + ".jpg"
|
||||
if l.static.Has(imageKey) {
|
||||
imageURL = "/static/" + imageKey
|
||||
}
|
||||
model.Classrooms = append(model.Classrooms, Classroom{
|
||||
Name: name,
|
||||
ImageURL: imageURL,
|
||||
HasCrews: len(info.crews) > 0,
|
||||
})
|
||||
}
|
||||
|
||||
for _, p := range model.People {
|
||||
if !p.IsStaff || p.Classroom == "" {
|
||||
continue
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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, " / ")
|
||||
}
|
||||
|
||||
func without(list []string, drop map[string]bool) []string {
|
||||
kept := []string{}
|
||||
for _, item := range list {
|
||||
if !drop[item] {
|
||||
kept = append(kept, item)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,9 @@ 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"`
|
||||
PhotoCaption string `json:"photoCaption,omitempty"`
|
||||
PronunciationURL string `json:"pronunciationUrl,omitempty"`
|
||||
@@ -36,12 +39,12 @@ type Family struct {
|
||||
}
|
||||
|
||||
type Classroom struct {
|
||||
Name string `json:"name"`
|
||||
ImageURL string `json:"imageUrl,omitempty"`
|
||||
HasSections bool `json:"hasSections"`
|
||||
Name string `json:"name"`
|
||||
ImageURL string `json:"imageUrl,omitempty"`
|
||||
HasCrews bool `json:"hasCrews"`
|
||||
}
|
||||
|
||||
type Section struct {
|
||||
type Crew struct {
|
||||
Classroom string `json:"classroom"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Teachers []string `json:"teachers,omitempty"`
|
||||
@@ -55,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Package geocode resolves street addresses to coordinates via the google geocoding api.
|
||||
package geocode
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Point struct {
|
||||
Lat float64
|
||||
Lng float64
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
key string
|
||||
mu sync.Mutex
|
||||
cache map[string]Point
|
||||
}
|
||||
|
||||
func New(key string) *Client {
|
||||
return &Client{key: key, cache: map[string]Point{}}
|
||||
}
|
||||
|
||||
func (c *Client) Lookup(address string) (Point, error) {
|
||||
c.mu.Lock()
|
||||
point, ok := c.cache[address]
|
||||
c.mu.Unlock()
|
||||
if ok {
|
||||
return point, nil
|
||||
}
|
||||
resp, err := http.Get("https://maps.googleapis.com/maps/api/geocode/json?address=" +
|
||||
url.QueryEscape(address) + "&key=" + url.QueryEscape(c.key))
|
||||
if err != nil {
|
||||
return Point{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var parsed struct {
|
||||
Status string `json:"status"`
|
||||
Results []struct {
|
||||
Geometry struct {
|
||||
Location struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
} `json:"location"`
|
||||
} `json:"geometry"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||
return Point{}, err
|
||||
}
|
||||
if parsed.Status != "OK" || len(parsed.Results) == 0 {
|
||||
return Point{}, fmt.Errorf("geocode %q: %s", address, parsed.Status)
|
||||
}
|
||||
point = Point{Lat: parsed.Results[0].Geometry.Location.Lat, Lng: parsed.Results[0].Geometry.Location.Lng}
|
||||
c.mu.Lock()
|
||||
c.cache[address] = point
|
||||
c.mu.Unlock()
|
||||
return point, nil
|
||||
}
|
||||
@@ -5,26 +5,20 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"heliosian/internal/auth"
|
||||
"heliosian/internal/blob"
|
||||
"heliosian/internal/data"
|
||||
"heliosian/internal/directory"
|
||||
"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 != "" {
|
||||
@@ -57,35 +51,91 @@ 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) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
func mapsKey(envName, file string) string {
|
||||
if key := os.Getenv(envName); key != "" {
|
||||
return key
|
||||
}
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
log.Fatalf("[ERROR] read %s (or set %s): %v", file, envName, err)
|
||||
}
|
||||
key := strings.TrimSpace(string(raw))
|
||||
if key == "" {
|
||||
log.Fatalf("[ERROR] %s is empty", file)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func main() {
|
||||
authn := auth.New(clientID(), sessionKey())
|
||||
cache, err := directory.NewCache(directorySource())
|
||||
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)
|
||||
if os.Getenv("DIRECTORY_SHEET") != "" {
|
||||
store, err := blob.New()
|
||||
if err != nil {
|
||||
log.Fatalf("[ERROR] blob store: %v", err)
|
||||
}
|
||||
directory.Register(mux, cache, browserKey)
|
||||
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)))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
@@ -0,0 +1,3 @@
|
||||
Name,Email
|
||||
Mia Torres,mia.torres@heliosschool.org
|
||||
Sam Whitfield,sam.whitfield@heliosschool.org
|
||||
|
@@ -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,,,,,,
|
||||
|
@@ -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,,,,,,,,,,,,,,,,,,
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}}">
|
||||
<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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 602 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 490 KiB |
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 365 KiB |
|
After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 484 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 363 KiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 395 KiB |
|
After Width: | Height: | Size: 412 KiB |
|
After Width: | Height: | Size: 414 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 363 KiB |
|
After Width: | Height: | Size: 348 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 342 KiB |
|
After Width: | Height: | Size: 388 KiB |
|
After Width: | Height: | Size: 358 KiB |
|
After Width: | Height: | Size: 385 KiB |
|
After Width: | Height: | Size: 410 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 445 KiB |
|
After Width: | Height: | Size: 472 KiB |
|
After Width: | Height: | Size: 476 KiB |
@@ -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;
|
||||
}
|
||||
@@ -388,6 +388,83 @@ main {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.map-canvas {
|
||||
height: 600px;
|
||||
border-radius: 12px;
|
||||
background: #eef1f3;
|
||||
}
|
||||
|
||||
.map-update {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.map-update-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.map-update-link svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
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: 260px;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.map-popup-photo {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.map-popup-body {
|
||||
padding: 12px 14px 14px;
|
||||
}
|
||||
|
||||
.map-popup-name {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.map-popup-sub {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.map-popup-link {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.email-hint {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
@@ -395,6 +472,10 @@ main {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.email-holder {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.email-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -413,6 +494,7 @@ main {
|
||||
.email-table td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 9px 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.email-table tbody tr:nth-child(even) {
|
||||
@@ -443,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;
|
||||
@@ -474,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;
|
||||
@@ -1024,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);
|
||||
@@ -1040,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 {
|
||||
@@ -1186,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;
|
||||
@@ -1198,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||