build: invites from the plan, and rebuilding a live site without losing its words

`invites_by` on a plan (and `invited_by` on a group) put an invite form
per fillable group on the inviting group's desk, plus a list of the
invites sent - portal's `invite:` does the onboarding. Every desk
group invites into itself by default; an inviter without a desk is
refused.

`--words-from <repo>` lifts the words an existing repo carries back
into the words map by structure: the plan is rendered with a token in
every slot and walked alongside the repo's files. Hand edits and
model-chosen icons survive a rebuild; only new slots get fallbacks,
which `--words <json>` can override. The words as used are written to
words.json. First use: Tomter Vel, additions only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-23 12:42:23 +02:00
co-authored by Claude Fable 5.1
parent 255aeb4f4b
commit 93a6fc0e10
6 changed files with 1182 additions and 4 deletions
+17
View File
@@ -32,6 +32,23 @@ The model writes words only, one page at a time, into a JSON object
whose shape is fixed. Its strings are merged over built-in fallbacks whose shape is fixed. Its strings are merged over built-in fallbacks
key by key, so a bad reply costs wording, never a working site. key by key, so a bad reply costs wording, never a working site.
### Onboarding from the plan
`invites_by: [styret]` on a plan, or `invited_by: [...]` on a group,
puts an invite form on the inviting group's desk for each group it may
fill, and a list of the invites sent. Portal does the rest (an account
in Kanidm, the group, a one-time credential link) through `invite:` on
the alternative. Every group with a desk invites into itself by
default.
### Rebuilding a live site
`--words-from <repo>` lifts the words a repo already has back into the
words map by structure, so a rebuild keeps hand-edited wording and
adds only the new slots; `--words <json>` overrides slots by page;
`words.json` in the output is the words as used. See
`runs/tomtervel-invites-2026-09-23`.
| Same contractor, same local model (nemotron 30B-A3B on ergo) | Free-form `generate` | `build` | | Same contractor, same local model (nemotron 30B-A3B on ergo) | Free-form `generate` | `build` |
|---|---|---| |---|---|---|
| A site the verifier accepts | never, in 6 rounds and 16 min | first try, 130 s | | A site the verifier accepts | never, in 6 rounds and 16 min | first try, 130 s |
+4
View File
@@ -24,6 +24,10 @@ groups:
- { id: pendlerforhold, label: Pendlerforhold } - { id: pendlerforhold, label: Pendlerforhold }
- { id: trafikk, label: Trafikkforhold } - { id: trafikk, label: Trafikkforhold }
# Styret kan invitere inn i alle grupper som har en pult; hver gruppe kan
# invitere inn i seg selv. Portal oppretter kontoen og en engangslenke.
invites_by: [styret]
doors: doors:
- id: medlem - id: medlem
who: en husstand i Tomter som vil bli medlem, betale kontingent, eller melde flytting, ny e-post eller utmelding who: en husstand i Tomter som vil bli medlem, betale kontingent, eller melde flytting, ny e-post eller utmelding
@@ -0,0 +1,11 @@
# Tomter Vel: invites on every desk, 2026-09-23
The first rebuild of a live site from its plan with `--words-from`:
every word the repo carried (hand-edited Norwegian, model-chosen
icons) lifted back into the words map by structure, the new invite
slots given Norwegian by `invite-words-no.json`, and the result
diffed against the live repo: additions only, 33 lines per committee
desk and 145 on styret's, which invites into all eight groups.
`words.json` is the words as used; the next rebuild can start from it
with `--words words.json` instead of lifting.
@@ -0,0 +1,40 @@
{
"questions/arrangementer/index.yaml": { "invites": {
"arrangementer": { "heading": "Inviter noen inn i Arrangementer", "description": "De får en konto, plass i Arrangementer, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } }
}, "invited": { "heading": "Sendte invitasjoner", "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", "done": "Er inne" } }
,
"questions/horingsinstans/index.yaml": { "invites": {
"horingsinstans": { "heading": "Inviter noen inn i Høringsinstans", "description": "De får en konto, plass i Høringsinstans, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } }
}, "invited": { "heading": "Sendte invitasjoner", "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", "done": "Er inne" } }
,
"questions/kasserer/index.yaml": { "invites": {
"kasserer": { "heading": "Inviter noen inn i kassereren", "description": "De får en konto, plass i kassereren, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } }
}, "invited": { "heading": "Sendte invitasjoner", "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", "done": "Er inne" } }
,
"questions/komiteer/index.yaml": { "invites": {
"komiteer": { "heading": "Inviter noen inn i komiteene", "description": "De får en konto, plass i komiteene, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } }
}, "invited": { "heading": "Sendte invitasjoner", "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", "done": "Er inne" } }
,
"questions/lekeplasser/index.yaml": { "invites": {
"lekeplasser": { "heading": "Inviter noen inn i Leke- og møteplasser", "description": "De får en konto, plass i Leke- og møteplasser, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } }
}, "invited": { "heading": "Sendte invitasjoner", "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", "done": "Er inne" } }
,
"questions/nabohjelp/index.yaml": { "invites": {
"nabohjelp": { "heading": "Inviter noen inn i nabohjelpen", "description": "De får en konto, plass i nabohjelpen, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } }
}, "invited": { "heading": "Sendte invitasjoner", "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", "done": "Er inne" } }
,
"questions/styret/index.yaml": { "invites": {
"arrangementer": { "heading": "Inviter noen inn i Arrangementer", "description": "De får en konto, plass i Arrangementer, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } },
"horingsinstans": { "heading": "Inviter noen inn i Høringsinstans", "description": "De får en konto, plass i Høringsinstans, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } },
"kasserer": { "heading": "Inviter noen inn i kassereren", "description": "De får en konto, plass i kassereren, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } },
"komiteer": { "heading": "Inviter noen inn i komiteene", "description": "De får en konto, plass i komiteene, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } },
"lekeplasser": { "heading": "Inviter noen inn i Leke- og møteplasser", "description": "De får en konto, plass i Leke- og møteplasser, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } },
"nabohjelp": { "heading": "Inviter noen inn i nabohjelpen", "description": "De får en konto, plass i nabohjelpen, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } },
"styret": { "heading": "Inviter noen inn i styret", "description": "De får en konto, plass i styret, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } },
"trafikk": { "heading": "Inviter noen inn i Trafikkforhold", "description": "De får en konto, plass i Trafikkforhold, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } }
}, "invited": { "heading": "Sendte invitasjoner", "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", "done": "Er inne" } }
,
"questions/trafikk/index.yaml": { "invites": {
"trafikk": { "heading": "Inviter noen inn i Trafikkforhold", "description": "De får en konto, plass i Trafikkforhold, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } }
}, "invited": { "heading": "Sendte invitasjoner", "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", "done": "Er inne" } }
}
@@ -0,0 +1,873 @@
{
"questions/arrangementer/index.yaml": {
"buckets": {
"frivillige": {
"description": "Her ser du hvem som har meldt seg til dugnad, 17. mai eller andre arrangementer, og hvor langt du har kommet med hver av dem.",
"heading": "Frivillige til arrangementer og dugnad",
"moves": {
"kontaktet->satt_opp": "Sett opp",
"kontaktet->takket_nei": "Takket nei",
"open->kontaktet": "Merk som kontaktet",
"open->takket_nei": "Takket nei"
}
}
},
"confirm": "Lagre",
"description": "Oversikt over folk som vil hjelpe til, slik at du kan følge opp og sette dem opp på noe som passer.",
"invited": {
"description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.",
"done": "Er inne",
"heading": "Sendte invitasjoner"
},
"invites": {
"arrangementer": {
"button": "Inviter",
"description": "De får en konto, plass i Arrangementer, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.",
"form_title": "Hvem",
"heading": "Inviter noen inn i Arrangementer",
"labels": {
"email": "E-post",
"name": "Navn"
}
}
},
"links": {},
"question": "Hvem har meldt seg, og hvor langt har jeg kommet med å sette dem opp?"
},
"questions/bidra/index.yaml": {
"description": "Vil du stille opp for nabolaget? Her melder du deg til dugnad og arrangementer, eller til en av komiteene.",
"needs": {
"hjelpe-til": {
"button": "Meld meg på",
"description": "17. mai, dugnad eller andre arrangementer trenger folk som stiller opp. Si fra hva du kan bidra med, så setter vi deg opp på noe som passer.",
"encouragement": "Det holder med noen ord om hva du kan tenke deg – vi tar kontakt når det nærmer seg.",
"form_description": "Fortell kort hva du kan hjelpe til med.",
"form_title": "Meld deg som hjelper",
"heading": "Bli med på dugnad eller arrangement",
"hints": {
"hva": "",
"navn": "Kari Nordmann",
"telefon": "912 34 567"
},
"icon": "hand-helping",
"labels": {
"hva": "Hva kan du hjelpe til med?",
"navn": "Navn",
"telefon": "Telefon"
},
"road_description": "Arrangementskomiteen tar kontakt og finner ut hva som passer for deg. Sier du ja, blir du satt opp på en oppgave – sier du nei takk, er det helt greit.",
"road_icon": "lucide:clipboard-list",
"road_title": "Hva skjer videre",
"who_description": "Arrangementskomiteen leser det du sender, og du kan nå dem på post@tomtervel.no i mellomtiden.",
"who_icon": "lucide:users",
"who_title": "Hvem leser dette"
},
"komite": {
"button": "Meld interesse",
"description": "Velet drives av sju komiteer på fritida, og alle trenger flere hender. Si hvilken komité du har lyst til å være med i.",
"encouragement": "Det tar bare et minutt, og noen fra styret svarer deg.",
"form_description": "Skriv hvilken komité du er interessert i.",
"form_title": "Meld deg til en komité",
"heading": "Sitt i en komité som betyr noe for deg",
"hints": {
"epost": "kari@eksempel.no",
"komite": "",
"navn": "Kari Nordmann"
},
"icon": "users",
"labels": {
"epost": "E-post",
"komite": "Hvilken komité?",
"navn": "Navn"
},
"road_description": "Styret ser på ønsket ditt og finner en plass i komiteen du har meldt deg til, eller sier fra hvis det ikke er rom akkurat nå.",
"road_icon": "lucide:clipboard-list",
"road_title": "Hva skjer videre",
"who_description": "Styret leser det du sender, og du kan nå dem på post@tomtervel.no i mellomtiden.",
"who_icon": "lucide:handshake",
"who_title": "Hvem leser dette"
}
},
"question": "Vil du hjelpe til i nabolaget?"
},
"questions/decide/dele-utstyr.yaml": {
"description": "Her sier du fra at utstyret er hentet eller ikke lenger tilgjengelig for utlån.",
"moves": {
"tatt_ned": {
"button": "Ta ned oppslaget",
"description": "Oppslaget fjernes fra siden, så ingen flere spør om å låne det.",
"heading": "Meld at utstyret er tatt ned"
}
},
"question": "Skal oppslaget om utstyret fortsatt stå ute?"
},
"questions/decide/kan-hjelpe.yaml": {
"description": "Her ser du naboer som har bedt om en hånd, og kan velge om du fortsatt kan bidra eller trekke deg for nå.",
"moves": {
"trukket": {
"button": "Trekk meg",
"description": "Du blir tatt av lista over naboer som kan spørres, men kan melde deg på igjen når det passer bedre.",
"heading": "Ta en pause fra nabohjelpen"
}
},
"question": "Vil du fortsatt kunne bli spurt om å hjelpe en nabo?"
},
"questions/decide/trenger-hjelp.yaml": {
"description": "Her ser du henvendelsen fra naboen som trengte en hånd, og kan si fra når det er ordnet.",
"moves": {
"ordnet": {
"button": "Merk som ordnet",
"description": "Da vet vi at naboen har fått hjelpen sin, og saken lukkes.",
"heading": "Meld fra at hjelpen er gitt"
}
},
"question": "Har naboen fått hjelpen de trengte?"
},
"questions/horingsinstans/index.yaml": {
"buckets": {
"innspill": {
"description": "Her ligger innspill fra beboere til saker kommunen har på høring.",
"heading": "Innspill til høringssvar",
"moves": {
"open->ikke_tatt_med": "Ikke tatt med",
"open->tatt_med": "Tatt med i svaret"
}
}
},
"confirm": "Send svar",
"description": "Her samler du innspillene og ser hva som er tatt med i velets høringssvar.",
"invited": {
"description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.",
"done": "Er inne",
"heading": "Sendte invitasjoner"
},
"invites": {
"horingsinstans": {
"button": "Inviter",
"description": "De får en konto, plass i Høringsinstans, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.",
"form_title": "Hvem",
"heading": "Inviter noen inn i Høringsinstans",
"labels": {
"email": "E-post",
"name": "Navn"
}
}
},
"links": {},
"question": "Hva sier vi i høringssvaret?"
},
"questions/index.yaml": {
"description": "Tomter Vel tar vare på nærmiljøet – fra elvestien og lekeplassene til trafikksikkerhet og naboskap. Finn det som gjelder deg.",
"doors": {
"bidra": {
"button": "Bli med",
"description": "Vi trenger folk på 17. mai, dugnader og i komiteene. Det trengs ikke mer enn litt tid du har å avse.",
"for_whom": "Deg som vil gjøre en innsats",
"heading": "Bli med og gjør en innsats",
"icon": "hand-helping"
},
"medlem": {
"button": "Til medlemskap",
"description": "Meld deg inn, betal kontingenten, eller gi beskjed om flytting, ny e-post eller utmelding.",
"for_whom": "Deg som bor i Tomter",
"heading": "Bli medlem eller endre opplysningene dine",
"icon": "house"
},
"melde-fra": {
"button": "Meld fra",
"description": "Sett du noe farlig i trafikken, eller noe ødelagt på lekeplassen eller elvestien? Gi oss beskjed.",
"for_whom": "Deg som har sett noe som bør fikses",
"heading": "Meld fra om noe som bør fikses",
"icon": "alert-triangle"
},
"naboer": {
"button": "Til nabohjelp",
"description": "Trenger du en hånd med snømåking, handling eller skyss? Eller kan du stille opp for en nabo? Her møtes dere.",
"for_whom": "Deg som trenger hjelp eller kan gi en hånd",
"heading": "Få hjelp av naboer, eller gi en hånd selv",
"icon": "heart-handshake"
}
},
"needs": {
"horingsinnspill": {
"button": "Send innspill",
"description": "Har kommunen en sak på høring du mener noe om? Send oss innspillet ditt, så tar vi det med i velets svar.",
"encouragement": "Innspillet ditt blir lest av høringsutvalget vårt og veier inn i svaret vi sender kommunen.",
"form_description": "Fortell hvilken sak det gjelder og hva du mener.",
"form_title": "Send et høringsinnspill",
"heading": "Få din mening med i høringssvaret",
"hints": {
"innspill": "Jeg mener det bør settes opp fartsdumper langs den nye traseen, for barna krysser der til skolen.",
"navn": "Kari Nordmann",
"sak": "Reguleringsplan for Tomterveien"
},
"icon": "megaphone",
"labels": {
"innspill": "Hva mener du?",
"navn": "Navn",
"sak": "Hvilken sak gjelder det?"
},
"road_description": "Innspillet ditt kommer inn hos høringsutvalget. De vurderer om det tas med i velets svar til kommunen, eller om det ikke passer inn denne gangen.",
"road_icon": "lucide:route",
"road_title": "Hva skjer videre",
"who_description": "Høringsutvalget i Tomter Vel leser innspillet ditt; i mellomtiden kan du nå styret på post@tomtervel.no.",
"who_icon": "lucide:users",
"who_title": "Hvem leser dette"
}
},
"question": "Hva gjelder det for deg?"
},
"questions/kasserer/index.yaml": {
"buckets": {
"husstander": {
"description": "Husstander som har meldt seg inn og venter på å bli registrert som betalende medlem.",
"heading": "Nye medlemmer som venter på registrering",
"moves": {
"open->betalt": "Registrer betaling",
"open->utmeldt": "Meld ut"
}
},
"medlemsendringer": {
"description": "Medlemmer som har flyttet, byttet e-post eller vil melde seg ut, og venter på at opplysningene blir oppdatert.",
"heading": "Endringer som venter på å bli registrert",
"moves": {
"open->oppdatert": "Registrer endring"
}
},
"utlegg": {
"description": "Utlegg fra komitémedlemmer som venter på godkjenning eller utbetaling.",
"heading": "Utlegg som venter på deg",
"moves": {
"godkjent->avvist": "Avvis likevel",
"godkjent->utbetalt": "Merk som utbetalt",
"open->avvist": "Avvis utlegg",
"open->godkjent": "Godkjenn utlegg"
}
}
},
"confirm": "Lagre",
"description": "Her har du oversikt over medlemmer, endringer og utlegg som venter på deg som kasserer.",
"invited": {
"description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.",
"done": "Er inne",
"heading": "Sendte invitasjoner"
},
"invites": {
"kasserer": {
"button": "Inviter",
"description": "De får en konto, plass i kassereren, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.",
"form_title": "Hvem",
"heading": "Inviter noen inn i kassereren",
"labels": {
"email": "E-post",
"name": "Navn"
}
}
},
"links": {},
"question": "Hva venter på meg som kasserer akkurat nå?"
},
"questions/komiteer/index.yaml": {
"buckets": {},
"confirm": "Confirm",
"description": "Her holder du oversikt over sakene komiteen din har ansvar for, og flytter dem videre etter hvert som de blir fulgt opp.",
"invited": {
"description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.",
"done": "Er inne",
"heading": "Sendte invitasjoner"
},
"invites": {
"komiteer": {
"button": "Inviter",
"description": "De får en konto, plass i komiteene, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.",
"form_title": "Hvem",
"heading": "Inviter noen inn i komiteene",
"labels": {
"email": "E-post",
"name": "Navn"
}
}
},
"links": {
"utlegg": {
"button": "Send utlegg",
"description": "Har du lagt ut for noe til komiteen? Send det inn så kasserer kan betale deg tilbake.",
"heading": "Få tilbake det du har lagt ut"
}
},
"question": "Hva står ubehandlet i komiteen din nå?"
},
"questions/komiteer/utlegg-sent.yaml": {
"back": "Til forsiden",
"description": "Utlegget er sendt til kasserer. Du får beskjed når det er sjekket.",
"next_description": "Kasserer ser gjennom kvitteringen og overfører pengene til kontoen du oppga.",
"next_heading": "Pengene kommer på konto",
"question": "Hva skjer med utlegget mitt nå?"
},
"questions/komiteer/utlegg.yaml": {
"button": "Send inn",
"description": "Har du lagt ut for noe til komiteen? Send det inn, så ordner kasserer resten.",
"encouragement": "Legg ved bilag om du har det, så går det raskere.",
"form_description": "Ha kvittering eller bilag klart før du fyller ut.",
"form_title": "Krav om refusjon",
"heading": "Få pengene tilbake",
"hints": {
"belop": "450 kr",
"hva": "Kaffe og kjeks til dugnaden",
"navn": "Kari Nordmann"
},
"icon": "receipt",
"labels": {
"belop": "Beløp",
"hva": "Hva gjaldt det",
"navn": "Navn"
},
"page_description": "Har du betalt for noe på vegne av en komité? Send inn utlegget, så får du pengene tilbake.",
"question": "Har du lagt ut penger for komiteen og vil ha dem tilbake?",
"road_description": "Kasserer ser gjennom kravet og godkjenner det, og deretter blir pengene utbetalt til deg. Blir det avvist, hører du hvorfor.",
"road_icon": "lucide:wallet",
"road_title": "Fra krav til utbetaling",
"who_description": "Kasserer i styret leser kravet ditt, og kan nås på post@tomtervel.no om det haster.",
"who_icon": "lucide:coins",
"who_title": "Kasserer ser dette"
},
"questions/lekeplasser/index.yaml": {
"buckets": {
"anleggsfeil": {
"description": "Meldinger om noe som er ødelagt på lekeplassen eller langs stien, og som bør fikses.",
"heading": "Det som er meldt inn av skader",
"moves": {
"open->tatt_tak_i": "Ta tak i saken",
"tatt_tak_i->utbedret": "Merk som utbedret"
}
}
},
"confirm": "Lagre",
"description": "Her følger komiteen for lekeplassene opp det som er meldt inn av skader, til det er fikset.",
"invited": {
"description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.",
"done": "Er inne",
"heading": "Sendte invitasjoner"
},
"invites": {
"lekeplasser": {
"button": "Inviter",
"description": "De får en konto, plass i Leke- og møteplasser, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.",
"form_title": "Hvem",
"heading": "Inviter noen inn i Leke- og møteplasser",
"labels": {
"email": "E-post",
"name": "Navn"
}
}
},
"links": {},
"question": "Hva må fikses på lekeplassene og stien nå?"
},
"questions/medlem/index.yaml": {
"description": "Bli medlem av Tomter Vel, eller oppdater opplysningene dine hvis du allerede er medlem.",
"needs": {
"bli-medlem": {
"button": "Bli medlem",
"description": "Meld deg inn i velet, så holder vi deg oppdatert og du støtter arbeidet i nærmiljøet.",
"encouragement": "Kasserer sender deg kontingenten på e-post rett etterpå.",
"form_description": "Ha adressen i Tomter og en e-post du bruker klar.",
"form_title": "Meld deg inn",
"heading": "Bli medlem av Tomter Vel",
"hints": {
"adresse": "Tomterveien 12",
"epost": "ola.nordmann@epost.no",
"navn": "Ola Nordmann",
"telefon": "123 45 678"
},
"icon": "user-plus",
"labels": {
"adresse": "Adresse",
"epost": "E-post",
"navn": "Navn",
"telefon": "Telefon"
},
"road_description": "Meldingen går til kasserer, som registrerer deg og sender deg kontingenten på e-post. Når den er betalt er du medlem.",
"road_icon": "lucide:coins",
"road_title": "Etter innmelding",
"who_description": "Kasserer i styret leser dette, og du kan nå oss på post@tomtervel.no i mellomtiden.",
"who_icon": "lucide:user",
"who_title": "Hvem svarer deg"
},
"endre-medlemskap": {
"button": "Send endring",
"description": "Har du flyttet, byttet e-post eller vil melde deg ut? Gi oss beskjed så vi har riktige opplysninger.",
"encouragement": "Det tar bare et par minutter, og kasserer ordner resten.",
"form_description": "Skriv navnet ditt og hva som er nytt.",
"form_title": "Endre medlemskap",
"heading": "Oppdater eller avslutt medlemskapet",
"hints": {
"adresse": "Ny adresse i Tomter",
"hva_er_nytt": "Ny e-post, ny adresse, eller ønske om utmelding",
"navn": "Ola Nordmann"
},
"icon": "pencil",
"labels": {
"adresse": "Adresse",
"hva_er_nytt": "Hva vil du endre?",
"navn": "Navn"
},
"road_description": "Kasserer får beskjeden og oppdaterer medlemslisten, eller melder deg ut om det er det du ber om.",
"road_icon": "lucide:refresh-cw",
"road_title": "Etter du sender inn",
"who_description": "Kasserer i styret leser dette, og du kan nå oss på post@tomtervel.no i mellomtiden.",
"who_icon": "lucide:user",
"who_title": "Hvem svarer deg"
}
},
"question": "Vil du bli medlem, eller endre medlemskapet ditt?"
},
"questions/melde-fra/index.yaml": {
"description": "Har du sett noe farlig i trafikken, eller noe ødelagt på lekeplassen eller stien? Her sier du fra til velet.",
"needs": {
"anleggsfeil": {
"button": "Send inn",
"description": "Se noe ødelagt på en lekeplass eller langs elvestien, så tar lekeplasskomiteen tak i det.",
"encouragement": "Det holder med noen ord om hva du så og hvor.",
"form_description": "Fortell hva som er ødelagt og omtrent hvor det er.",
"form_title": "Meld anleggsfeil",
"heading": "Meld fra om noe ødelagt",
"hints": {
"hva": "Løs planke på gjerdet ved husken",
"hvor": ""
},
"icon": "construction",
"labels": {
"hva": "Hva er ødelagt",
"hvor": "Hvor er det"
},
"road_description": "Meldingen går til lekeplasskomiteen, som ser på den og tar tak i saken. Når feilen er utbedret, er saken avsluttet.",
"road_icon": "lucide:wrench",
"road_title": "Hva skjer videre",
"who_description": "Lekeplasskomiteen leser meldingen din; til det haster kan du nå styret på post@tomtervel.no.",
"who_icon": "lucide:hammer",
"who_title": "Hvem leser dette"
},
"trafikkfare": {
"button": "Send inn",
"description": "Sett du noe farlig i trafikken? Fortell trafikkomiteen om det, så tar de det videre.",
"encouragement": "Bare noen linjer om hva og hvor er nok til at vi kan følge opp.",
"form_description": "Fortell hva du så, hvor det var, og legg gjerne igjen e-posten din hvis vi trenger å spørre mer.",
"form_title": "Meld trafikkfare",
"heading": "Meld fra om en trafikkfare",
"hints": {
"epost": "ola.nordmann@epost.no",
"hva": "Dårlig sikt i krysset, biler kjører fort",
"hvor": "Krysset Tomterveien / Elvestien"
},
"icon": "alert-triangle",
"labels": {
"epost": "E-post (valgfritt)",
"hva": "Hva så du",
"hvor": "Hvor var det"
},
"road_description": "Trafikkomiteen tar meldingen din opp med kommunen eller den som eier veien. Du får vite om saken blir ordnet eller avvist.",
"road_icon": "lucide:route",
"road_title": "Hva skjer videre",
"who_description": "Trafikkomiteen leser meldingen din; haster det, kan du nå styret på post@tomtervel.no.",
"who_icon": "lucide:car",
"who_title": "Hvem leser dette"
}
},
"question": "Hva vil du melde fra om?"
},
"questions/naboer/index.yaml": {
"description": "Godt naboskap gjør hverdagen lettere. Trenger du en hånd, kan gi en, eller har noe å låne bort?",
"needs": {
"dele-utstyr": {
"button": "Legg ut oppslag",
"description": "Har du noe naboene kan få låne, eller trenger du å låne noe selv? Legg ut et oppslag så ser de andre på Tomter det.",
"encouragement": "Vi legger det ut så naboene kan se det.",
"form_description": "Skriv kort hva det gjelder, så folk skjønner det med en gang.",
"form_title": "Lån eller utlån",
"heading": "Lån ut eller lån av naboen",
"hints": {
"email": "ola@eksempel.no",
"har_eller_trenger": "",
"hva": "Hengerfeste til bil, tilhenger, stige",
"navn": "Ola Nordmann"
},
"icon": "package",
"labels": {
"email": "E-post",
"har_eller_trenger": "Har du eller trenger du",
"hva": "Hva gjelder det",
"navn": "Navn"
},
"road_description": "Oppslaget legges ut for naboene å se. Når det er ordnet eller ikke lenger aktuelt, tas det ned igjen.",
"road_icon": "lucide:clipboard-list",
"road_title": "Fra oppslag til avtale",
"who_description": "Styret legger ut oppslaget og kan nås på post@tomtervel.no i mellomtiden.",
"who_icon": "lucide:users",
"who_title": "Hvem ser dette"
},
"kan-hjelpe": {
"button": "Meld deg",
"description": "Kan du stille opp med snømåking, en handletur eller skyss når en nabo trenger det? Meld deg, så blir du spurt når det passer.",
"encouragement": "Du blir bare kontaktet når noe passer for deg.",
"form_description": "Fortell kort hva du kan bidra med.",
"form_title": "Meld deg som hjelper",
"heading": "Bli spurt når en nabo trenger en hånd",
"hints": {
"email": "kari@eksempel.no",
"kan": "",
"navn": "Kari Nordmann",
"telefon": "123 45 678"
},
"icon": "hand-helping",
"labels": {
"email": "E-post",
"kan": "Hva kan du hjelpe med",
"navn": "Navn",
"telefon": "Telefon"
},
"road_description": "Du blir registrert som hjelper. Trekker du deg tilbake senere, er det bare å si fra.",
"road_icon": "lucide:user-check",
"road_title": "Fra påmelding til spørsmål",
"who_description": "Nabohjelpen har oversikten og ringer deg direkte når det er noe, eller nås på post@tomtervel.no.",
"who_icon": "lucide:heart-handshake",
"who_title": "Hvem ser dette"
},
"trenger-hjelp": {
"button": "Be om hjelp",
"description": "Får du ikke til snømåking, en handletur eller skyss for tiden? Si fra, så finner vi en nabo som kan stille opp.",
"encouragement": "Vi finner en nabo som kan stille opp så fort som mulig.",
"form_description": "Skriv kort hva du trenger hjelp til.",
"form_title": "Be om en hånd",
"heading": "Få en nabo til å stille opp",
"hints": {
"adresse": "Tomterveien 12",
"email": "kari@eksempel.no",
"hva": "Måke snø på trappa, handle inn litt mat",
"navn": "Kari Nordmann",
"telefon": "123 45 678"
},
"icon": "heart-handshake",
"labels": {
"adresse": "Adresse",
"email": "E-post",
"hva": "Hva trenger du hjelp til",
"navn": "Navn",
"telefon": "Telefon"
},
"road_description": "Nabohjelpen finner en nabo som kan stille opp. Er det ordnet, eller om det ikke lot seg løse, får du beskjed.",
"road_icon": "lucide:hand-helping",
"road_title": "Fra melding til hjelp",
"who_description": "Nabohjelpen tar imot og finner noen til deg, eller ring post@tomtervel.no i mellomtiden.",
"who_icon": "lucide:users",
"who_title": "Hvem ser dette"
}
},
"question": "Trenger du en hånd, eller kan du gi en?"
},
"questions/nabohjelp/index.yaml": {
"buckets": {
"hjelpebehov": {
"description": "Naboer som trenger en hånd med snømåking, handling eller skyss – klare til å bli koblet med noen som kan hjelpe.",
"heading": "Naboer som trenger en hånd",
"moves": {
"formidlet->ikke_lost": "Meld ikke løst",
"formidlet->ordnet": "Meld ordnet",
"open->formidlet": "Koble til nabo",
"open->ikke_lost": "Meld ikke løst"
}
},
"hjelpere": {
"description": "Naboer som har sagt de kan stille opp – klare til å bli spurt når noen trenger dem.",
"heading": "Naboer som kan stille opp",
"moves": {
"open->registrert": "Registrer som hjelper",
"open->trukket": "Meld trukket"
}
}
},
"confirm": "Lagre",
"description": "Her kobler du naboer som trenger hjelp med dem som kan stille opp.",
"invited": {
"description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.",
"done": "Er inne",
"heading": "Sendte invitasjoner"
},
"invites": {
"nabohjelp": {
"button": "Inviter",
"description": "De får en konto, plass i nabohjelpen, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.",
"form_title": "Hvem",
"heading": "Inviter noen inn i nabohjelpen",
"labels": {
"email": "E-post",
"name": "Navn"
}
}
},
"links": {},
"question": "Hvem trenger hjelp, og hvem kan stille opp?"
},
"questions/received/anleggsfeil.yaml": {
"back": "Tilbake til forsiden",
"description": "Meldingen din om skaden er sendt til lekeplasskomiteen. De ser over det du har sendt.",
"next_description": "Lekeplasskomiteen ser på meldingen og tar tak i det som må fikses før noen skader seg.",
"next_heading": "Komiteen tar tak i det",
"question": "Hva skjer med meldingen min nå?",
"see_all": {
"button": "Se meldte feil",
"description": "Se hva andre har meldt inn av skader på lekeplassene og stien, og hvor langt det har kommet.",
"heading": "Se andre meldte feil"
}
},
"questions/received/bli-medlem.yaml": {
"back": "Tilbake til forsiden",
"description": "Vi har mottatt innmeldingen din. Kasserer tar kontakt med betalingsinfo så snart medlemskapet er registrert.",
"next_description": "Kasserer går gjennom nye innmeldinger og sender deg kontonummer og beløp for kontingenten.",
"next_heading": "Kasserer sender deg betalingsinfo",
"question": "Hva skjer etter at jeg har meldt meg inn?"
},
"questions/received/dele-utstyr.yaml": {
"back": "Tilbake til forsiden",
"description": "Takk for at du la ut noe naboene kan låne. Vi legger det ut på oppslaget så snart vi kan.",
"next_description": "En i styret legger utstyret ut på naboenes oppslag, så andre kan se det og spørre om å låne.",
"next_heading": "Styret legger det ut for deg",
"question": "Hva skjer med det jeg har lagt ut?",
"see_all": {
"button": "Se hva naboer deler",
"description": "Se hva andre i Tomter har lagt ut til utlån akkurat nå.",
"heading": "Se alt naboene deler"
}
},
"questions/received/endre-medlemskap.yaml": {
"back": "Tilbake til forsiden",
"description": "Vi har mottatt beskjeden om at du har flyttet, byttet e-post eller vil melde deg ut.",
"next_description": "Kasserer oppdaterer medlemslista og gir deg beskjed hvis det er noe mer vi trenger fra deg.",
"next_heading": "Kasserer retter opp opplysningene",
"question": "Hva skjer nå med det jeg meldte inn?"
},
"questions/received/hjelpe-til.yaml": {
"back": "Tilbake til forsiden",
"description": "Takk for at du vil hjelpe til. Arrangementskomiteen har fått beskjed og tar kontakt med deg.",
"next_description": "Du hører fra noen i komiteen når de vet hva de trenger hjelp til, gjerne noen dager før arrangementet eller dugnaden.",
"next_heading": "Komiteen tar kontakt",
"question": "Hva skjer nå som jeg har meldt meg?"
},
"questions/received/horingsinnspill.yaml": {
"back": "Tilbake til forsiden",
"description": "Innspillet ditt er sendt til høringsinstansen i velet.",
"next_description": "De ser på om det kan tas med i velets høringssvar til kommunen, og gir beskjed om det blir med.",
"next_heading": "Innspillet ditt inn i vurderingen",
"question": "Hva skjer med det jeg har sagt om saken?"
},
"questions/received/kan-hjelpe.yaml": {
"back": "Tilbake til forsiden",
"description": "Takk for at du sier fra at du kan stille opp. Nabohjelp-komiteen tar imot meldingen og legger deg inn på lista over naboer som kan hjelpe.",
"next_description": "Når en nabo trenger hjelp med noe du har sagt du kan bidra med, tar komiteen kontakt med deg direkte.",
"next_heading": "Du blir kontaktet ved behov",
"question": "Hva skjer nå som jeg har registrert meg?"
},
"questions/received/komite.yaml": {
"back": "Tilbake til forsiden",
"description": "Takk for at du vil bidra! Styret har fått beskjed og ser på hvilken komité som passer best for deg.",
"next_description": "Noen fra styret tar kontakt for å høre mer om hva du liker å drive med, og finner en komité som passer.",
"next_heading": "Vi finner riktig komité til deg",
"question": "Hva skjer nå som jeg har meldt interesse for en komité?"
},
"questions/received/trafikkfare.yaml": {
"back": "Tilbake til forsiden",
"description": "Meldingen din om trafikkfaren er sendt til komiteen for trafikk.",
"next_description": "Trafikkomiteen ser gjennom meldinger jevnlig og tar det videre med kommunen eller den som eier veien der det trengs.",
"next_heading": "Trafikkomiteen tar den videre",
"question": "Hva skjer med meldingen min nå?",
"see_all": {
"button": "Se andre trafikkfarer",
"description": "Se hva andre har meldt inn, og status på sakene.",
"heading": "Andre meldte trafikkfarer"
}
},
"questions/received/trenger-hjelp.yaml": {
"back": "Til forsiden",
"description": "Meldingen din er sendt til nabohjelpa. Vi ser etter en nabo som kan stille opp.",
"next_description": "Vi tar kontakt med noen som kan hjelpe, så snart vi finner en som passer.",
"next_heading": "Vi finner en nabo til deg",
"question": "Hva skjer nå som jeg har bedt om hjelp?"
},
"questions/status/anleggsfeil/index.yaml": {
"back": "Tilbake til meldinger",
"description": "Her ser du meldinger om ødelagt lekeutstyr og skader på elvestien, og hvor langt hver sak har kommet.",
"heading": "Status på meldte anleggsfeil",
"list_description": "Alle meldinger er åpne for alle å se.",
"question": "Hva skjer med det jeg har meldt fra om?"
},
"questions/status/oppslag/index.yaml": {
"back": "Tilbake",
"description": "Her ser du alt som er sendt inn og hvor langt hvert oppslag har kommet.",
"heading": "Oversikt over det som er meldt inn",
"list_description": "Åpen liste – navn og detaljer vises ikke.",
"question": "Hva er sendt inn, og hvor langt har det kommet?"
},
"questions/status/trafikkmeldinger/index.yaml": {
"back": "Tilbake",
"description": "Her ser du farlige steder i trafikken som er meldt inn, og hvor langt saken har kommet.",
"heading": "Trafikkmeldinger og status",
"list_description": "Alle meldinger som er sendt inn, og hvor de står nå.",
"question": "Er det meldt fra om noe farlig i trafikken der jeg bor?"
},
"questions/styret/index.yaml": {
"buckets": {
"innspill": {
"description": "Her samles meninger fra beboerne om saker kommunen har på høring, klare til å tas med i velets svar.",
"heading": "Høringsinnspill fra beboerne",
"moves": {}
},
"komitesoknader": {
"description": "Her ligger alle som har meldt seg til en komité og venter på en plass.",
"heading": "Søknader til komiteene",
"moves": {
"open->avslatt": "Avslå søknaden",
"open->plassert": "Sett i komité"
}
},
"oppslag": {
"description": "Her ligger det naboer har å låne ut eller trenger å låne, klart til å legges ut.",
"heading": "Utstyr til utlån blant naboer",
"moves": {
"open->lagt_ut": "Legg ut oppslaget",
"open->tatt_ned": "Ta ned oppslaget"
}
},
"trafikkmeldinger": {
"description": "Her samles meldinger om farlige steder i trafikken, klare til å tas videre med kommunen eller veieier.",
"heading": "Meldinger om trafikkfarer",
"moves": {}
}
},
"confirm": "Lagre",
"description": "Herfra følger styret opp det som ikke hører under en enkelt komité: høringssvar, komitéplasser og oppslag fra naboer.",
"invited": {
"description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.",
"done": "Er inne",
"heading": "Sendte invitasjoner"
},
"invites": {
"arrangementer": {
"button": "Inviter",
"description": "De får en konto, plass i Arrangementer, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.",
"form_title": "Hvem",
"heading": "Inviter noen inn i Arrangementer",
"labels": {
"email": "E-post",
"name": "Navn"
}
},
"horingsinstans": {
"button": "Inviter",
"description": "De får en konto, plass i Høringsinstans, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.",
"form_title": "Hvem",
"heading": "Inviter noen inn i Høringsinstans",
"labels": {
"email": "E-post",
"name": "Navn"
}
},
"kasserer": {
"button": "Inviter",
"description": "De får en konto, plass i kassereren, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.",
"form_title": "Hvem",
"heading": "Inviter noen inn i kassereren",
"labels": {
"email": "E-post",
"name": "Navn"
}
},
"komiteer": {
"button": "Inviter",
"description": "De får en konto, plass i komiteene, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.",
"form_title": "Hvem",
"heading": "Inviter noen inn i komiteene",
"labels": {
"email": "E-post",
"name": "Navn"
}
},
"lekeplasser": {
"button": "Inviter",
"description": "De får en konto, plass i Leke- og møteplasser, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.",
"form_title": "Hvem",
"heading": "Inviter noen inn i Leke- og møteplasser",
"labels": {
"email": "E-post",
"name": "Navn"
}
},
"nabohjelp": {
"button": "Inviter",
"description": "De får en konto, plass i nabohjelpen, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.",
"form_title": "Hvem",
"heading": "Inviter noen inn i nabohjelpen",
"labels": {
"email": "E-post",
"name": "Navn"
}
},
"styret": {
"button": "Inviter",
"description": "De får en konto, plass i styret, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.",
"form_title": "Hvem",
"heading": "Inviter noen inn i styret",
"labels": {
"email": "E-post",
"name": "Navn"
}
},
"trafikk": {
"button": "Inviter",
"description": "De får en konto, plass i Trafikkforhold, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.",
"form_title": "Hvem",
"heading": "Inviter noen inn i Trafikkforhold",
"labels": {
"email": "E-post",
"name": "Navn"
}
}
},
"links": {},
"question": "Hva venter på styret akkurat nå?"
},
"questions/trafikk/index.yaml": {
"buckets": {
"trafikkmeldinger": {
"description": "Farlige steder i trafikken som naboer har meldt inn.",
"heading": "Farlige steder i trafikken",
"moves": {
"open->avvist": "Avvis meldingen",
"open->tatt_opp": "Ta opp med kommunen",
"tatt_opp->avvist": "Avvis meldingen",
"tatt_opp->ordnet": "Meld ordnet"
}
}
},
"confirm": "Lagre",
"description": "Her ser du meldinger om farlige steder i trafikken og følger dem opp overfor kommunen eller veieier.",
"invited": {
"description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.",
"done": "Er inne",
"heading": "Sendte invitasjoner"
},
"invites": {
"trafikk": {
"button": "Inviter",
"description": "De får en konto, plass i Trafikkforhold, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.",
"form_title": "Hvem",
"heading": "Inviter noen inn i Trafikkforhold",
"labels": {
"email": "E-post",
"name": "Navn"
}
}
},
"links": {},
"question": "Hvilke trafikkfarer må følges opp?"
}
}
+237 -4
View File
@@ -49,6 +49,14 @@ pub struct Plan {
/// never disagree about what the committees are. /// never disagree about what the committees are.
#[serde(default)] #[serde(default)]
pub groups: Vec<GroupDef>, pub groups: Vec<GroupDef>,
/// Groups that may invite people into every group with a desk (the
/// board), besides each group inviting into itself. An inviting
/// group's desk gets one invite form per group it may fill (portal
/// creates the account and a one-time credential link) and a list
/// of the invites sent. This is onboarding from the content side:
/// no admin session, a member of the business vouching for a person.
#[serde(default)]
pub invites_by: Vec<String>,
pub needs: Vec<PlanNeed>, pub needs: Vec<PlanNeed>,
#[serde(default)] #[serde(default)]
pub personas: Vec<Value>, pub personas: Vec<Value>,
@@ -63,6 +71,10 @@ fn english() -> String {
pub struct GroupDef { pub struct GroupDef {
pub id: String, pub id: String,
pub label: String, pub label: String,
/// Groups that may invite people into this one, besides itself and
/// the plan's `invites_by`.
#[serde(default)]
pub invited_by: Vec<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -190,6 +202,11 @@ impl Plan {
bail!("group {:?}: id must be unique ASCII lowercase with - or _, and it needs a label", g.id); bail!("group {:?}: id must be unique ASCII lowercase with - or _, and it needs a label", g.id);
} }
} }
for g in self.invites_by.iter().chain(self.groups.iter().flat_map(|g| g.invited_by.iter())) {
if !groups(self).contains(g) {
bail!("{g:?} may invite, but has no desk to invite from: it must handle, watch or move something");
}
}
let mut seen = BTreeSet::new(); let mut seen = BTreeSet::new();
for need in &self.needs { for need in &self.needs {
let at = |what: &str| format!("need {:?}: {what}", need.id); let at = |what: &str| format!("need {:?}: {what}", need.id);
@@ -477,9 +494,29 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
.filter(|n| n.enters_as.as_deref() == Some(group.as_str())) .filter(|n| n.enters_as.as_deref() == Some(group.as_str()))
.map(|n| (n.id.clone(), json!({ "heading": n.wants, "description": "", "button": "Open" }))) .map(|n| (n.id.clone(), json!({ "heading": n.wants, "description": "", "button": "Open" })))
.collect(); .collect();
// One invite form per group this desk may fill, and the list of
// invites sent, when it may fill any.
let invites: Map<String, Value> = groups(plan)
.iter()
.filter(|g| inviters(plan, g).contains(&group))
.map(|g| {
(g.clone(), json!({
"heading": format!("Invite someone into {}", label_of(plan, g)),
"description": format!("They get an account, a place in {}, and a one-time link to set their own password or passkey.", label_of(plan, g)),
"button": "Invite",
"form_title": "Who",
"labels": { "name": "Name", "email": "Email" },
}))
})
.collect();
let invited = if invites.is_empty() {
Value::Null
} else {
json!({ "heading": "Invitations sent", "description": "Each invite with the link it made. Mark it done when the person is in.", "done": "Is in" })
};
words.insert( words.insert(
format!("questions/{group}/index.yaml"), format!("questions/{group}/index.yaml"),
json!({ "question": "What needs doing?", "description": "", "confirm": "Confirm", "buckets": buckets, "links": links }), json!({ "question": "What needs doing?", "description": "", "confirm": "Confirm", "buckets": buckets, "links": links, "invites": invites, "invited": invited }),
); );
} }
// A public view per bucket the plan opens: a read-only list. // A public view per bucket the plan opens: a read-only list.
@@ -567,6 +604,22 @@ fn groups(plan: &Plan) -> BTreeSet<String> {
.collect() .collect()
} }
/// The groups whose desks carry an invite form into `group`: itself,
/// the plan's `invites_by`, and its own `invited_by`, each of which
/// must have a desk to carry it on.
fn inviters(plan: &Plan, group: &str) -> BTreeSet<String> {
let desks = groups(plan);
std::iter::once(group.to_string())
.chain(plan.invites_by.iter().cloned())
.chain(plan.groups.iter().filter(|g| g.id == group).flat_map(|g| g.invited_by.iter().cloned()))
.filter(|g| desks.contains(g))
.collect()
}
fn label_of(plan: &Plan, group: &str) -> String {
plan.groups.iter().find(|g| g.id == group).map(|g| g.label.clone()).unwrap_or_else(|| humanize(group))
}
/// Buckets the public may read, with the needs that fill them. /// Buckets the public may read, with the needs that fill them.
fn public_buckets(plan: &Plan) -> Vec<(&str, Vec<&PlanNeed>)> { fn public_buckets(plan: &Plan) -> Vec<(&str, Vec<&PlanNeed>)> {
plan.by_bucket().into_iter().filter(|(_, needs)| needs.iter().any(|n| n.watched_by.iter().any(|g| g == PUBLIC))).collect() plan.by_bucket().into_iter().filter(|(_, needs)| needs.iter().any(|n| n.watched_by.iter().any(|g| g == PUBLIC))).collect()
@@ -655,6 +708,11 @@ pub const ICONS: &[&str] = &[
/// `lucide:<name>` for a name on the list, otherwise nothing. /// `lucide:<name>` for a name on the list, otherwise nothing.
pub fn icon_name(given: &str) -> Option<String> { pub fn icon_name(given: &str) -> Option<String> {
// A lift token (see `lift_words`) has to reach the rendered file to
// be matched against the repo's own icon.
if given.starts_with('\u{1}') {
return Some(given.to_string());
}
let name = given.trim().trim_start_matches("lucide:"); let name = given.trim().trim_start_matches("lucide:");
ICONS.contains(&name).then(|| format!("lucide:{name}")) ICONS.contains(&name).then(|| format!("lucide:{name}"))
} }
@@ -861,6 +919,40 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
"consequence": [s(&lw["button"])], "consequence": [s(&lw["button"])],
})); }));
} }
// Invites this desk may extend: `invite: {group}` is portal's
// onboarding (an account, the group, a one-time credential
// link), gated by this page's `qualifies`; what it made is
// listed from the runtime's portal_invites bucket.
if let Some(inv) = w["invites"].as_object().filter(|m| !m.is_empty()) {
let mut targets: Vec<&String> = inv.keys().collect();
targets.sort();
for target in targets {
let iw = &inv[target];
alternatives.push(json!({
"name": s(&iw["heading"]),
"description": s(&iw["description"]),
"action": format!("/{group}"),
"consequence": [s(&iw["button"])],
"invite": { "group": target },
"features": [{ "name": s(&iw["form_title"]), "description": "", "requirements": [
{ "name": "name", "label": s(&iw["labels"]["name"]) },
{ "name": "email", "label": s(&iw["labels"]["email"]), "type": "email" },
]}],
}));
}
let lw = &w["invited"];
alternatives.push(json!({
"name": s(&lw["heading"]),
"description": s(&lw["description"]),
"action": format!("/{group}"),
"consequence": [s(&w["confirm"])],
"features": [{ "name": "", "description": "", "resource": {
"source": { "kind": "kv", "bucket": "portal_invites" },
"requires_group": group,
"transitions": [{ "from": "open", "to": "done", "label": s(&lw["done"]) }],
}}],
}));
}
files.insert(format!("questions/{group}/_section.yaml"), json!({ "qualifies": group })); files.insert(format!("questions/{group}/_section.yaml"), json!({ "qualifies": group }));
files.insert( files.insert(
format!("questions/{group}/index.yaml"), format!("questions/{group}/index.yaml"),
@@ -975,7 +1067,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
} else if plan.doors.iter().any(|d| door_path(d) == path) { } else if plan.doors.iter().any(|d| door_path(d) == path) {
"a page one kind of visitor reaches from the front door. `question` is what this page asks them; under `needs`, each entry is one alternative they can pick, with the form behind it." "a page one kind of visitor reaches from the front door. `question` is what this page asks them; under `needs`, each entry is one alternative they can pick, with the form behind it."
} else if path.ends_with("/index.yaml") { } else if path.ends_with("/index.yaml") {
"a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open." "a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open. Under `invites`, each entry is a form that invites a person into that group - they get an account and a one-time link - and `invited` is the list of invites sent."
} else if path.contains("/received/") || path.ends_with("-sent.yaml") { } else if path.contains("/received/") || path.ends_with("-sent.yaml") {
"a page shown right after someone has sent a form. Say who reads it, when they answer, and what happens next." "a page shown right after someone has sent a form. Say who reads it, when they answer, and what happens next."
} else { } else {
@@ -1006,14 +1098,33 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
pub fn run(argv: &[String]) -> anyhow::Result<()> { pub fn run(argv: &[String]) -> anyhow::Result<()> {
let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned(); let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned();
let usage = "usage: portal-trainer build --plan <plan.yaml> --out <dir> [--llm 'cmd'] [--lint question_lint]"; let usage = "usage: portal-trainer build --plan <plan.yaml> --out <dir> [--words-from <existing repo>] [--words <overrides.json>] [--llm 'cmd'] [--lint question_lint]";
let plan_path = value("--plan").ok_or_else(|| anyhow!(usage))?; let plan_path = value("--plan").ok_or_else(|| anyhow!(usage))?;
let out = std::path::PathBuf::from(value("--out").ok_or_else(|| anyhow!(usage))?); let out = std::path::PathBuf::from(value("--out").ok_or_else(|| anyhow!(usage))?);
let plan: Plan = serde_yaml::from_str(&std::fs::read_to_string(&plan_path).with_context(|| format!("reading {plan_path}"))?) let plan: Plan = serde_yaml::from_str(&std::fs::read_to_string(&plan_path).with_context(|| format!("reading {plan_path}"))?)
.with_context(|| format!("parsing {plan_path}"))?; .with_context(|| format!("parsing {plan_path}"))?;
plan.validate()?; plan.validate()?;
let mut words = fallback_words(&plan); // Words, in layers: the fallbacks; what an existing repo already
// says (hand edits survive a rebuild); explicit overrides for new
// slots; and only then a model, for whatever is still fallback.
let mut words = match value("--words-from") {
Some(repo) => lift_words(&plan, Path::new(&repo))?,
None => fallback_words(&plan),
};
if let Some(path) = value("--words") {
let given: BTreeMap<String, Value> = serde_json::from_str(&std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?)
.with_context(|| format!("parsing {path}"))?;
for (page, v) in given {
match words.get(&page) {
Some(base) => {
let merged = merge_words(base, &v);
words.insert(page, merged);
}
None => eprintln!("words: {page} is not a page of this plan, ignored"),
}
}
}
if let Some(cmd) = value("--llm") { if let Some(cmd) = value("--llm") {
std::fs::create_dir_all(out.join("llm"))?; std::fs::create_dir_all(out.join("llm"))?;
for (n, (path, fallback)) in words.clone().iter().enumerate() { for (n, (path, fallback)) in words.clone().iter().enumerate() {
@@ -1030,6 +1141,8 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
} }
} }
write_repo(&out, render(&plan, &words)?)?; write_repo(&out, render(&plan, &words)?)?;
// The words as used, so the next rebuild can start from them.
std::fs::write(out.join("words.json"), serde_json::to_string_pretty(&words)?)?;
println!("content repo written to {}", out.display()); println!("content repo written to {}", out.display());
if let Some(lint) = value("--lint") { if let Some(lint) = value("--lint") {
@@ -1297,3 +1410,123 @@ mod tests {
assert!(plan.validate().unwrap_err().to_string().contains("must agree")); assert!(plan.validate().unwrap_err().to_string().contains("must agree"));
} }
} }
/// The words a repo already carries, lifted back into the words map by
/// structure: the plan is rendered with a token in every word slot,
/// each rendered file is walked alongside the repo's file of the same
/// path, and wherever a token meets a string, that string is the word.
/// Pages the repo does not have, and slots it never had (a new invite
/// form), keep their fallback. Hand-edited wording survives a rebuild.
pub fn lift_words(plan: &Plan, repo: &Path) -> anyhow::Result<BTreeMap<String, Value>> {
let fallback = fallback_words(plan);
let tokened: BTreeMap<String, Value> =
fallback.iter().map(|(path, v)| (path.clone(), tokenize(v, path, ""))).collect();
let rendered = render(plan, &tokened)?;
let mut found: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for (path, text) in &rendered {
let Ok(live) = std::fs::read_to_string(repo.join(path)) else { continue };
let (Ok(a), Ok(b)) = (
serde_yaml::from_str::<serde_yaml::Value>(text),
serde_yaml::from_str::<serde_yaml::Value>(&live),
) else {
continue;
};
collect_words(&a, &b, &mut found);
}
Ok(fallback.iter().map(|(path, v)| (path.clone(), untokenize(v, path, "", &found))).collect())
}
fn token(path: &str, pointer: &str) -> String {
format!("\u{1}{path}\u{2}{pointer}\u{3}")
}
fn tokenize(v: &Value, path: &str, pointer: &str) -> Value {
match v {
Value::Object(m) => Value::Object(m.iter().map(|(k, x)| (k.clone(), tokenize(x, path, &format!("{pointer}/{k}")))).collect()),
Value::String(_) => Value::String(token(path, pointer)),
other => other.clone(),
}
}
fn untokenize(v: &Value, path: &str, pointer: &str, found: &std::collections::HashMap<String, String>) -> Value {
match v {
Value::Object(m) => Value::Object(m.iter().map(|(k, x)| (k.clone(), untokenize(x, path, &format!("{pointer}/{k}"), found))).collect()),
Value::String(_) => match found.get(&token(path, pointer)) {
Some(word) => Value::String(word.clone()),
None => v.clone(),
},
other => other.clone(),
}
}
fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std::collections::HashMap<String, String>) {
match (a, b) {
(serde_yaml::Value::Mapping(x), serde_yaml::Value::Mapping(y)) => {
for (k, va) in x {
if let Some(vb) = y.get(k) {
collect_words(va, vb, found);
}
}
}
(serde_yaml::Value::Sequence(x), serde_yaml::Value::Sequence(y)) => {
for (va, vb) in x.iter().zip(y) {
collect_words(va, vb, found);
}
}
(serde_yaml::Value::String(t), serde_yaml::Value::String(live)) if t.starts_with('\u{1}') => {
// An icon slot holds the bare name; the file carries `lucide:`.
let word = if t.ends_with("/icon\u{3}") { live.trim_start_matches("lucide:") } else { live.as_str() };
found.insert(t.clone(), word.to_string());
}
_ => {}
}
}
#[cfg(test)]
mod invite_tests {
use super::*;
const PLAN: &str = "business: Vel\ncontact: { name: S, email: s@v.example }\ngroups:\n - { id: trafikk, label: Trafikk }\ninvites_by: [styret]\nneeds:\n - id: melde\n who: a neighbour\n wants: a hazard seen to\n lands_in: farer\n handled_by: trafikk\n stages: [open, ordnet]\n done_when: [ordnet]\n max_fields: 3\n collects:\n - { name: hvor }\n - id: utlegg\n who: a committee member\n wants: money back\n enters_as: trafikk\n lands_in: utlegg\n handled_by: styret\n stages: [open, betalt, avvist]\n done_when: [betalt, avvist]\n max_fields: 3\n collects:\n - { name: belop }\n";
#[test]
fn inviting_desks_get_a_form_per_group_and_a_list_of_invites_sent() {
let plan: Plan = serde_yaml::from_str(PLAN).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
// The board invites into both groups; trafikk only into itself.
let styret = &files["questions/styret/index.yaml"];
assert_eq!(styret.matches("invite:").count(), 2, "{styret}");
assert!(styret.contains("bucket: portal_invites"));
let trafikk = &files["questions/trafikk/index.yaml"];
assert_eq!(trafikk.matches("invite:").count(), 1);
assert!(trafikk.contains("group: trafikk") && !trafikk.contains("group: styret"));
// The plan is refused when an inviter has no desk.
let bad: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [styret]", "invites_by: [nobody]")).unwrap();
assert!(bad.validate().unwrap_err().to_string().contains("no desk"));
}
#[test]
fn words_lift_back_out_of_a_rendered_repo_and_new_slots_keep_their_fallback() {
let plan: Plan = serde_yaml::from_str(PLAN).unwrap();
let mut words = fallback_words(&plan);
// A hand-edited desk: Norwegian words in the live repo.
let desk = words.get_mut("questions/trafikk/index.yaml").unwrap();
desk["question"] = json!("Hva må gjøres?");
desk["buckets"]["farer"]["heading"] = json!("Meldte farer");
desk["buckets"]["farer"]["moves"]["open->ordnet"] = json!("Ordnet");
let dir = std::env::temp_dir().join(format!("lift-{}", std::process::id()));
write_repo(&dir, render(&plan, &words).unwrap()).unwrap();
let lifted = lift_words(&plan, &dir).unwrap();
let desk = &lifted["questions/trafikk/index.yaml"];
assert_eq!(desk["question"], "Hva må gjøres?");
assert_eq!(desk["buckets"]["farer"]["heading"], "Meldte farer");
assert_eq!(desk["buckets"]["farer"]["moves"]["open->ordnet"], "Ordnet");
// A slot the repo never had keeps the fallback.
let plan2: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [styret]", "invites_by: [styret, trafikk]")).unwrap();
let lifted2 = lift_words(&plan2, &dir).unwrap();
assert_eq!(lifted2["questions/trafikk/index.yaml"]["question"], "Hva må gjøres?");
assert!(lifted2["questions/trafikk/index.yaml"]["invites"]["styret"]["heading"].as_str().unwrap().starts_with("Invite someone"));
let _ = std::fs::remove_dir_all(&dir);
}
}