1use std::sync::{Arc, LazyLock};
5
6use headless_lms_models::library::agent_changesets::course_state::{
7 CourseState, ExerciseRecord, ExerciseReviewConfig, review_instructions_for_storage,
8};
9use headless_lms_utils::document_schema_processor::GutenbergBlock;
10use headless_lms_utils::strings::decode_html_entities;
11use regex::Regex;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use uuid::Uuid;
15
16use crate::engine::{Document, DocumentKey, EditError, EditErrorCode, PageDocument, PageType};
17use crate::validator::ExerciseContext;
18use headless_lms_utils::strings::escape_html_text;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum InstructionsList {
23 Task {
24 exercise_id: Uuid,
25 task_id: Uuid,
26 },
27 Review {
29 exercise_id: Uuid,
30 },
31 CourseDefaultReview {
33 config_id: Uuid,
34 },
35}
36
37impl InstructionsList {
38 pub fn document_id(self) -> Uuid {
41 match self {
42 InstructionsList::Task { task_id, .. } => task_id,
43 InstructionsList::Review { exercise_id } => exercise_id,
44 InstructionsList::CourseDefaultReview { config_id } => config_id,
45 }
46 }
47
48 pub fn path_prefix(self) -> String {
50 match self {
51 InstructionsList::Task { task_id, .. } => format!("/tasks/{task_id}"),
52 InstructionsList::Review { .. } | InstructionsList::CourseDefaultReview { .. } => {
53 "/review_instructions".to_string()
54 }
55 }
56 }
57
58 pub fn exercise_id(self) -> Option<Uuid> {
60 match self {
61 InstructionsList::Task { exercise_id, .. }
62 | InstructionsList::Review { exercise_id } => Some(exercise_id),
63 InstructionsList::CourseDefaultReview { .. } => None,
64 }
65 }
66
67 pub fn location(self) -> InstructionsLocation {
69 InstructionsLocation {
70 exercise: self.exercise_id(),
71 instructions: self.path_prefix(),
72 course_default_review_config: matches!(
73 self,
74 InstructionsList::CourseDefaultReview { .. }
75 ),
76 }
77 }
78
79 pub fn page(self, state: &CourseState) -> Option<Uuid> {
81 state.exercise_page_id(self.exercise_id()?)
82 }
83
84 pub fn validation_context(self) -> ExerciseContext {
86 match self {
87 InstructionsList::Task { .. } => ExerciseContext::TaskInstructions,
88 InstructionsList::Review { .. } | InstructionsList::CourseDefaultReview { .. } => {
89 ExerciseContext::ReviewInstructions
90 }
91 }
92 }
93
94 fn stored_blocks(self, state: &CourseState) -> Option<Option<&Value>> {
96 match self {
97 InstructionsList::Task {
98 exercise_id,
99 task_id,
100 } => state
101 .exercise(exercise_id)?
102 .tasks
103 .iter()
104 .find(|task| task.id == task_id)
105 .map(|task| Some(task.assignment.as_ref())),
106 InstructionsList::Review { exercise_id } => {
107 match &state.exercise(exercise_id)?.review_config {
108 ExerciseReviewConfig::Own {
109 review_instructions,
110 ..
111 } => Some(review_instructions.as_deref()),
112 _ => None,
113 }
114 }
115 InstructionsList::CourseDefaultReview { config_id } => state
116 .course_default_review_config
117 .as_ref()
118 .filter(|config| config.id == config_id)
119 .map(|config| config.review_instructions.as_ref()),
120 }
121 }
122
123 pub fn document(self, state: &CourseState) -> Option<Result<PageDocument, EditError>> {
125 let stored = self.stored_blocks(state)?;
126 Some(parse_blocks(stored).map(|blocks| PageDocument {
127 page_id: self.document_id(),
128 page_type: PageType::CoursePage,
129 blocks,
130 }))
131 }
132
133 pub fn set_blocks(self, state: &mut CourseState, blocks: &[GutenbergBlock]) {
136 let value = serde_json::to_value(blocks).unwrap_or(Value::Array(Vec::new()));
137 match self {
138 InstructionsList::Task {
139 exercise_id,
140 task_id,
141 } => {
142 if let Some(task) = state
143 .exercise_mut(exercise_id)
144 .and_then(|exercise| exercise.tasks.iter_mut().find(|task| task.id == task_id))
145 {
146 task.assignment = Arc::new(value);
147 }
148 }
149 InstructionsList::Review { exercise_id } => {
150 if let Some(ExerciseReviewConfig::Own {
151 review_instructions,
152 ..
153 }) = state
154 .exercise_mut(exercise_id)
155 .map(|exercise| &mut exercise.review_config)
156 {
157 *review_instructions = review_instructions_for_storage(value).map(Arc::new);
158 }
159 }
160 InstructionsList::CourseDefaultReview { config_id } => {
161 if let Some(config) = state
162 .course_default_review_config
163 .as_mut()
164 .filter(|config| config.id == config_id)
165 {
166 config.review_instructions = review_instructions_for_storage(value);
167 }
168 }
169 }
170 }
171}
172
173static MARKUP: LazyLock<Regex> = LazyLock::new(|| {
174 Regex::new(r"<[a-zA-Z/!]").expect("a constant pattern the unit tests compile")
175});
176
177pub fn checked_name(name: &str) -> Result<String, EditError> {
179 let trimmed = name.trim();
180 if trimmed.is_empty() || trimmed.contains(['\n', '\r']) || MARKUP.is_match(trimmed) {
181 return Err(EditError::new(
182 EditErrorCode::InvalidValue,
183 "an exercise name is non-empty plain text on one line, without markup",
184 ));
185 }
186 Ok(trimmed.to_string())
187}
188
189pub fn parse_blocks(stored: Option<&Value>) -> Result<Vec<GutenbergBlock>, EditError> {
191 match stored {
192 None | Some(Value::Null) => Ok(Vec::new()),
193 Some(value) => Vec::<GutenbergBlock>::deserialize(value).map_err(|e| {
194 EditError::new(
195 EditErrorCode::InvalidValue,
196 format!("the stored instructions aren't blocks the server can edit: {e}"),
197 )
198 .with_fix("ask the teacher to edit these instructions in the editor")
199 }),
200 }
201}
202
203pub fn review_list(state: &CourseState, exercise: &ExerciseRecord) -> Option<InstructionsList> {
206 if !exercise.has_reviews() {
207 return None;
208 }
209 match &exercise.review_config {
210 ExerciseReviewConfig::Own { .. } => Some(InstructionsList::Review {
211 exercise_id: exercise.id,
212 }),
213 ExerciseReviewConfig::CourseDefault => {
214 state.course_default_review_config.as_ref().map(|config| {
215 InstructionsList::CourseDefaultReview {
216 config_id: config.id,
217 }
218 })
219 }
220 ExerciseReviewConfig::Unset => None,
221 }
222}
223
224pub fn changed_lists(before: &CourseState, after: &CourseState) -> Vec<InstructionsList> {
227 let mut lists = Vec::new();
228 for exercise_id in after.exercises_changed_since(before) {
229 let (Some(exercise), Some(before_exercise)) =
230 (after.exercise(exercise_id), before.exercise(exercise_id))
231 else {
232 continue;
233 };
234 for task in &exercise.tasks {
235 let before_task = before_exercise.tasks.iter().find(|t| t.id == task.id);
236 if before_task.is_none_or(|before_task| before_task.assignment != task.assignment) {
237 lists.push(InstructionsList::Task {
238 exercise_id,
239 task_id: task.id,
240 });
241 }
242 }
243 if let ExerciseReviewConfig::Own {
244 review_instructions,
245 ..
246 } = &exercise.review_config
247 {
248 let before_instructions = match &before_exercise.review_config {
249 ExerciseReviewConfig::Own {
250 review_instructions,
251 ..
252 } => Some(review_instructions),
253 _ => None,
254 };
255 if before_instructions != Some(review_instructions) {
256 lists.push(InstructionsList::Review { exercise_id });
257 }
258 }
259 }
260 if let Some(config) = &after.course_default_review_config
261 && before.course_default_review_config.as_ref() != Some(config)
262 {
263 lists.push(InstructionsList::CourseDefaultReview {
264 config_id: config.id,
265 });
266 }
267 lists
268}
269
270pub fn lists_of_pages(
274 state: &CourseState,
275 page_ids: &[Uuid],
276 with_course_default: bool,
277) -> Vec<InstructionsList> {
278 let mut lists = Vec::new();
279 let mut uses_course_default = false;
280 for page_id in page_ids {
281 let Some(page) = state.page(*page_id) else {
282 continue;
283 };
284 for exercise_id in page.content.iter().filter_map(|b| b.exercise_id()) {
285 let Some(exercise) = state.exercise(exercise_id) else {
286 continue;
287 };
288 lists.extend(exercise.tasks.iter().map(|task| InstructionsList::Task {
289 exercise_id,
290 task_id: task.id,
291 }));
292 match review_list(state, exercise) {
293 Some(InstructionsList::CourseDefaultReview { .. }) => uses_course_default = true,
294 Some(list) => lists.push(list),
295 None => {}
296 }
297 }
298 }
299 if (with_course_default || uses_course_default)
300 && let Some(config) = &state.course_default_review_config
301 {
302 lists.push(InstructionsList::CourseDefaultReview {
303 config_id: config.id,
304 });
305 }
306 lists
307}
308
309#[derive(Debug, Clone, PartialEq, Serialize)]
311pub struct InstructionsLocation {
312 #[serde(skip_serializing_if = "Option::is_none")]
314 pub exercise: Option<Uuid>,
315 pub instructions: String,
317 #[serde(skip_serializing_if = "std::ops::Not::not")]
320 pub course_default_review_config: bool,
321}
322
323#[derive(Debug, Clone, Copy)]
325pub struct ExerciseTextParts {
326 pub instructions: bool,
327 pub course_default: bool,
330 pub names: bool,
331}
332
333pub fn text_documents(
337 state: &CourseState,
338 page_ids: &[Uuid],
339 parts: ExerciseTextParts,
340) -> Vec<Document> {
341 let mut documents = Vec::new();
342 if parts.instructions {
343 let exercise_pages = state.exercise_page_ids();
344 for list in lists_of_pages(state, page_ids, parts.course_default) {
345 if let Some(Ok(document)) = list.document(state) {
346 let page_id = list
347 .exercise_id()
348 .and_then(|exercise_id| exercise_pages.get(&exercise_id).copied());
349 documents.push(Document {
350 key: DocumentKey::Instructions { list, page_id },
351 page_type: document.page_type,
352 blocks: document.blocks,
353 });
354 }
355 }
356 }
357 if parts.names {
358 for page_id in page_ids {
359 let Some(page) = state.page(*page_id) else {
360 continue;
361 };
362 for exercise_id in page.content.iter().filter_map(|b| b.exercise_id()) {
363 if let Some(exercise) = state.exercise(exercise_id) {
364 documents.push(Document {
365 key: DocumentKey::ExerciseName {
366 exercise_id,
367 page_id: *page_id,
368 },
369 page_type: PageType::CoursePage,
370 blocks: vec![
371 GutenbergBlock::paragraph(&escape_html_text(&exercise.name))
372 .with_id(exercise_id),
373 ],
374 });
375 }
376 }
377 }
378 }
379 documents
380}
381
382pub fn apply_documents(after: &mut CourseState, changed: Vec<Document>) -> Result<(), EditError> {
385 for document in changed {
386 match document.key {
387 DocumentKey::Page(page_id) => {
388 if let Some(page) = after.page_mut(page_id) {
389 page.content = Arc::new(document.blocks);
390 }
391 }
392 DocumentKey::Instructions { list, .. } => list.set_blocks(after, &document.blocks),
393 DocumentKey::ExerciseName { exercise_id, .. } => {
394 let name = document
395 .blocks
396 .first()
397 .and_then(|block| block.attributes.get("content"))
398 .and_then(Value::as_str)
399 .unwrap_or_default();
400 let name =
401 checked_name(&decode_html_entities(name)).map_err(|error| EditError {
402 message: format!("exercise {exercise_id}: {}", error.message),
403 ..error
404 })?;
405 if let Some(exercise) = after.exercise_mut(exercise_id) {
406 exercise.name = name;
407 }
408 }
409 }
410 }
411 Ok(())
412}
413
414#[cfg(test)]
415#[path = "instruction_lists_tests.rs"]
416mod tests;