1use std::collections::{HashMap, HashSet};
4
5use headless_lms_utils::document_schema_processor::GutenbergBlock;
6use serde_json::{Map, Value, json};
7use uuid::Uuid;
8
9use super::address::ListRef;
10use super::{Editor, check_creatable, exercise_read_only};
11use crate::engine::blocks;
12use crate::engine::blocks::RENDERED_BLOCK_KEYS;
13use crate::engine::error::{EditError, EditErrorCode, Warning, WarningCode};
14use crate::engine::labels::label_field;
15use crate::engine::path::parse_uuid;
16use crate::engine::reject_unknown_keys;
17
18const BLOCK_FIELDS: [&str; 5] = ["clientId", "name", "attributes", "innerBlocks", "isValid"];
20
21const NEW_BLOCK_KEYS: [&str; 6] = [
23 "name",
24 "attributes",
25 "innerBlocks",
26 "label",
27 "clientId",
28 "isValid",
29];
30
31pub(super) enum ListEntry {
33 Stub(Uuid),
35 Existing {
37 id: Uuid,
38 name: Option<String>,
39 attributes: Option<Map<String, Value>>,
40 inner: Option<Vec<ListEntry>>,
41 },
42 New {
43 name: String,
44 attributes: Map<String, Value>,
45 label: Option<String>,
46 ignored_client_id: Option<Value>,
48 inner: Vec<ListEntry>,
49 },
50}
51
52impl Editor<'_> {
53 pub(super) fn replace_list(
56 &mut self,
57 list: ListRef,
58 entries: Vec<ListEntry>,
59 ) -> Result<(), EditError> {
60 let parent_path = self.list_parent_path(list)?;
61 let doc = &self.pages[list.page].blocks;
62 let locations = check_list_entries(doc, &parent_path, &entries)?;
63 let existing: HashMap<Uuid, GutenbergBlock> = locations
64 .iter()
65 .map(|(id, path)| Ok((*id, blocks::get(doc, path)?.clone())))
66 .collect::<Result<_, EditError>>()?;
67 let new_list = self.build_entries(entries, &existing)?;
68
69 let from_elsewhere: Vec<Uuid> = locations
70 .iter()
71 .filter(|(_, path)| !(path.len() > parent_path.len() && path.starts_with(&parent_path)))
72 .map(|(id, _)| *id)
73 .collect();
74 let doc = &mut self.pages[list.page].blocks;
75 for id in from_elsewhere {
76 if let Some(path) = blocks::find_path(doc, id)
77 && let Some((index, parent_path)) = path.split_last()
78 {
79 blocks::list_mut(doc, parent_path)?.remove(*index);
80 }
81 }
82 let parent_path = self.list_parent_path(list)?;
84 *blocks::list_mut(&mut self.pages[list.page].blocks, &parent_path)? = new_list;
85 Ok(())
86 }
87
88 fn build_entries(
89 &mut self,
90 entries: Vec<ListEntry>,
91 existing: &HashMap<Uuid, GutenbergBlock>,
92 ) -> Result<Vec<GutenbergBlock>, EditError> {
93 entries
94 .into_iter()
95 .map(|entry| self.build_entry(entry, existing))
96 .collect()
97 }
98
99 pub(super) fn build_entry(
101 &mut self,
102 entry: ListEntry,
103 existing: &HashMap<Uuid, GutenbergBlock>,
104 ) -> Result<GutenbergBlock, EditError> {
105 match entry {
106 ListEntry::Stub(id) => existing
107 .get(&id)
108 .cloned()
109 .ok_or_else(blocks::stale_location),
110 ListEntry::Existing {
111 id,
112 name,
113 attributes,
114 inner,
115 } => {
116 let block = existing.get(&id).ok_or_else(blocks::stale_location)?;
117 let name = name.unwrap_or_else(|| block.name.clone());
118 check_creatable(&name)?;
119 let attributes = attributes.unwrap_or_else(|| block.attributes.clone());
120 let inner_blocks = match inner {
121 Some(inner) => self.build_entries(inner, existing)?,
122 None => block.inner_blocks.clone(),
123 };
124 Ok(GutenbergBlock {
125 client_id: id,
126 name,
127 is_valid: true,
128 attributes,
129 inner_blocks,
130 })
131 }
132 ListEntry::New {
133 name,
134 attributes,
135 label,
136 ignored_client_id,
137 inner,
138 } => {
139 let id = self.ids.new_id();
140 if let Some(sent) = ignored_client_id {
141 self.warnings.push(Warning::new(
142 WarningCode::IgnoredClientId,
143 format!(
144 "clientId {sent} on a new block was replaced by {id}; the server assigns ids, and existing blocks are relocated with move"
145 ),
146 ));
147 }
148 if let Some(label) = label {
149 self.labels.define(&label, id)?;
150 }
151 let inner_blocks = self.build_entries(inner, existing)?;
152 Ok(GutenbergBlock {
153 client_id: id,
154 name,
155 is_valid: true,
156 attributes,
157 inner_blocks,
158 })
159 }
160 }
161 }
162}
163
164fn check_list_entries(
167 doc: &[GutenbergBlock],
168 parent_path: &[usize],
169 entries: &[ListEntry],
170) -> Result<HashMap<Uuid, Vec<usize>>, EditError> {
171 let mut referenced: Vec<(Uuid, bool)> = Vec::new();
172 collect_references(entries, &mut referenced);
173 let mut seen = HashSet::new();
174 let duplicates: Vec<String> = referenced
175 .iter()
176 .filter(|(id, _)| !seen.insert(*id))
177 .map(|(id, _)| id.to_string())
178 .collect();
179 if !duplicates.is_empty() {
180 return Err(EditError::new(
181 EditErrorCode::DuplicateIds,
182 "each existing block may appear once in the value",
183 )
184 .with_details(json!({"duplicate_ids": duplicates})));
185 }
186
187 let mut locations: HashMap<Uuid, Vec<usize>> = HashMap::new();
188 let mut unknown = Vec::new();
189 for (id, _) in &referenced {
190 match blocks::find_path(doc, *id) {
191 Some(path) => {
192 locations.insert(*id, path);
193 }
194 None => unknown.push(id.to_string()),
195 }
196 }
197 if !unknown.is_empty() {
198 return Err(EditError::new(
199 EditErrorCode::UnknownIds,
200 format!("these ids aren't on this page: {}", unknown.join(", ")),
201 )
202 .with_fix("leave clientId out of new blocks; blocks on other pages are brought over with move and to_page")
203 .with_details(json!({"unknown_ids": unknown})));
204 }
205
206 for depth in 1..=parent_path.len() {
207 let ancestor = blocks::get(doc, &parent_path[..depth])?.client_id;
208 if locations.contains_key(&ancestor) {
209 return Err(EditError::new(
210 EditErrorCode::MoveIntoItself,
211 format!(
212 "/{ancestor} contains the list being replaced, so it can't be placed inside it"
213 ),
214 ));
215 }
216 }
217
218 let stubs: HashSet<Uuid> = referenced
219 .iter()
220 .filter(|(_, is_stub)| *is_stub)
221 .map(|(id, _)| *id)
222 .collect();
223 for (id, path) in &locations {
224 if let Some(holder) = blocks::ancestor_ids(doc, path)
225 .into_iter()
226 .find(|a| stubs.contains(a))
227 {
228 return Err(EditError::new(
229 EditErrorCode::DuplicateIds,
230 format!("/{id} is inside /{holder}, whose stub already keeps it"),
231 )
232 .with_fix(format!(
233 "give /{holder} as a full block with innerBlocks, or drop the separate /{id}"
234 )));
235 }
236 }
237
238 let mut required: Vec<Uuid> = blocks::list(doc, parent_path)?
239 .iter()
240 .map(|b| b.client_id)
241 .collect();
242 collect_required_children(entries, doc, &locations, &mut required)?;
243 let missing: Vec<String> = required
244 .iter()
245 .filter(|id| !locations.contains_key(id))
246 .map(|id| id.to_string())
247 .collect();
248 if !missing.is_empty() {
249 let stubs = missing
250 .iter()
251 .map(|id| format!("{{\"clientId\":\"{id}\"}}"))
252 .collect::<Vec<_>>()
253 .join(",");
254 return Err(EditError::new(
255 EditErrorCode::MissingIds,
256 format!(
257 "the value leaves out existing blocks: {}; deletions need an explicit remove",
258 missing.join(", ")
259 ),
260 )
261 .with_fix(format!(
262 "keep them with stubs ({stubs}), or remove them with separate remove ops"
263 ))
264 .with_details(json!({"missing_ids": missing})));
265 }
266
267 for (id, path) in &locations {
268 let is_given_in_full = !stubs.contains(id);
269 if blocks::is_in_exercise(doc, path, is_given_in_full)? {
270 return Err(exercise_read_only("replace it"));
271 }
272 }
273 Ok(locations)
274}
275
276fn collect_references(entries: &[ListEntry], out: &mut Vec<(Uuid, bool)>) {
278 for entry in entries {
279 match entry {
280 ListEntry::Stub(id) => out.push((*id, true)),
281 ListEntry::Existing { id, inner, .. } => {
282 out.push((*id, false));
283 if let Some(inner) = inner {
284 collect_references(inner, out);
285 }
286 }
287 ListEntry::New { inner, .. } => collect_references(inner, out),
288 }
289 }
290}
291
292fn collect_required_children(
294 entries: &[ListEntry],
295 doc: &[GutenbergBlock],
296 locations: &HashMap<Uuid, Vec<usize>>,
297 required: &mut Vec<Uuid>,
298) -> Result<(), EditError> {
299 for entry in entries {
300 match entry {
301 ListEntry::Stub(_) => {}
302 ListEntry::Existing { id, inner, .. } => {
303 let path = locations.get(id).ok_or_else(blocks::stale_location)?;
304 let existing = blocks::get(doc, path)?;
305 match inner {
306 Some(inner) => {
307 required.extend(existing.inner_blocks.iter().map(|b| b.client_id));
308 collect_required_children(inner, doc, locations, required)?;
309 }
310 None if !existing.inner_blocks.is_empty() => {
311 let stubs = existing
312 .inner_blocks
313 .iter()
314 .map(|b| format!("{{\"clientId\":\"{}\"}}", b.client_id))
315 .collect::<Vec<_>>()
316 .join(",");
317 return Err(EditError::new(
318 EditErrorCode::MissingField,
319 format!("/{id} has inner blocks, so its replacement needs innerBlocks"),
320 )
321 .with_fix(format!("pass \"innerBlocks\":[{stubs}] to keep them")));
322 }
323 None => {}
324 }
325 }
326 ListEntry::New { inner, .. } => {
327 collect_required_children(inner, doc, locations, required)?
328 }
329 }
330 }
331 Ok(())
332}
333
334pub(super) fn parse_list_entries(value: &Value, what: &str) -> Result<Vec<ListEntry>, EditError> {
335 let Value::Array(items) = value else {
336 return Err(EditError::new(
337 EditErrorCode::InvalidValue,
338 format!(
339 "replacing {what} takes an array of blocks; to replace one block use its own path"
340 ),
341 ));
342 };
343 items.iter().map(parse_list_entry).collect()
344}
345
346pub(super) fn parse_list_entry(value: &Value) -> Result<ListEntry, EditError> {
348 let object = value.as_object().ok_or_else(|| {
349 EditError::new(
350 EditErrorCode::InvalidValue,
351 "each item is a block object or a {\"clientId\":\"…\"} stub",
352 )
353 })?;
354 let Some(raw) = object.get("clientId") else {
355 return parse_new_entry(object, &parse_list_entry);
356 };
357 let id = raw
358 .as_str()
359 .ok_or_else(|| EditError::new(EditErrorCode::InvalidValue, "clientId is a string"))?;
360 let id = parse_uuid(id, id)?;
361 let is_stub = object
362 .keys()
363 .all(|k| k == "clientId" || RENDERED_BLOCK_KEYS.contains(&k.as_str()));
364 if is_stub {
365 return Ok(ListEntry::Stub(id));
366 }
367 let allowed: Vec<&str> = BLOCK_FIELDS
368 .iter()
369 .chain(&RENDERED_BLOCK_KEYS)
370 .copied()
371 .collect();
372 check_block_keys(object, &allowed)?;
373 let name = match object.get("name") {
374 None => None,
375 Some(_) => Some(block_name(object)?),
376 };
377 Ok(ListEntry::Existing {
378 id,
379 name,
380 attributes: block_attributes(object)?,
381 inner: parse_inner_blocks(object, &parse_list_entry)?,
382 })
383}
384
385pub(super) fn parse_new_block(value: &Value) -> Result<ListEntry, EditError> {
387 let object = value.as_object().ok_or_else(|| {
388 EditError::new(
389 EditErrorCode::InvalidValue,
390 "a block is an object like {\"name\":\"core/paragraph\",\"attributes\":{\"content\":\"…\"}}",
391 )
392 })?;
393 parse_new_entry(object, &parse_new_block)
394}
395
396fn parse_new_entry(
398 object: &Map<String, Value>,
399 parse_child: &dyn Fn(&Value) -> Result<ListEntry, EditError>,
400) -> Result<ListEntry, EditError> {
401 check_block_keys(object, &NEW_BLOCK_KEYS)?;
402 let name = block_name(object)?;
403 check_creatable(&name)?;
404 Ok(ListEntry::New {
405 name,
406 attributes: block_attributes(object)?.unwrap_or_default(),
407 label: label_field(object, "$box")?,
408 ignored_client_id: object.get("clientId").cloned(),
409 inner: parse_inner_blocks(object, parse_child)?.unwrap_or_default(),
410 })
411}
412
413fn parse_inner_blocks(
415 object: &Map<String, Value>,
416 parse_child: &dyn Fn(&Value) -> Result<ListEntry, EditError>,
417) -> Result<Option<Vec<ListEntry>>, EditError> {
418 match object.get("innerBlocks") {
419 None | Some(Value::Null) => Ok(None),
420 Some(Value::Array(items)) => items
421 .iter()
422 .map(parse_child)
423 .collect::<Result<_, _>>()
424 .map(Some),
425 Some(_) => Err(EditError::new(
426 EditErrorCode::InvalidValue,
427 "innerBlocks must be an array of blocks",
428 )),
429 }
430}
431
432fn check_block_keys(object: &Map<String, Value>, allowed: &[&str]) -> Result<(), EditError> {
433 reject_unknown_keys(object, allowed, "a block").map_err(|error| {
434 error.with_fix(format!(
435 "block fields are: {}; block-specific values go inside attributes",
436 allowed.join(", ")
437 ))
438 })
439}
440
441fn block_name(object: &Map<String, Value>) -> Result<String, EditError> {
442 match object.get("name") {
443 Some(Value::String(name)) if name.contains('/') => Ok(name.clone()),
444 Some(Value::String(name)) => Err(EditError::new(
445 EditErrorCode::InvalidValue,
446 format!("block name \"{name}\" needs its namespace"),
447 )
448 .with_fix(format!("core/{name} or moocfi/{name}"))),
449 Some(_) => Err(EditError::new(
450 EditErrorCode::InvalidValue,
451 "name is a string",
452 )),
453 None => Err(EditError::new(
454 EditErrorCode::MissingField,
455 "a new block needs \"name\", e.g. \"core/paragraph\"",
456 )),
457 }
458}
459
460fn block_attributes(object: &Map<String, Value>) -> Result<Option<Map<String, Value>>, EditError> {
461 match object.get("attributes") {
462 None | Some(Value::Null) => Ok(None),
463 Some(Value::Object(attributes)) => Ok(Some(attributes.clone())),
464 Some(_) => Err(EditError::new(
465 EditErrorCode::InvalidValue,
466 "attributes is an object",
467 )),
468 }
469}