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:
co-authored by
Claude Fable 5.1
parent
255aeb4f4b
commit
93a6fc0e10
+237
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user