1use std::collections::{HashMap, HashSet};
5
6use headless_lms_models::library::{
7 agent_changesets::{StagedEntity, course_state::CourseState},
8 entity_snapshots::{ContentEntityType, EntityKey},
9};
10use headless_lms_utils::document_schema_processor::GutenbergBlock;
11use serde::Serialize;
12use uuid::Uuid;
13
14use crate::engine::PageRef;
15use crate::snapshots;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "snake_case")]
20pub enum ChangeKind {
21 Added,
22 Removed,
23 Changed,
24 Moved,
26}
27
28#[derive(Debug, Clone, PartialEq, Serialize)]
30pub struct BlockChange {
31 pub block_id: Uuid,
32 pub name: String,
33 pub kind: ChangeKind,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub page_before: Option<Uuid>,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub page_after: Option<Uuid>,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub exercise_id: Option<Uuid>,
41 #[serde(skip_serializing_if = "Vec::is_empty")]
43 pub changed_fields: Vec<String>,
44 pub is_moved: bool,
46}
47
48struct Placed<'a> {
49 page: Uuid,
50 parent: Option<Uuid>,
51 previous_sibling: Option<Uuid>,
52 block: &'a GutenbergBlock,
53}
54
55fn place_blocks<'a>(pages: &[PageRef<'a>]) -> (HashMap<Uuid, Placed<'a>>, Vec<Uuid>) {
57 fn place<'a>(
58 page: Uuid,
59 parent: Option<Uuid>,
60 blocks: &'a [GutenbergBlock],
61 placed: &mut HashMap<Uuid, Placed<'a>>,
62 order: &mut Vec<Uuid>,
63 ) {
64 let mut previous_sibling = None;
65 for block in blocks {
66 order.push(block.client_id);
67 placed.insert(
68 block.client_id,
69 Placed {
70 page,
71 parent,
72 previous_sibling,
73 block,
74 },
75 );
76 place(
77 page,
78 Some(block.client_id),
79 &block.inner_blocks,
80 placed,
81 order,
82 );
83 previous_sibling = Some(block.client_id);
84 }
85 }
86 let mut placed = HashMap::new();
87 let mut order = Vec::new();
88 for page in pages {
89 place(page.page_id, None, page.blocks, &mut placed, &mut order);
90 }
91 (placed, order)
92}
93
94pub fn diff_blocks(before: &[PageRef], after: &[PageRef]) -> Vec<BlockChange> {
97 let (old, old_order) = place_blocks(before);
98 let (new, new_order) = place_blocks(after);
99 let mut changes = Vec::new();
100
101 let mut seen: HashSet<Uuid> = HashSet::new();
102 for id in old_order.into_iter().chain(new_order) {
103 if !seen.insert(id) {
104 continue;
105 }
106 match (old.get(&id), new.get(&id)) {
107 (Some(o), None) => changes.push(BlockChange {
108 block_id: id,
109 name: o.block.name.clone(),
110 kind: ChangeKind::Removed,
111 page_before: Some(o.page),
112 page_after: None,
113 exercise_id: o.block.exercise_id(),
114 changed_fields: Vec::new(),
115 is_moved: false,
116 }),
117 (None, Some(n)) => changes.push(BlockChange {
118 block_id: id,
119 name: n.block.name.clone(),
120 kind: ChangeKind::Added,
121 page_before: None,
122 page_after: Some(n.page),
123 exercise_id: n.block.exercise_id(),
124 changed_fields: Vec::new(),
125 is_moved: false,
126 }),
127 (Some(o), Some(n)) => {
128 let mut changed_fields = Vec::new();
129 if o.block.name != n.block.name {
130 changed_fields.push("name".to_string());
131 }
132 let mut keys: Vec<&String> = o
133 .block
134 .attributes
135 .keys()
136 .chain(n.block.attributes.keys())
137 .collect();
138 keys.sort();
139 keys.dedup();
140 for key in keys {
141 if o.block.attributes.get(key) != n.block.attributes.get(key) {
142 changed_fields.push(format!("attributes/{key}"));
143 }
144 }
145 let is_moved_among_siblings = o.previous_sibling != n.previous_sibling
147 && n.previous_sibling.is_none_or(|p| old.contains_key(&p))
148 && o.previous_sibling.is_none_or(|p| new.contains_key(&p));
149 let is_moved = o.page != n.page || o.parent != n.parent || is_moved_among_siblings;
150 if changed_fields.is_empty() && !is_moved {
151 continue;
152 }
153 changes.push(BlockChange {
154 block_id: id,
155 name: n.block.name.clone(),
156 kind: if changed_fields.is_empty() {
157 ChangeKind::Moved
158 } else {
159 ChangeKind::Changed
160 },
161 page_before: Some(o.page),
162 page_after: Some(n.page),
163 exercise_id: n.block.exercise_id(),
164 changed_fields,
165 is_moved,
166 });
167 }
168 (None, None) => {}
169 }
170 }
171 changes
172}
173
174pub fn diff_pages(
177 before: &CourseState,
178 after: &CourseState,
179 page_ids: &[Uuid],
180) -> Vec<BlockChange> {
181 fn blocks<'a>(state: &'a CourseState, page_ids: &[Uuid]) -> Vec<PageRef<'a>> {
182 page_ids
183 .iter()
184 .filter_map(|id| Some(snapshots::page_ref(state.page(*id)?)))
185 .collect()
186 }
187 diff_blocks(&blocks(before, page_ids), &blocks(after, page_ids))
188}
189
190pub fn structure_changes(before: &CourseState, after: &CourseState) -> HashMap<Uuid, ChangeKind> {
192 let changes = after.changes_since(before);
193 let written = changes
194 .entities
195 .iter()
196 .filter(|entity| entity.after().is_some())
197 .map(StagedEntity::key)
198 .filter(|key| {
199 matches!(
200 key.entity_type,
201 ContentEntityType::Module | ContentEntityType::Chapter
202 )
203 })
204 .chain(
205 changes
206 .pages
207 .iter()
208 .filter(|page_id| after.page(**page_id).is_some())
209 .map(|page_id| EntityKey::new(ContentEntityType::Page, *page_id)),
210 );
211 written
212 .map(|key| {
213 let kind = if !before.has_entity(key) {
214 ChangeKind::Added
215 } else {
216 let is_content_changed = key.entity_type == ContentEntityType::Page
217 && before.page(key.entity_id).map(|page| &page.content)
218 != after.page(key.entity_id).map(|page| &page.content);
219 let fields = after.field_changes_since(before, key);
220 let is_placed_only = !is_content_changed
221 && !fields.is_empty()
222 && fields.iter().all(|change| change.field.is_placement());
223 if is_placed_only {
224 ChangeKind::Moved
225 } else {
226 ChangeKind::Changed
227 }
228 };
229 (key.entity_id, kind)
230 })
231 .collect()
232}
233
234pub fn changed_roots(before: Option<PageRef>, page: PageRef) -> Vec<GutenbergBlock> {
238 let mut parent_before: HashMap<Uuid, Uuid> = HashMap::new();
239 if let Some(before) = before {
240 crate::engine::blocks::walk(before.blocks, &mut |block, _| {
241 for child in &block.inner_blocks {
242 parent_before.insert(child.client_id, block.client_id);
243 }
244 });
245 }
246 let mut touched: HashSet<Uuid> = HashSet::new();
247 let before_pages: Vec<PageRef> = before.into_iter().collect();
248 for change in diff_blocks(&before_pages, &[page]) {
249 if change.kind != ChangeKind::Removed {
250 touched.insert(change.block_id);
251 }
252 let left_its_parent = change.kind == ChangeKind::Removed || change.is_moved;
253 if left_its_parent && let Some(parent) = parent_before.get(&change.block_id) {
254 touched.insert(*parent);
255 }
256 }
257 fn holds_touched(block: &GutenbergBlock, touched: &HashSet<Uuid>) -> bool {
258 touched.contains(&block.client_id)
259 || block
260 .inner_blocks
261 .iter()
262 .any(|child| holds_touched(child, touched))
263 }
264 page.blocks
265 .iter()
266 .filter(|root| holds_touched(root, &touched))
267 .cloned()
268 .collect()
269}
270
271#[cfg(test)]
272mod tests {
273 use pretty_assertions::assert_eq;
274
275 use serde_json::json;
276
277 use super::*;
278 use crate::engine::{PageDocument, PageType};
279 use crate::test_fixtures::{exercise, id, infobox, page, para};
280
281 fn blocks(documents: &[PageDocument]) -> Vec<PageRef<'_>> {
282 documents.iter().map(PageDocument::as_page_ref).collect()
283 }
284
285 fn diff(before: &[PageDocument], after: &[PageDocument]) -> Vec<BlockChange> {
286 diff_blocks(&blocks(before), &blocks(after))
287 }
288
289 fn before() -> Vec<PageDocument> {
290 vec![
291 page(
292 "pg1",
293 PageType::CoursePage,
294 vec![
295 para("p1", "a"),
296 para("p2", "b"),
297 infobox("box", vec![para("c1", "c")]),
298 exercise("ex1", "e1"),
299 ],
300 ),
301 page("pg2", PageType::CoursePage, vec![para("q1", "x")]),
302 ]
303 }
304
305 #[test]
306 fn block_changes_by_id() {
307 let mut after = before();
308 after[0].blocks.remove(0);
309 after[0].blocks[0]
310 .attributes
311 .insert("content".into(), json!("B"));
312 let moved = after[0].blocks.remove(2);
313 after[1].blocks.push(moved);
314 after[0].blocks[1].inner_blocks.push(para("new", "n"));
315 let result = diff(&before(), &after);
316 let kinds: Vec<(Uuid, ChangeKind)> = result.iter().map(|b| (b.block_id, b.kind)).collect();
317 assert_eq!(
318 kinds,
319 vec![
320 (id("p1"), ChangeKind::Removed),
321 (id("p2"), ChangeKind::Changed),
322 (id("ex1"), ChangeKind::Moved),
323 (id("new"), ChangeKind::Added)
324 ]
325 );
326 let exercise_move = &result[2];
327 assert_eq!(
328 (exercise_move.page_before, exercise_move.page_after),
329 (Some(id("pg1")), Some(id("pg2")))
330 );
331 assert_eq!(exercise_move.exercise_id, Some(id("e1")));
332 assert_eq!(
333 result[1].changed_fields,
334 vec!["attributes/content".to_string()]
335 );
336 }
337
338 #[test]
339 fn reordering_siblings_is_a_move() {
340 let mut after = before();
341 after[0].blocks.swap(0, 1);
342 let result = diff(&before(), &after);
343 assert!(result.iter().all(|b| b.kind == ChangeKind::Moved));
344 assert!(!result.is_empty());
345 }
346
347 #[test]
348 fn unchanged_is_empty() {
349 let result = diff(&before(), &before());
350 assert!(result.is_empty());
351 }
352
353 #[test]
354 fn changed_roots_are_top_level_ancestors() {
355 let before = page(
356 "pg1",
357 PageType::CoursePage,
358 vec![para("p1", "a"), infobox("box", vec![para("c1", "b")])],
359 );
360 let mut after = before.clone();
361 after.blocks[1].inner_blocks[0]
362 .attributes
363 .insert("content".into(), json!("B"));
364 let roots = changed_roots(Some(before.as_page_ref()), after.as_page_ref());
365 assert_eq!(roots.len(), 1);
366 assert_eq!(roots[0].client_id, id("box"));
367 }
368
369 #[test]
370 fn a_container_that_lost_a_child_is_a_changed_root() {
371 let before = page(
372 "pg1",
373 PageType::CoursePage,
374 vec![
375 para("p1", "a"),
376 infobox("box", vec![para("c1", "b"), para("c2", "c")]),
377 ],
378 );
379 let mut after = before.clone();
380 after.blocks[1].inner_blocks.remove(0);
381 let roots = changed_roots(Some(before.as_page_ref()), after.as_page_ref());
382 assert_eq!(roots.len(), 1);
383 assert_eq!(roots[0].client_id, id("box"));
384
385 let mut moved_out = before.clone();
386 let child = moved_out.blocks[1].inner_blocks.remove(1);
387 moved_out.blocks.insert(0, child);
388 let ids: Vec<Uuid> = changed_roots(Some(before.as_page_ref()), moved_out.as_page_ref())
389 .iter()
390 .map(|root| root.client_id)
391 .collect();
392 assert!(ids.contains(&id("box")), "{ids:?}");
393 }
394}