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-from <repo>` lifts the words a repo already has back into the
|
||||||
words map by structure, so a rebuild keeps hand-edited wording and
|
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
|
adds only the new slots. Cards are matched by what they are (the
|
||||||
rebuild of the same shape and not across a restructure: on uhhm.no it
|
bucket they record into, the page they lead to, a field's name, the
|
||||||
put the old front door's three headings on three new cards. `--words
|
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
|
<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);
|
the last (the words a build used, then a round of hand changes);
|
||||||
`words.json` in the output is the words as used. See
|
`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())
|
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 {
|
fn token(path: &str, pointer: &str) -> String {
|
||||||
format!("\u{1}{path}\u{2}{pointer}\u{3}")
|
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)) => {
|
(serde_yaml::Value::Sequence(x), serde_yaml::Value::Sequence(y)) => {
|
||||||
for (va, vb) in x.iter().zip(y) {
|
let mut used = vec![false; y.len()];
|
||||||
collect_words(va, vb, found);
|
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}') => {
|
(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"));
|
assert!(lifted2["questions/trafikk/index.yaml"]["invites"]["styret"]["heading"].as_str().unwrap().starts_with("Invite someone"));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
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