1use std::borrow::Cow;
4
5use headless_lms_utils::document_schema_processor::GutenbergBlock;
6use regex::Regex;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use serde_json::{Value, json};
10use uuid::Uuid;
11
12use super::blocks;
13use super::error::{EditError, EditErrorCode};
14use super::op::Op;
15use super::page_content;
16use super::read::block_preview;
17use super::{BlockMatch, Document, DocumentRef, PageDocument};
18use crate::ids::IdGenerator;
19use crate::text::plain_text;
20
21#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
24#[serde(deny_unknown_fields)]
25pub struct StructuralMatch {
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub name: Option<String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub text_starts_with: Option<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub text_contains: Option<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub text_regex: Option<String>,
37}
38
39#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
41#[serde(deny_unknown_fields)]
42pub struct StructuralAction {
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub wrap_in: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub retype_to: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub unwrap: Option<bool>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub remove: Option<bool>,
54}
55
56#[derive(Debug, Clone, PartialEq)]
58pub struct StructuralReplaceResult {
59 pub matches: Vec<BlockMatch>,
60 pub changed_pages: Vec<Document>,
61}
62
63enum Action<'a> {
64 Wrap(&'a str),
65 Retype(&'a str),
66 Unwrap,
67 Remove,
68}
69
70pub fn structural_replace(
73 pages: &[DocumentRef],
74 pattern: &StructuralMatch,
75 action: &StructuralAction,
76 expected_count: Option<usize>,
77 ids: &dyn IdGenerator,
78) -> Result<StructuralReplaceResult, EditError> {
79 let action = parse_action(action)?;
80 if pattern == &StructuralMatch::default() {
81 return Err(EditError::new(
82 EditErrorCode::MissingField,
83 "match needs at least one condition: name, text_starts_with, text_contains or text_regex",
84 ));
85 }
86 let text_regex = pattern
87 .text_regex
88 .as_deref()
89 .map(Regex::new)
90 .transpose()
91 .map_err(|e| {
92 EditError::new(
93 EditErrorCode::InvalidRegex,
94 format!("text_regex isn't a valid regex: {e}"),
95 )
96 })?;
97
98 let mut matches = Vec::new();
99 let mut changed_pages = Vec::new();
100 for page in pages {
101 let mut page_matches: Vec<(Uuid, Vec<usize>)> = Vec::new();
102 blocks::walk_outside_exercises(page.blocks, &mut |block, path| {
103 if is_match(block, pattern, text_regex.as_ref()) {
104 page_matches.push((block.client_id, path.to_vec()));
105 }
106 });
107 match action {
108 Action::Remove => {
109 let ancestors: Vec<Vec<usize>> =
111 page_matches.iter().map(|(_, p)| p.clone()).collect();
112 page_matches.retain(|(_, path)| {
113 !ancestors
114 .iter()
115 .any(|a| a.len() < path.len() && path.starts_with(a))
116 });
117 }
118 Action::Unwrap => page_matches.retain(|(_, path)| {
119 blocks::get(page.blocks, path).is_ok_and(|block| !block.inner_blocks.is_empty())
120 }),
121 _ => {}
122 }
123 for (block_id, path) in &page_matches {
124 let block = blocks::get(page.blocks, path)?;
125 matches.push(page.key.block_match(
126 block,
127 format!("/{block_id}"),
128 block_preview(block).unwrap_or_default(),
129 ));
130 }
131 if page_matches.is_empty() {
132 continue;
133 }
134 let ops: Vec<Op> = page_matches
135 .iter()
136 .map(|(id, _)| op_for(&action, *id))
137 .collect();
138 let document = PageDocument {
140 page_id: page.key.page_id().unwrap_or_default(),
141 page_type: page.page_type,
142 blocks: page.blocks.to_vec(),
143 };
144 let result = page_content::apply(document, &|_| None, &ops, ids).map_err(|mut e| {
145 e.message = format!("in {}: {}", page.key, e.message);
146 e
147 })?;
148 changed_pages.push(Document {
149 key: page.key,
150 page_type: page.page_type,
151 blocks: result.page.blocks,
152 });
153 }
154 if let Some(expected) = expected_count
155 && expected != matches.len()
156 {
157 return Err(EditError::expected_count_mismatch(
158 expected,
159 matches.len(),
160 matches.iter().map(|m| m.location(None)).collect(),
161 ));
162 }
163 Ok(StructuralReplaceResult {
164 matches,
165 changed_pages,
166 })
167}
168
169fn parse_action(action: &StructuralAction) -> Result<Action<'_>, EditError> {
170 let mut chosen = Vec::new();
171 if let Some(name) = &action.wrap_in {
172 chosen.push(Action::Wrap(name));
173 }
174 if let Some(name) = &action.retype_to {
175 chosen.push(Action::Retype(name));
176 }
177 if action.unwrap == Some(true) {
178 chosen.push(Action::Unwrap);
179 }
180 if action.remove == Some(true) {
181 chosen.push(Action::Remove);
182 }
183 if chosen.len() != 1 {
184 return Err(EditError::new(
185 EditErrorCode::InvalidValue,
186 "action takes exactly one of wrap_in, retype_to, unwrap: true or remove: true",
187 ));
188 }
189 Ok(chosen.remove(0))
190}
191
192fn op_for(action: &Action, id: Uuid) -> Op {
193 let path = format!("/{id}");
194 match action {
195 Action::Wrap(name) => Op::Wrap {
196 path,
197 through: None,
198 value: json!({ "name": name }),
199 label: None,
200 },
201 Action::Retype(name) => Op::Replace {
202 path: format!("{path}/name"),
203 value: Value::String(name.to_string()),
204 },
205 Action::Unwrap => Op::Unwrap { path },
206 Action::Remove => Op::Remove {
207 path,
208 through: None,
209 },
210 }
211}
212
213fn block_text(block: &GutenbergBlock) -> Option<Cow<'_, str>> {
215 block
216 .attributes
217 .get("content")
218 .and_then(Value::as_str)
219 .map(plain_text)
220}
221
222fn is_match(block: &GutenbergBlock, pattern: &StructuralMatch, text_regex: Option<&Regex>) -> bool {
223 if pattern
224 .name
225 .as_ref()
226 .is_some_and(|name| *name != block.name)
227 {
228 return false;
229 }
230 let needs_text = pattern.text_starts_with.is_some()
231 || pattern.text_contains.is_some()
232 || text_regex.is_some();
233 if !needs_text {
234 return true;
235 }
236 let Some(text) = block_text(block) else {
237 return false;
238 };
239 pattern
240 .text_starts_with
241 .as_ref()
242 .is_none_or(|p| text.starts_with(p.as_str()))
243 && pattern
244 .text_contains
245 .as_ref()
246 .is_none_or(|p| text.contains(p.as_str()))
247 && text_regex.is_none_or(|re| re.is_match(&text))
248}
249
250#[cfg(test)]
251mod tests {
252 use pretty_assertions::assert_eq;
253
254 use super::*;
255 use crate::engine::PageType;
256 use crate::ids::SequentialIds;
257 use crate::test_fixtures::{document_refs, id, infobox, page, para, top_level};
258
259 fn pages() -> Vec<PageDocument> {
260 vec![page(
261 "pg1",
262 PageType::CoursePage,
263 vec![
264 para("p1", "<strong>Note:</strong> remember this."),
265 para("p2", "Regular text."),
266 infobox("box1", vec![para("p3", "Note: nested.")]),
267 ],
268 )]
269 }
270
271 fn note_match() -> StructuralMatch {
272 StructuralMatch {
273 name: Some("core/paragraph".into()),
274 text_starts_with: Some("Note:".into()),
275 ..Default::default()
276 }
277 }
278
279 #[test]
280 fn wraps_each_match_ignoring_markup() {
281 let ids = SequentialIds::default();
282 let action = StructuralAction {
283 wrap_in: Some("moocfi/infobox".into()),
284 ..Default::default()
285 };
286 let result = structural_replace(
287 &document_refs(&pages()),
288 ¬e_match(),
289 &action,
290 Some(2),
291 &ids,
292 )
293 .unwrap();
294 assert_eq!(result.matches.len(), 2);
295 let blocks = &result.changed_pages[0].blocks;
296 assert_eq!(blocks[0].name, "moocfi/infobox");
297 assert_eq!(top_level(&blocks[0].inner_blocks), vec![id("p1")]);
298 assert_eq!(blocks[2].inner_blocks[0].name, "moocfi/infobox");
299 }
300
301 #[test]
302 fn retype_and_remove() {
303 let ids = SequentialIds::default();
304 let retype = StructuralAction {
305 retype_to: Some("core/heading".into()),
306 ..Default::default()
307 };
308 let result =
309 structural_replace(&document_refs(&pages()), ¬e_match(), &retype, None, &ids)
310 .unwrap();
311 assert_eq!(result.changed_pages[0].blocks[0].name, "core/heading");
312 let remove = StructuralAction {
313 remove: Some(true),
314 ..Default::default()
315 };
316 let all = StructuralMatch {
317 text_contains: Some("e".into()),
318 ..Default::default()
319 };
320 let result =
321 structural_replace(&document_refs(&pages()), &all, &remove, None, &ids).unwrap();
322 assert_eq!(top_level(&result.changed_pages[0].blocks), vec![id("box1")]);
323 }
324
325 #[test]
326 fn unwrap_reaches_nested_matches_and_skips_empty_ones() {
327 let ids = SequentialIds::default();
328 let doc = vec![page(
329 "pg1",
330 PageType::CoursePage,
331 vec![
332 infobox("box1", vec![infobox("box2", vec![para("p1", "Inside.")])]),
333 infobox("empty", vec![]),
334 ],
335 )];
336 let boxes = StructuralMatch {
337 name: Some("moocfi/infobox".into()),
338 ..Default::default()
339 };
340 let unwrap = StructuralAction {
341 unwrap: Some(true),
342 ..Default::default()
343 };
344 let result =
345 structural_replace(&document_refs(&doc), &boxes, &unwrap, Some(2), &ids).unwrap();
346 assert_eq!(result.matches.len(), 2);
347 assert_eq!(
348 top_level(&result.changed_pages[0].blocks),
349 vec![id("p1"), id("empty")]
350 );
351 }
352
353 #[test]
354 fn action_and_count_checks() {
355 let ids = SequentialIds::default();
356 let none = StructuralAction::default();
357 assert_eq!(
358 structural_replace(&document_refs(&pages()), ¬e_match(), &none, None, &ids)
359 .unwrap_err()
360 .code,
361 EditErrorCode::InvalidValue
362 );
363 let wrap = StructuralAction {
364 wrap_in: Some("moocfi/infobox".into()),
365 ..Default::default()
366 };
367 let error = structural_replace(
368 &document_refs(&pages()),
369 ¬e_match(),
370 &wrap,
371 Some(5),
372 &ids,
373 )
374 .unwrap_err();
375 assert_eq!(error.code, EditErrorCode::ExpectedCountMismatch);
376 }
377}