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
+237 -4
View File
@@ -49,6 +49,14 @@ pub struct Plan {
/// never disagree about what the committees are.
#[serde(default)]
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>,
#[serde(default)]
pub personas: Vec<Value>,
@@ -63,6 +71,10 @@ fn english() -> String {
pub struct GroupDef {
pub id: 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)]
@@ -190,6 +202,11 @@ impl Plan {
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();
for need in &self.needs {
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()))
.map(|n| (n.id.clone(), json!({ "heading": n.wants, "description": "", "button": "Open" })))
.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(
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.
@@ -567,6 +604,22 @@ fn groups(plan: &Plan) -> BTreeSet<String> {
.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.
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()
@@ -655,6 +708,11 @@ pub const ICONS: &[&str] = &[
/// `lucide:<name>` for a name on the list, otherwise nothing.
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:");
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"])],
}));
}
// 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}/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) {
"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") {
"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") {
"a page shown right after someone has sent a form. Say who reads it, when they answer, and what happens next."
} else {
@@ -1006,14 +1098,33 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
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 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 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}"))?)
.with_context(|| format!("parsing {plan_path}"))?;
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") {
std::fs::create_dir_all(out.join("llm"))?;
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)?)?;
// 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());
if let Some(lint) = value("--lint") {
@@ -1297,3 +1410,123 @@ mod tests {
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);
}
}