build: the lift matches cards by identity, not position
Sequences were walked side by side, so a rebuilt page whose cards sit in a different order, or with a new card in front, gave every card its neighbour's words: uhhm.no's front door got "Attention is earned" on the wrong card that way. Items now match on the bucket they record into, the page they lead to, a field's name, the group they invite into or the bucket they read; information features with no identity still match by position. Lifting live uhhm.no into the current plan puts each of its three headings on the card with its bucket. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
bf60879de9
commit
f5f5132056
@@ -45,9 +45,12 @@ default.
|
||||
|
||||
`--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. The lift is by position, so it holds for a
|
||||
rebuild of the same shape and not across a restructure: on uhhm.no it
|
||||
put the old front door's three headings on three new cards. `--words
|
||||
adds only the new slots. Cards are matched by what they are (the
|
||||
bucket they record into, the page they lead to, a field's name, the
|
||||
group they invite into), not by position, so a rebuild that reorders
|
||||
or reshapes a page still hands each card its own words; on uhhm.no
|
||||
the live front door's three headings follow their buckets onto the
|
||||
new page. `--words
|
||||
<json>` overrides slots by page and may repeat, each file layered over
|
||||
the last (the words a build used, then a round of hand changes);
|
||||
`words.json` in the output is the words as used. See
|
||||
|
||||
+74
-2
@@ -1693,6 +1693,39 @@ pub fn lift_words(plan: &Plan, repo: &Path) -> anyhow::Result<BTreeMap<String, V
|
||||
Ok(fallback.iter().map(|(path, v)| (path.clone(), untokenize(v, path, "", &found))).collect())
|
||||
}
|
||||
|
||||
/// What identifies an item of a page regardless of its position: the
|
||||
/// structural strings portal itself keys on. Word slots (tokens on
|
||||
/// the rendered side) are never an identity.
|
||||
fn identity(v: &serde_yaml::Value) -> Vec<(&'static str, String)> {
|
||||
const KEYS: &[&[&str]] = &[
|
||||
&["record_as"],
|
||||
&["action"],
|
||||
&["name"],
|
||||
&["invite", "group"],
|
||||
&["resource", "source", "bucket"],
|
||||
&["self_transition", "bucket"],
|
||||
];
|
||||
let mut id = Vec::new();
|
||||
for path in KEYS {
|
||||
let mut at = v;
|
||||
for key in path.iter() {
|
||||
match at.get(*key) {
|
||||
Some(next) => at = next,
|
||||
None => {
|
||||
at = &serde_yaml::Value::Null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = at.as_str() {
|
||||
if !s.starts_with('\u{1}') && !s.is_empty() {
|
||||
id.push((path[0], s.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn token(path: &str, pointer: &str) -> String {
|
||||
format!("\u{1}{path}\u{2}{pointer}\u{3}")
|
||||
}
|
||||
@@ -1725,9 +1758,27 @@ fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std::
|
||||
}
|
||||
}
|
||||
}
|
||||
// Items are matched by what they are, not where they sit: the
|
||||
// bucket an alternative records into, the page it leads to, a
|
||||
// field's name, the group it invites into. A rebuild that
|
||||
// reorders or reshapes a page still hands each card its own
|
||||
// words; on uhhm.no a positional match once put the old front
|
||||
// door's three headings on three unrelated new cards. Items
|
||||
// with no identity (an information feature) fall back to
|
||||
// position.
|
||||
(serde_yaml::Value::Sequence(x), serde_yaml::Value::Sequence(y)) => {
|
||||
for (va, vb) in x.iter().zip(y) {
|
||||
collect_words(va, vb, found);
|
||||
let mut used = vec![false; y.len()];
|
||||
for (i, va) in x.iter().enumerate() {
|
||||
let ida = identity(va);
|
||||
let hit = if ida.is_empty() {
|
||||
(i < y.len() && !used[i] && identity(&y[i]).is_empty()).then_some(i)
|
||||
} else {
|
||||
(0..y.len()).find(|&j| !used[j] && identity(&y[j]).iter().any(|k| ida.contains(k)))
|
||||
};
|
||||
if let Some(j) = hit {
|
||||
used[j] = true;
|
||||
collect_words(va, &y[j], found);
|
||||
}
|
||||
}
|
||||
}
|
||||
(serde_yaml::Value::String(t), serde_yaml::Value::String(live)) if t.starts_with('\u{1}') => {
|
||||
@@ -1786,4 +1837,25 @@ mod invite_tests {
|
||||
assert!(lifted2["questions/trafikk/index.yaml"]["invites"]["styret"]["heading"].as_str().unwrap().starts_with("Invite someone"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_follow_their_card_when_the_live_page_is_ordered_differently() {
|
||||
let plan: Plan = serde_yaml::from_str(PLAN).unwrap();
|
||||
let mut words = fallback_words(&plan);
|
||||
words.get_mut("questions/index.yaml").unwrap()["needs"]["melde"]["heading"] = json!("Meld fra om en fare");
|
||||
let dir = std::env::temp_dir().join(format!("lift-order-{}", std::process::id()));
|
||||
write_repo(&dir, render(&plan, &words).unwrap()).unwrap();
|
||||
// The live front door has an extra first card the plan knows
|
||||
// nothing about, so the hazard form is no longer first.
|
||||
let front = dir.join("questions/index.yaml");
|
||||
let mut page: serde_yaml::Value = serde_yaml::from_str(&std::fs::read_to_string(&front).unwrap()).unwrap();
|
||||
let alternatives = page["alternatives"].as_sequence_mut().unwrap();
|
||||
let stranger: serde_yaml::Value = serde_yaml::from_str("name: About us\ndescription: Founded in 1947.\nrecord_as: newsletter\nfeatures: []\n").unwrap();
|
||||
alternatives.insert(0, stranger);
|
||||
std::fs::write(&front, serde_yaml::to_string(&page).unwrap()).unwrap();
|
||||
|
||||
let lifted = lift_words(&plan, &dir).unwrap();
|
||||
assert_eq!(lifted["questions/index.yaml"]["needs"]["melde"]["heading"], "Meld fra om en fare");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user