Rename Section to Crew, stage the load pipeline, dedupe lookups and token minting, drop burned tools

This commit is contained in:
Ian Gulliver
2026-08-16 13:21:25 -07:00
parent 8c9c1a6cd6
commit 2b836367e1
17 changed files with 389 additions and 530 deletions
+1 -1
View File
@@ -133,7 +133,7 @@ The loader hard-fails — no fallbacks, server refuses to start — on:
- conflicting values for the same adult across import rows
- an invalid canonical value from any layer
The import tool 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 (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. Running it is the whole import procedure. `tools/findsheet` lists the spreadsheets visible to the service account; `tools/sheets` dumps a sheet's tabs, headers, and rows.
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
+17
View File
@@ -35,3 +35,20 @@ Development happens on macOS. Two Homebrew installs cover everything here and in
- **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`
+4
View File
@@ -5,3 +5,7 @@ What remains to build. Current behavior is documented in `docs/dev.md`, `docs/da
## Hosting and deployment
- 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`.
+18 -15
View File
@@ -45,6 +45,21 @@ func Fixed(email string, next http.Handler) http.Handler {
})
}
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)
@@ -52,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
}
@@ -108,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",
@@ -126,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 {
@@ -151,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, "|")
+6 -5
View File
@@ -19,12 +19,13 @@ 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 BlobChecker) (*Cache, error) {
c := &Cache{source: source, geocoder: geocoder, blobs: blobs}
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
}
@@ -52,7 +53,7 @@ func (c *Cache) refreshLoop() {
func (c *Cache) refresh() error {
start := time.Now()
model, err := LoadModel(c.source, c.blobs)
model, err := LoadModel(c.source, c.blobs, c.static)
if err != nil {
return err
}
@@ -60,8 +61,8 @@ func (c *Cache) refresh() error {
c.mu.Lock()
c.model = model
c.mu.Unlock()
log.Printf("loaded directory model: %d people, %d families, %d classrooms, %d sections in %s",
len(model.People), len(model.Families), len(model.Classrooms), len(model.Sections),
log.Printf("loaded directory model: %d people, %d families, %d classrooms, %d crews in %s",
len(model.People), len(model.Families), len(model.Classrooms), len(model.Crews),
time.Since(start).Round(time.Millisecond))
return nil
}
+2 -4
View File
@@ -30,7 +30,7 @@ type app struct {
func MemberGate(cache *Cache, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/auth/login" || r.URL.Path == "/auth/logout" || strings.HasPrefix(r.URL.Path, "/static/") {
if auth.Public(r.URL.Path) || r.URL.Path == "/auth/logout" {
next.ServeHTTP(w, r)
return
}
@@ -60,14 +60,12 @@ func Register(mux *http.ServeMux, cache *Cache, mapsKey string) {
func (a app) myFamily(w http.ResponseWriter, r *http.Request) {
model := a.cache.Model()
email := auth.Email(r)
for _, p := range model.People {
if p.Email == email && p.FamilyKey != "" {
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)
}
+219 -149
View File
@@ -5,7 +5,6 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"os"
"regexp"
"sort"
"strings"
@@ -88,6 +87,11 @@ type household struct {
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 {
@@ -108,51 +112,101 @@ func familyHash(members []string) string {
return hex.EncodeToString(sum[:])[:16]
}
func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
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
}
}
return l.model, nil
}
func (l *loader) readTables(source data.Source) error {
importHeader, importRows, err := source.Table(appName, "Veracross Import")
if err != nil {
return nil, err
return err
}
if err := requireColumns("Veracross Import", importHeader, importColumns); err != nil {
return nil, err
return err
}
l.importRows = importRows
mapHeader, mapRows, err := source.Table(appName, "Name to Email")
if err != nil {
return nil, err
return err
}
if err := requireColumns("Name to Email", mapHeader, []string{"Name", "Email"}); err != nil {
return nil, err
return err
}
overrideHeader, overrideRows, err := source.Table(appName, "Overrides")
if err != nil {
return nil, err
return err
}
if err := requireColumns("Overrides", overrideHeader, overrideColumns); err != nil {
return nil, err
return err
}
l.overrideRows = overrideRows
nameToEmail := map[string]string{}
l.nameToEmail = map[string]string{}
for _, row := range mapRows {
name, email := normName(row["Name"]), strings.ToLower(row["Email"])
if name == "" || email == "" {
return nil, fmt.Errorf("name to email row %v is incomplete", row)
return fmt.Errorf("name to email row %v is incomplete", row)
}
if _, ok := nameToEmail[name]; ok {
return nil, fmt.Errorf("name to email has duplicate name %q", row["Name"])
if _, ok := l.nameToEmail[name]; ok {
return fmt.Errorf("name to email has duplicate name %q", row["Name"])
}
nameToEmail[name] = email
l.nameToEmail[name] = email
}
return nil
}
mappingUses := map[string]int{}
people := map[string]*Person{}
order := []string{}
households := map[string]*household{}
householdOrder := []string{}
personHouseholds := map[string][]string{}
addAdult := func(rawName, email, phone string) error {
func (l *loader) addAdult(rawName, email, phone string) error {
n := parseName(rawName)
if p, ok := people[email]; ok {
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)
}
@@ -165,53 +219,55 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
p.IsParent = true
return nil
}
people[email] = &Person{
l.people[email] = &Person{
Email: email, FullName: n.display, LegalName: n.legal, PreferredName: n.preferred,
Phone: phone, IsParent: true,
}
order = append(order, email)
l.order = append(l.order, email)
return nil
}
for _, row := range importRows {
func (l *loader) transformImport() error {
mappingUses := map[string]int{}
for _, row := range l.importRows {
rawName := row["student_full_name"]
if rawName == "" {
return nil, fmt.Errorf("import row %v has no student name", row)
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 nil, fmt.Errorf("student %s classifications: %w", rawName, err)
return fmt.Errorf("student %s classifications: %w", rawName, err)
}
if gradeBands[classifications.GradeLevel] == "" {
return nil, fmt.Errorf("student %s has unknown grade %q", rawName, classifications.GradeLevel)
return fmt.Errorf("student %s has unknown grade %q", rawName, classifications.GradeLevel)
}
if classifications.Homeroom == "" {
return nil, fmt.Errorf("student %s has no homeroom", rawName)
return fmt.Errorf("student %s has no homeroom", rawName)
}
classroom, crew := splitHomeroom(classifications.Homeroom)
email := strings.ToLower(row["student_email"])
if mapped, ok := nameToEmail[normName(rawName)]; ok {
if mapped, ok := l.nameToEmail[normName(rawName)]; ok {
email = mapped
mappingUses[normName(rawName)]++
}
if email == "" {
return nil, fmt.Errorf("student %s has no email and no name to email entry", rawName)
return fmt.Errorf("student %s has no email and no name to email entry", rawName)
}
if _, ok := people[email]; ok {
return nil, fmt.Errorf("student email %s appears twice", email)
if _, ok := l.people[email]; ok {
return fmt.Errorf("student email %s appears twice", email)
}
n := parseName(rawName)
student := &Person{
Email: email, FullName: n.display, LegalName: n.legal, PreferredName: n.preferred,
IsStudent: true, Grade: classifications.GradeLevel, Classroom: classroom, Section: crew,
IsStudent: true, Grade: classifications.GradeLevel, Classroom: classroom, Crew: crew,
Phone: row["student_phone_mobile"],
}
people[email] = student
order = append(order, email)
l.people[email] = student
l.order = append(l.order, email)
for _, hn := range []string{"1", "2"} {
adults := []string{}
@@ -222,14 +278,14 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
}
adultEmail := strings.ToLower(row[prefix+"email"])
if adultEmail == "" {
return nil, fmt.Errorf("adult %q of student %s has no email", row[prefix+"full_name"], rawName)
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 := addAdult(row[prefix+"full_name"], adultEmail, phone); err != nil {
return nil, err
if err := l.addAdult(row[prefix+"full_name"], adultEmail, phone); err != nil {
return err
}
adults = append(adults, adultEmail)
}
@@ -242,70 +298,65 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
sort.Strings(s)
return s
}(), "\n")
hh, ok := households[setKey]
hh, ok := l.households[setKey]
if !ok {
hh = &household{adults: adults, address: address, phone: phone}
households[setKey] = hh
householdOrder = append(householdOrder, setKey)
l.households[setKey] = hh
l.householdOrder = append(l.householdOrder, setKey)
for _, a := range adults {
if len(personHouseholds[a]) > 0 {
return nil, fmt.Errorf("adult %s belongs to more than one household", a)
if len(l.personHouseholds[a]) > 0 {
return fmt.Errorf("adult %s belongs to more than one household", a)
}
personHouseholds[a] = append(personHouseholds[a], setKey)
l.personHouseholds[a] = append(l.personHouseholds[a], setKey)
}
} else if hh.address != address || hh.phone != phone {
return nil, fmt.Errorf("household of %v has conflicting address or phone across rows", adults)
return fmt.Errorf("household of %v has conflicting address or phone across rows", adults)
}
hh.kids = append(hh.kids, email)
personHouseholds[email] = append(personHouseholds[email], setKey)
l.personHouseholds[email] = append(l.personHouseholds[email], setKey)
student.ParentContactEmails = append(student.ParentContactEmails, adults...)
}
}
for name := range nameToEmail {
for name := range l.nameToEmail {
switch mappingUses[name] {
case 0:
return nil, fmt.Errorf("name to email entry %q matches no import row", name)
return fmt.Errorf("name to email entry %q matches no import row", name)
case 1:
default:
return nil, fmt.Errorf("name to email entry %q matches %d import rows", name, mappingUses[name])
return fmt.Errorf("name to email entry %q matches %d import rows", name, mappingUses[name])
}
}
return nil
}
type familyCells struct {
address, phone, caption string
hasAddress, hasPhone, hasCaption bool
}
familyOverrides := map[string]familyCells{}
func (l *loader) applyOverrides() error {
bandSet := map[string]bool{}
for _, band := range gradeBands {
bandSet[band] = true
}
roomParents := map[string][]string{}
optedOut := map[string]bool{}
seenOverride := map[string]bool{}
for _, row := range overrideRows {
seen := map[string]bool{}
for _, row := range l.overrideRows {
email := strings.ToLower(row["Email"])
if email == "" {
return nil, fmt.Errorf("overrides row %v has no email", row)
return fmt.Errorf("overrides row %v has no email", row)
}
if seenOverride[email] {
return nil, fmt.Errorf("overrides has duplicate email %s", email)
if seen[email] {
return fmt.Errorf("overrides has duplicate email %s", email)
}
seenOverride[email] = true
seen[email] = true
added := row["Added"] == "TRUE"
p, exists := people[email]
p, exists := l.people[email]
if added && exists {
return nil, fmt.Errorf("overrides row %s is flagged added but the import covers this person", email)
return fmt.Errorf("overrides row %s is flagged added but the import covers this person", email)
}
if !added && !exists {
return nil, fmt.Errorf("overrides row %s matches no imported person", email)
return fmt.Errorf("overrides row %s matches no imported person", email)
}
if added {
p = &Person{Email: email}
people[email] = p
order = append(order, email)
l.people[email] = p
l.order = append(l.order, email)
}
apply := func(cell string, field *string) {
@@ -336,29 +387,29 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
"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 nil, err
return err
}
}
apply(row["Pronouns"], &p.Pronouns)
apply(row["Facts"], &p.Facts)
if cell := row["Grade"]; cell != "" && cell != "-" && !added && gradeBands[cell] == "" {
return nil, fmt.Errorf("overrides row %s has unknown grade %q", email, 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.Section)
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 nil, fmt.Errorf("overrides row %s has unknown grade band %q", email, 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 nil, fmt.Errorf("overrides row %s has unknown room parent band %q", email, cell)
return fmt.Errorf("overrides row %s has unknown room parent band %q", email, cell)
}
roomParents[cell] = append(roomParents[cell], email)
l.roomParents[cell] = append(l.roomParents[cell], email)
}
cells := familyCells{}
@@ -381,36 +432,36 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
}
}
if cells.hasAddress || cells.hasPhone || cells.hasCaption {
familyOverrides[email] = cells
l.familyOverrides[email] = cells
}
switch row["Opted Out"] {
case "", "-", "FALSE":
case "TRUE":
optedOut[email] = true
l.optedOut[email] = true
default:
return nil, fmt.Errorf("overrides row %s has invalid Opted Out %q", email, row["Opted Out"])
return fmt.Errorf("overrides row %s has invalid Opted Out %q", email, row["Opted Out"])
}
if added {
if p.FullName == "" {
return nil, fmt.Errorf("added row %s has no full name", email)
return fmt.Errorf("added row %s has no full name", email)
}
if !p.IsStudent && !p.IsParent && !p.IsStaff {
return nil, fmt.Errorf("added row %s has no role", email)
return fmt.Errorf("added row %s has no role", email)
}
}
}
return nil
}
model := &Model{Families: map[string]Family{}, RoomParents: map[string][]string{}}
familyKeys := map[string]string{}
for _, setKey := range householdOrder {
hh := households[setKey]
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)
familyKeys[setKey] = key
model.Families[key] = Family{
l.familyKeys[setKey] = key
l.model.Families[key] = Family{
Key: key,
Address: hh.address,
Phone: hh.phone,
@@ -418,22 +469,22 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
KidEmails: hh.kids,
}
}
for email, sets := range personHouseholds {
if p := people[email]; p.FamilyKey == "" {
p.FamilyKey = familyKeys[sets[0]]
for email, sets := range l.personHouseholds {
if p := l.people[email]; p.FamilyKey == "" {
p.FamilyKey = l.familyKeys[sets[0]]
}
}
for email, cells := range familyOverrides {
p := people[email]
for email, cells := range l.familyOverrides {
p := l.people[email]
if !p.IsParent {
return nil, fmt.Errorf("overrides row %s has family cells but %s is not a parent", email, email)
return fmt.Errorf("overrides row %s has family cells but %s is not a parent", email, email)
}
sets := personHouseholds[email]
sets := l.personHouseholds[email]
if len(sets) != 1 {
return nil, fmt.Errorf("overrides row %s has family cells but %s has no household", email, email)
return fmt.Errorf("overrides row %s has family cells but %s has no household", email, email)
}
key := familyKeys[sets[0]]
family := model.Families[key]
key := l.familyKeys[sets[0]]
family := l.model.Families[key]
if cells.hasAddress {
family.Address = cells.address
}
@@ -443,73 +494,92 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
if cells.hasCaption {
family.PhotoCaption = cells.caption
}
model.Families[key] = family
l.model.Families[key] = family
}
return nil
}
for email := range optedOut {
delete(people, email)
func (l *loader) removeOptedOut() error {
for email := range l.optedOut {
delete(l.people, email)
}
kept := []string{}
for _, email := range order {
if !optedOut[email] {
for _, email := range l.order {
if !l.optedOut[email] {
kept = append(kept, email)
}
}
order = kept
for key, family := range model.Families {
family.AdultEmails = without(family.AdultEmails, optedOut)
family.KidEmails = without(family.KidEmails, optedOut)
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(model.Families, key)
delete(l.model.Families, key)
continue
}
family.Name = familyNameFor(family, people)
model.Families[key] = family
family.Name = familyNameFor(family, l.people)
l.model.Families[key] = family
}
for _, p := range people {
p.ParentContactEmails = without(p.ParentContactEmails, optedOut)
for _, p := range l.people {
p.ParentContactEmails = without(p.ParentContactEmails, l.optedOut)
}
for band, emails := range roomParents {
roomParents[band] = without(emails, optedOut)
for band, emails := range l.roomParents {
l.roomParents[band] = without(emails, l.optedOut)
}
return nil
}
if blobs != nil {
for _, p := range people {
func (l *loader) attachBlobs() error {
if l.blobs == nil {
return nil
}
for _, p := range l.people {
local, _, _ := strings.Cut(p.Email, "@")
if blobs.Has("people/" + local + "-photo") {
if l.blobs.Has("people/" + local + "-photo") {
p.PhotoURL = "/blob/people/" + local + "-photo"
}
if blobs.Has("people/" + local + "-pronunciation") {
if l.blobs.Has("people/" + local + "-pronunciation") {
p.PronunciationURL = "/blob/people/" + local + "-pronunciation"
}
}
for key, family := range model.Families {
if blobs.Has("families/" + key + "-photo") {
for key, family := range l.model.Families {
if l.blobs.Has("families/" + key + "-photo") {
family.PhotoURL = "/blob/families/" + key + "-photo"
}
if blobs.Has("families/" + key + "-pronunciation") {
if l.blobs.Has("families/" + key + "-pronunciation") {
family.PronunciationURL = "/blob/families/" + key + "-pronunciation"
}
model.Families[key] = family
l.model.Families[key] = family
}
return nil
}
for _, email := range order {
model.People = append(model.People, *people[email])
func (l *loader) sortPeople() error {
for _, email := range l.order {
l.model.People = append(l.model.People, *l.people[email])
}
sort.Slice(model.People, func(i, j int) bool {
si, sj := surname(model.People[i].FullName), surname(model.People[j].FullName)
sort.Slice(l.model.People, func(i, j int) bool {
si, sj := surname(l.model.People[i].FullName), surname(l.model.People[j].FullName)
if si != sj {
return si < sj
}
return model.People[i].FullName < model.People[j].FullName
return l.model.People[i].FullName < l.model.People[j].FullName
})
l.model.byEmail = map[string]int{}
for i, p := range l.model.People {
l.model.byEmail[p.Email] = i
}
return nil
}
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] == "" {
@@ -520,8 +590,8 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
info = &classroomInfo{crews: map[string]bool{}, minGrade: len(gradeOrder), bands: map[string]bool{}}
classrooms[p.Classroom] = info
}
if p.Section != "" {
info.crews[p.Section] = true
if p.Crew != "" {
info.crews[p.Crew] = true
}
for i, g := range gradeOrder {
if g == p.Grade && i < info.minGrade {
@@ -544,17 +614,17 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
for _, name := range classroomNames {
info := classrooms[name]
if len(info.bands) != 1 {
return nil, fmt.Errorf("classroom %s spans multiple grade bands", name)
return fmt.Errorf("classroom %s spans multiple grade bands", name)
}
imageURL := ""
imagePath := "web/static/brand/classrooms/classroom-" + strings.ToLower(name) + ".jpg"
if _, err := os.Stat(imagePath); err == nil {
imageURL = "/static/brand/classrooms/classroom-" + strings.ToLower(name) + ".jpg"
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,
HasSections: len(info.crews) > 0,
HasCrews: len(info.crews) > 0,
})
}
@@ -564,10 +634,10 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
}
info, ok := classrooms[p.Classroom]
if !ok {
return nil, fmt.Errorf("staff %s is assigned to unknown classroom %q", p.Email, p.Classroom)
return fmt.Errorf("staff %s is assigned to unknown classroom %q", p.Email, p.Classroom)
}
if p.Section != "" && !info.crews[p.Section] {
return nil, fmt.Errorf("staff %s is assigned to unknown crew %q of %s", p.Email, p.Section, 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 {
@@ -584,33 +654,33 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
if len(crews) == 0 {
crews = []string{""}
}
for _, crew := range crews {
section := Section{Classroom: name, Name: crew, GradeBand: band}
for _, crewName := range crews {
crew := Crew{Classroom: name, Name: crewName, GradeBand: band}
for _, p := range model.People {
if p.IsStaff && p.Classroom == name && p.Section == crew {
section.Teachers = append(section.Teachers, p.Email)
if p.IsStaff && p.Classroom == name && p.Crew == crewName {
crew.Teachers = append(crew.Teachers, p.Email)
}
}
model.Sections = append(model.Sections, section)
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]
}
model.Grades = append(model.Grades, g)
l.model.Grades = append(l.model.Grades, g)
}
for band, emails := range roomParents {
model.RoomParents[bandLabel(band)] = emails
for band, emails := range l.roomParents {
l.model.RoomParents[bandLabel(band)] = emails
}
model.Departments = append(model.Departments, departmentOrder...)
return model, nil
l.model.Departments = append(l.model.Departments, departmentOrder...)
return nil
}
func bandLabel(band string) string {
+24 -22
View File
@@ -15,7 +15,7 @@ type Person struct {
PhotoURL string `json:"photoUrl,omitempty"`
Grade string `json:"grade,omitempty"`
Classroom string `json:"classroom,omitempty"`
Section string `json:"section,omitempty"`
Crew string `json:"crew,omitempty"`
Phone string `json:"phone,omitempty"`
FamilyKey string `json:"familyKey,omitempty"`
ParentContactEmails []string `json:"parentContactEmails,omitempty"`
@@ -41,10 +41,10 @@ type Family struct {
type Classroom struct {
Name string `json:"name"`
ImageURL string `json:"imageUrl,omitempty"`
HasSections bool `json:"hasSections"`
HasCrews bool `json:"hasCrews"`
}
type Section struct {
type Crew struct {
Classroom string `json:"classroom"`
Name string `json:"name,omitempty"`
Teachers []string `json:"teachers,omitempty"`
@@ -58,30 +58,32 @@ type Grade struct {
NextBand string `json:"nextBand,omitempty"`
}
func (m *Model) Member(email string) bool {
for _, p := range m.People {
if p.Email == email {
return true
}
}
return false
}
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
}
+25 -53
View File
@@ -73,6 +73,25 @@ func clearable(value string) string {
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")))
@@ -80,13 +99,7 @@ func (u uploader) edit(w http.ResponseWriter, r *http.Request) {
value := strings.TrimSpace(r.FormValue("value"))
me := auth.Email(r)
model := u.cache.Model()
var person *Person
for i := range model.People {
if model.People[i].Email == key {
person = &model.People[i]
break
}
}
person := model.Person(key)
if person == nil {
http.Error(w, "no such person", http.StatusBadRequest)
return
@@ -144,19 +157,7 @@ func (u uploader) edit(w http.ResponseWriter, r *http.Request) {
return
}
for column, cell := range cells {
if err := u.sheet.Upsert(appName, "Overrides", "Email", key, column, cell); err != nil {
serverError(w, fmt.Errorf("set %s for %s: %w", column, key, err))
return
}
}
logRow := changeLogRow(me, key, previous)
if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil {
serverError(w, fmt.Errorf("append change log after %s edit for %s: %w", field, key, err))
return
}
if err := u.cache.Refresh(); err != nil {
serverError(w, fmt.Errorf("refresh model after %s edit: %w", field, err))
if !u.applyOverride(w, me, key, field+" edit", cells, previous) {
return
}
log.Printf("edit: %s set %s on %s", me, field, key)
@@ -175,17 +176,7 @@ func (u uploader) optOut(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not allowed to edit this record", http.StatusForbidden)
return
}
if err := u.sheet.Upsert(appName, "Overrides", "Email", key, "Opted Out", "TRUE"); err != nil {
serverError(w, fmt.Errorf("opt out %s: %w", key, err))
return
}
logRow := changeLogRow(me, key, map[string]string{"Opted Out": ""})
if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil {
serverError(w, fmt.Errorf("append change log after opt out of %s: %w", key, err))
return
}
if err := u.cache.Refresh(); err != nil {
serverError(w, fmt.Errorf("refresh model after opt out: %w", err))
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)
@@ -207,23 +198,10 @@ func (u uploader) facts(w http.ResponseWriter, r *http.Request) {
return
}
old := ""
for _, p := range model.People {
if p.Email == key {
if p := model.Person(key); p != nil {
old = p.Facts
break
}
}
if err := u.sheet.Upsert(appName, "Overrides", "Email", key, "Facts", facts); err != nil {
serverError(w, fmt.Errorf("update facts for %s: %w", key, err))
return
}
logRow := changeLogRow(me, key, map[string]string{"Facts": old})
if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil {
serverError(w, fmt.Errorf("append change log after facts update for %s: %w", key, err))
return
}
if err := u.cache.Refresh(); err != nil {
serverError(w, fmt.Errorf("refresh model after facts update: %w", err))
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))
@@ -291,13 +269,7 @@ func (u uploader) upload(w http.ResponseWriter, r *http.Request) {
}
func mayEdit(model *Model, me, target, key string) bool {
var mine *Person
for i := range model.People {
if model.People[i].Email == me {
mine = &model.People[i]
break
}
}
mine := model.Person(me)
if mine == nil {
return false
}
+9 -1
View File
@@ -8,6 +8,7 @@ import (
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"heliosian/internal/auth"
@@ -50,6 +51,13 @@ func clientID() string {
return parsed.Web.ClientID
}
type staticFiles struct{}
func (staticFiles) Has(key string) bool {
_, err := os.Stat(filepath.Join("web/static", filepath.FromSlash(key)))
return err == nil
}
func noCache(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
@@ -99,7 +107,7 @@ func main() {
}
blobs = store
}
cache, err := directory.NewCache(source, geocoder, blobs)
cache, err := directory.NewCache(source, geocoder, blobs, staticFiles{})
if err != nil {
log.Fatalf("[ERROR] load directory data: %v", err)
}
+1
View File
@@ -0,0 +1 @@
Timestamp,Actor,Email,Added,Full Name,Legal Name,Preferred Name,Is Student,Is Parent,Is Staff,New to Helios,Pronouns,Facts,Grade,Classroom,Crew,Phone,Job Title,Department,Grade Band,Room Parent,Address,Family Phone,Family Photo Caption,Opted Out
1 Timestamp Actor Email Added Full Name Legal Name Preferred Name Is Student Is Parent Is Staff New to Helios Pronouns Facts Grade Classroom Crew Phone Job Title Department Grade Band Room Parent Address Family Phone Family Photo Caption Opted Out
+3 -8
View File
@@ -2,14 +2,13 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"flag"
"fmt"
"log"
"os"
"time"
"heliosian/internal/auth"
)
func main() {
@@ -19,9 +18,5 @@ func main() {
if key == "" || *email == "" {
log.Fatal("[ERROR] SESSION_KEY and -email are required")
}
payload := fmt.Sprintf("%s|%d", *email, time.Now().Add(24*time.Hour).Unix())
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(payload))
fmt.Println(base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." +
base64.RawURLEncoding.EncodeToString(mac.Sum(nil)))
fmt.Println(auth.Token([]byte(key), *email, time.Now().Add(24*time.Hour)))
}
+13 -13
View File
@@ -5,12 +5,21 @@ 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()
@@ -21,7 +30,7 @@ func main() {
if err != nil {
log.Fatalf("[ERROR] sheet source: %v", err)
}
model, err := directory.LoadModel(source, nil)
model, err := directory.LoadModel(source, nil, staticFiles{})
if err != nil {
log.Fatalf("[ERROR] load model: %v", err)
}
@@ -56,11 +65,11 @@ func main() {
}
fmt.Println("classrooms:")
for _, c := range model.Classrooms {
fmt.Printf(" %s (image %v, crews %v)\n", c.Name, c.ImageURL != "", c.HasSections)
fmt.Printf(" %s (image %v, crews %v)\n", c.Name, c.ImageURL != "", c.HasCrews)
}
fmt.Println("crews:")
for _, s := range model.Sections {
fmt.Printf(" %s | %s | %s | teachers %v\n", s.Classroom, s.Name, s.GradeBand, s.Teachers)
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 {
@@ -76,13 +85,4 @@ func main() {
for _, g := range model.Grades {
fmt.Printf(" %s -> %s (%s -> %s)\n", g.Name, g.NextName, g.Band, g.NextBand)
}
for _, email := range []string{"lexi.augenbergs@heliosschool.org", "daren.liang@heliosschool.org", "yeada.li@heliosschool.org", "dog@heliosschool.org", "mike.orlando@heliosschool.org", "evie.weiss@heliosschool.org"} {
for _, p := range model.People {
if p.Email == email {
fmt.Printf("spot %s: full=%q legal=%q pref=%q roles=S%v/P%v/T%v grade=%q class=%q crew=%q band=%q dept=%q title=%q family=%s\n",
email, p.FullName, p.LegalName, p.PreferredName, p.IsStudent, p.IsParent, p.IsStaff,
p.Grade, p.Classroom, p.Section, p.GradeBand, p.Department, p.JobTitle, p.FamilyKey)
}
}
}
}
-81
View File
@@ -1,81 +0,0 @@
// Command oneoff appends the Opted Out column to the Overrides and Change Log tabs.
package main
import (
"context"
"flag"
"fmt"
"log"
"heliosian/internal/data"
"google.golang.org/api/option"
"google.golang.org/api/sheets/v4"
)
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(sheetId,title,gridProperties(columnCount)))").Do()
if err != nil {
log.Fatalf("[ERROR] get spreadsheet: %v", err)
}
for _, tab := range []string{"Overrides", "Change Log"} {
var props *sheets.SheetProperties
for _, s := range meta.Sheets {
if s.Properties.Title == tab {
props = s.Properties
}
}
if props == nil {
log.Fatalf("[ERROR] no %s tab", tab)
}
resp, err := svc.Spreadsheets.Values.Get(*sheet, fmt.Sprintf("'%s'!1:1", tab)).Do()
if err != nil {
log.Fatalf("[ERROR] read %s header: %v", tab, err)
}
width := len(resp.Values[0])
for _, cell := range resp.Values[0] {
if fmt.Sprint(cell) == "Opted Out" {
log.Fatalf("[ERROR] %s already has an Opted Out column", tab)
}
}
if int64(width) >= props.GridProperties.ColumnCount {
_, err = svc.Spreadsheets.BatchUpdate(*sheet, &sheets.BatchUpdateSpreadsheetRequest{
Requests: []*sheets.Request{{AppendDimension: &sheets.AppendDimensionRequest{
SheetId: props.SheetId,
Dimension: "COLUMNS",
Length: 1,
}}},
}).Do()
if err != nil {
log.Fatalf("[ERROR] widen %s: %v", tab, err)
}
}
cell := fmt.Sprintf("'%s'!%s1", tab, columnName(width))
_, err = svc.Spreadsheets.Values.Update(*sheet, cell, &sheets.ValueRange{
Values: [][]interface{}{{"Opted Out"}},
}).ValueInputOption("RAW").Do()
if err != nil {
log.Fatalf("[ERROR] write %s header: %v", tab, err)
}
log.Printf("added Opted Out to %s at %s", tab, cell)
}
}
func columnName(idx int) string {
name := ""
for idx >= 0 {
name = string(rune('A'+idx%26)) + name
idx = idx/26 - 1
}
return name
}
-126
View File
@@ -1,126 +0,0 @@
// Command renamefamilyblobs renames family media in the drive from parent-email names to family key hashes.
package main
import (
"context"
"flag"
"fmt"
"log"
"path"
"strings"
"heliosian/internal/data"
"heliosian/internal/directory"
"google.golang.org/api/drive/v3"
"google.golang.org/api/option"
)
const folderMime = "application/vnd.google-apps.folder"
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)
if err != nil {
log.Fatalf("[ERROR] load model: %v", err)
}
localToHash := map[string]string{}
for _, family := range model.Families {
for _, adult := range family.AdultEmails {
local, _, _ := strings.Cut(adult, "@")
if existing, ok := localToHash[local]; ok && existing != family.Key {
log.Fatalf("[ERROR] adult local part %s maps to two families", local)
}
localToHash[local] = family.Key
}
}
svc, err := drive.NewService(context.Background(),
option.WithCredentialsFile(data.KeyFile),
option.WithScopes(drive.DriveScope))
if err != nil {
log.Fatalf("[ERROR] drive client: %v", err)
}
drives, err := svc.Drives.List().Do()
if err != nil {
log.Fatalf("[ERROR] list shared drives: %v", err)
}
if len(drives.Drives) != 1 {
log.Fatalf("[ERROR] expected one shared drive, found %d", len(drives.Drives))
}
folderList, err := svc.Files.List().
Q(fmt.Sprintf("name = 'families' and '%s' in parents and mimeType = '%s' and trashed = false", drives.Drives[0].Id, folderMime)).
SupportsAllDrives(true).IncludeItemsFromAllDrives(true).Corpora("allDrives").
Fields("files(id)").Do()
if err != nil {
log.Fatalf("[ERROR] find families folder: %v", err)
}
if len(folderList.Files) != 1 {
log.Fatalf("[ERROR] expected one families folder, found %d", len(folderList.Files))
}
folderID := folderList.Files[0].Id
renamed, kept, unknown := 0, 0, 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, mimeType)").PageSize(1000)
if token != "" {
call = call.PageToken(token)
}
list, err := call.Do()
if err != nil {
log.Fatalf("[ERROR] list families folder: %v", err)
}
for _, f := range list.Files {
if f.MimeType == folderMime {
continue
}
ext := path.Ext(f.Name)
base := strings.TrimSuffix(f.Name, ext)
kind := ""
for _, k := range []string{"-photo", "-pronunciation"} {
if strings.HasSuffix(base, k) {
kind = k
}
}
if kind == "" {
log.Printf("[ERROR] unrecognized file name %q, leaving it", f.Name)
unknown++
continue
}
local := strings.TrimSuffix(base, kind)
hash, ok := localToHash[local]
if !ok {
if _, isCurrent := model.Families[local]; isCurrent {
kept++
continue
}
log.Printf("[ERROR] file %q matches no family adult, leaving it", f.Name)
unknown++
continue
}
newName := hash + kind + ext
_, err := svc.Files.Update(f.Id, &drive.File{Name: newName}).SupportsAllDrives(true).Do()
if err != nil {
log.Fatalf("[ERROR] rename %q to %q: %v", f.Name, newName, err)
}
log.Printf("renamed %q -> %q", f.Name, newName)
renamed++
}
if list.NextPageToken == "" {
break
}
token = list.NextPageToken
}
log.Printf("done: %d renamed, %d already keyed by hash, %d left untouched", renamed, kept, unknown)
}
+3 -8
View File
@@ -2,9 +2,6 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"flag"
"fmt"
"log"
@@ -12,6 +9,8 @@ import (
"os/exec"
"strings"
"time"
"heliosian/internal/auth"
)
func main() {
@@ -56,11 +55,7 @@ func main() {
time.Sleep(time.Second)
}
payload := fmt.Sprintf("%s|%d", *email, time.Now().Add(24*time.Hour).Unix())
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(payload))
cookie := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." +
base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
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") {
+7 -7
View File
@@ -189,7 +189,7 @@ function roleLabel(p) {
}
function gradeChain(p) {
return [p.grade, p.classroom, p.section].filter(Boolean).join(' ▶ ');
return [p.grade, p.classroom, p.crew].filter(Boolean).join(' ▶ ');
}
function personContext(p) {
@@ -1397,11 +1397,11 @@ function parentsOf(students) {
function teachersOf(classroomNames) {
const seen = new Set();
const teachers = [];
for (const section of state.model.sections) {
if (!classroomNames.includes(section.classroom)) {
for (const crew of state.model.crews) {
if (!classroomNames.includes(crew.classroom)) {
continue;
}
for (const name of section.teachers || []) {
for (const name of crew.teachers || []) {
if (!seen.has(name)) {
seen.add(name);
teachers.push(name);
@@ -1512,9 +1512,9 @@ function renderClassroomDetail(slug) {
return;
}
const students = studentsOf(p => p.classroom === classroom.name);
const sections = [...new Set(students.map(s => s.section).filter(Boolean))].sort();
const groups = sections.length
? sections.map(name => ({header: name, students: students.filter(s => s.section === name)}))
const crews = [...new Set(students.map(s => s.crew).filter(Boolean))].sort();
const groups = crews.length
? crews.map(name => ({header: name, students: students.filter(s => s.crew === name)}))
: [{header: classroom.name, students}];
renderRoster(classroom.name, classroom.imageUrl, groups);
}