Skip to main content

headless_lms_mcp/
snapshots.rs

1//! Conversions between the stored entity snapshots and the documents the edit engines work on.
2
3use std::collections::{HashMap, HashSet};
4use std::sync::Arc;
5
6use headless_lms_models::{
7    course_modules::{AutomaticCompletionRequirements, CompletionPolicy as ModelCompletionPolicy},
8    courses::{Course, CourseAiPolicy},
9    library::agent_changesets::course_state::{CourseState, PageRecord},
10    library::entity_snapshots::{
11        ChapterSnapshot, CourseAudienceSnapshot, GlossaryTermSnapshot, ModuleSnapshot,
12        ReferenceSnapshot,
13    },
14};
15use headless_lms_utils::document_schema_processor::GutenbergBlock;
16use uuid::Uuid;
17
18use crate::engine::blocks;
19use crate::engine::course_settings::{
20    AiPolicy, Audience, CompletionPolicy, CompletionPolicyKind, CourseSettings,
21    CourseSettingsCourse, ModuleSettings,
22};
23use crate::engine::course_structure::{
24    CourseStructure, StructureChapter, StructureModule, StructurePage,
25};
26use crate::engine::glossary_references::{GlossaryAndReferences, GlossaryTerm, Reference};
27use crate::engine::{PageDocument, PageRef, PageType};
28
29fn structure_page(page: &PageRecord, facts: &PageFacts) -> StructurePage {
30    let (block_count, exercise_count) = blocks::content_counts(&page.content);
31    StructurePage {
32        id: page.id,
33        title: page.title.clone(),
34        url_path: page.url_path.clone(),
35        hidden: page.hidden,
36        block_count,
37        exercise_count,
38        has_submissions: facts.pages_with_submissions.contains(&page.id),
39        page_language_group_id: facts
40            .page_language_groups
41            .get(&page.id)
42            .copied()
43            .or(page.joins_page_language_group_id),
44    }
45}
46
47/// Live facts about pages that the course tree shows.
48pub struct PageFacts {
49    pub pages_with_submissions: HashSet<Uuid>,
50    /// By page id; pages not published yet have none.
51    pub page_language_groups: HashMap<Uuid, Uuid>,
52}
53
54/// The course tree the structure engine edits, with the live facts of its pages.
55pub fn structure(state: &CourseState, facts: &PageFacts) -> CourseStructure {
56    let modules = state
57        .modules_in_order()
58        .into_iter()
59        .map(|module| StructureModule {
60            id: module.id,
61            default: module.name.is_none(),
62            name: module.name.clone(),
63            chapters: state
64                .chapters_in_order(module.id)
65                .into_iter()
66                .map(|chapter| StructureChapter {
67                    id: chapter.id,
68                    chapter_number: chapter.chapter_number,
69                    name: chapter.name.clone(),
70                    color: chapter.color.clone(),
71                    opens_at: chapter.opens_at,
72                    deadline: chapter.deadline,
73                    pages: state
74                        .pages_in_order(Some(chapter.id))
75                        .into_iter()
76                        .map(|p| structure_page(p, facts))
77                        .collect(),
78                })
79                .collect(),
80        })
81        .collect();
82    CourseStructure {
83        id: state.course_id(),
84        pages: state
85            .pages_in_order(None)
86            .into_iter()
87            .map(|p| structure_page(p, facts))
88            .collect(),
89        modules,
90    }
91}
92
93/// The editor context the page's blocks are checked in.
94pub fn page_type(page: &PageRecord) -> PageType {
95    PageType::of(page.chapter_id.is_some(), &page.url_path)
96}
97
98/// The page with its editor context, or `None` if the course has no such page.
99pub fn page_document(state: &CourseState, page_id: Uuid) -> Option<PageDocument> {
100    state.page(page_id).map(|page| page_ref(page).to_document())
101}
102
103/// The page with its editor context, borrowed.
104pub fn page_ref(page: &PageRecord) -> PageRef<'_> {
105    PageRef {
106        page_id: page.id,
107        page_type: page_type(page),
108        blocks: &page.content,
109    }
110}
111
112/// Every page of the course with its editor context, borrowed.
113pub fn page_refs(state: &CourseState) -> Vec<PageRef<'_>> {
114    state.pages.iter().map(|page| page_ref(page)).collect()
115}
116
117/// Sets the blocks of each page in `documents`.
118pub fn apply_page_documents(
119    state: &mut CourseState,
120    documents: impl IntoIterator<Item = PageDocument>,
121) {
122    for document in documents {
123        if let Some(page) = state.page_mut(document.page_id) {
124            page.content = Arc::new(document.blocks);
125        }
126    }
127}
128
129fn page_lists(structure: &CourseStructure) -> Vec<(Option<Uuid>, &[StructurePage])> {
130    std::iter::once((None, structure.pages.as_slice()))
131        .chain(
132            structure
133                .chapters()
134                .map(|c| (Some(c.id), c.pages.as_slice())),
135        )
136        .collect()
137}
138
139/// The state after the structure engine turned `before` into `after`. `contents` holds the blocks
140/// of new pages. Order numbers change only in lists whose order changed.
141pub fn apply_structure(
142    state: &CourseState,
143    before: &CourseStructure,
144    after: &CourseStructure,
145    contents: &HashMap<Uuid, Vec<GutenbergBlock>>,
146) -> CourseState {
147    let mut next = state.clone();
148
149    let modules_before: Vec<Uuid> = before.modules().map(|m| m.id).collect();
150    let mut modules: Vec<ModuleSnapshot> = state
151        .modules
152        .iter()
153        .filter(|m| !modules_before.contains(&m.id))
154        .cloned()
155        .collect();
156    let modules_by_id: HashMap<Uuid, &ModuleSnapshot> =
157        state.modules.iter().map(|m| (m.id, m)).collect();
158    let module_order_numbers = list_order_numbers(
159        Some(&modules_before),
160        &after
161            .modules()
162            .map(|m| {
163                (
164                    m.id,
165                    modules_by_id.get(&m.id).map(|module| module.order_number),
166                )
167            })
168            .collect::<Vec<_>>(),
169    );
170    for (structure_module, order_number) in after.modules().zip(module_order_numbers) {
171        let mut module = modules_by_id
172            .get(&structure_module.id)
173            .copied()
174            .cloned()
175            .unwrap_or_else(|| ModuleSnapshot::new(structure_module.id, state.course_id()));
176        module.name = structure_module.name.clone();
177        if let Some(order_number) = order_number {
178            module.order_number = order_number;
179        }
180        modules.push(module);
181    }
182    next.modules = modules;
183
184    let chapters_before: HashSet<Uuid> = before.chapters().map(|c| c.id).collect();
185    let mut chapters: Vec<ChapterSnapshot> = state
186        .chapters
187        .iter()
188        .filter(|c| !chapters_before.contains(&c.id))
189        .cloned()
190        .collect();
191    let chapters_by_id: HashMap<Uuid, &ChapterSnapshot> =
192        state.chapters.iter().map(|c| (c.id, c)).collect();
193    for structure_module in after.modules() {
194        for structure_chapter in &structure_module.chapters {
195            let mut chapter = chapters_by_id
196                .get(&structure_chapter.id)
197                .copied()
198                .cloned()
199                .unwrap_or_else(|| {
200                    ChapterSnapshot::new(
201                        structure_chapter.id,
202                        state.course_id(),
203                        structure_module.id,
204                    )
205                });
206            chapter.course_module_id = structure_module.id;
207            chapter.name = structure_chapter.name.clone();
208            chapter.color = structure_chapter.color.clone();
209            chapter.opens_at = structure_chapter.opens_at;
210            chapter.deadline = structure_chapter.deadline;
211            chapter.chapter_number = structure_chapter.chapter_number;
212            chapter.front_page_id = structure_chapter.pages.first().map(|p| p.id);
213            chapters.push(chapter);
214        }
215    }
216    next.chapters = chapters;
217
218    let lists_before: HashMap<Option<Uuid>, Vec<Uuid>> = page_lists(before)
219        .into_iter()
220        .map(|(chapter, pages)| (chapter, pages.iter().map(|p| p.id).collect()))
221        .collect();
222    let pages_before: HashSet<Uuid> = lists_before.values().flatten().copied().collect();
223    let mut pages: Vec<PageRecord> = state
224        .pages
225        .iter()
226        .filter(|p| !pages_before.contains(&p.id))
227        .cloned()
228        .collect();
229    let pages_by_id: HashMap<Uuid, &PageRecord> = state.pages.iter().map(|p| (p.id, p)).collect();
230    for (chapter_id, list) in page_lists(after) {
231        let order_numbers = list_order_numbers(
232            lists_before.get(&chapter_id).map(Vec::as_slice),
233            &list
234                .iter()
235                .map(|p| (p.id, pages_by_id.get(&p.id).map(|page| page.order_number)))
236                .collect::<Vec<_>>(),
237        );
238        for (structure_page, new_order_number) in list.iter().zip(order_numbers) {
239            let mut page = pages_by_id
240                .get(&structure_page.id)
241                .copied()
242                .cloned()
243                .unwrap_or_else(|| PageRecord {
244                    id: structure_page.id,
245                    chapter_id,
246                    url_path: String::new(),
247                    title: String::new(),
248                    hidden: false,
249                    order_number: new_order_number.unwrap_or_default(),
250                    content: Arc::new(
251                        contents
252                            .get(&structure_page.id)
253                            .cloned()
254                            .unwrap_or_default(),
255                    ),
256                    joins_page_language_group_id: None,
257                });
258            page.chapter_id = chapter_id;
259            page.title = structure_page.title.clone();
260            page.url_path = structure_page.url_path.clone();
261            page.hidden = structure_page.hidden;
262            page.order_number = new_order_number.unwrap_or(page.order_number);
263            pages.push(page);
264        }
265    }
266    next.pages = pages;
267    next
268}
269
270/// New order numbers for the list `after` (entries with their current number, `None` if new), or
271/// all `None` (keep every number) when the order equals `before_ids`. A list with no `before_ids`
272/// is renumbered.
273fn list_order_numbers(
274    before_ids: Option<&[Uuid]>,
275    after: &[(Uuid, Option<i32>)],
276) -> Vec<Option<i32>> {
277    let is_reordered =
278        before_ids.is_none_or(|before| !before.iter().copied().eq(after.iter().map(|(id, _)| *id)));
279    if is_reordered {
280        increasing_order_numbers(after.iter().map(|(_, own)| *own))
281            .into_iter()
282            .map(Some)
283            .collect()
284    } else {
285        vec![None; after.len()]
286    }
287}
288
289/// Order numbers for a list in its new order, given each entry's current number (`None` if new).
290/// The first gets 0, reserved for front pages and the default module; the others keep theirs while
291/// strictly increasing, so only added, moved and pushed entries are renumbered.
292fn increasing_order_numbers(current: impl Iterator<Item = Option<i32>>) -> Vec<i32> {
293    let mut previous: Option<i32> = None;
294    current
295        .map(|own| {
296            let next = match (own, previous) {
297                (Some(own), Some(previous)) if own > previous => own,
298                (_, Some(previous)) => previous + 1,
299                (_, None) => 0,
300            };
301            previous = Some(next);
302            next
303        })
304        .collect()
305}
306
307impl From<CourseAiPolicy> for AiPolicy {
308    fn from(policy: CourseAiPolicy) -> Self {
309        match policy {
310            CourseAiPolicy::NotSet => AiPolicy::NotSet,
311            CourseAiPolicy::NoAi => AiPolicy::NoAi,
312            CourseAiPolicy::PlanningOnly => AiPolicy::PlanningOnly,
313            CourseAiPolicy::Limited => AiPolicy::Limited,
314            CourseAiPolicy::FullUse => AiPolicy::FullUse,
315            CourseAiPolicy::Required => AiPolicy::Required,
316        }
317    }
318}
319
320impl From<AiPolicy> for CourseAiPolicy {
321    fn from(policy: AiPolicy) -> Self {
322        match policy {
323            AiPolicy::NotSet => CourseAiPolicy::NotSet,
324            AiPolicy::NoAi => CourseAiPolicy::NoAi,
325            AiPolicy::PlanningOnly => CourseAiPolicy::PlanningOnly,
326            AiPolicy::Limited => CourseAiPolicy::Limited,
327            AiPolicy::FullUse => CourseAiPolicy::FullUse,
328            AiPolicy::Required => CourseAiPolicy::Required,
329        }
330    }
331}
332
333fn completion_policy(policy: &ModelCompletionPolicy) -> CompletionPolicy {
334    match policy {
335        ModelCompletionPolicy::Automatic(requirements) => CompletionPolicy {
336            policy: CompletionPolicyKind::Automatic,
337            number_of_points_treshold: requirements.number_of_points_treshold,
338            number_of_exercises_attempted_treshold: requirements
339                .number_of_exercises_attempted_treshold,
340            requires_exam: requirements.requires_exam,
341        },
342        ModelCompletionPolicy::Manual => CompletionPolicy::manual(),
343    }
344}
345
346fn model_completion_policy(module_id: Uuid, policy: &CompletionPolicy) -> ModelCompletionPolicy {
347    match policy.policy {
348        CompletionPolicyKind::Automatic => {
349            ModelCompletionPolicy::Automatic(AutomaticCompletionRequirements {
350                course_module_id: module_id,
351                number_of_exercises_attempted_treshold: policy
352                    .number_of_exercises_attempted_treshold,
353                number_of_points_treshold: policy.number_of_points_treshold,
354                requires_exam: policy.requires_exam,
355            })
356        }
357        CompletionPolicyKind::Manual => ModelCompletionPolicy::Manual,
358    }
359}
360
361/// The settings document of the settings tool: the course's own settings and its modules'.
362pub fn settings_document(state: &CourseState, course: &Course) -> CourseSettings {
363    let settings = &state.settings;
364    CourseSettings {
365        course: CourseSettingsCourse {
366            id: state.course_id(),
367            slug: course.slug.clone(),
368            language_code: course.language_code.clone(),
369            name: settings.name.clone(),
370            description: settings.description.clone(),
371            is_draft: settings.is_draft,
372            is_test_mode: settings.is_test_mode,
373            is_unlisted: settings.is_unlisted,
374            chapter_locking_enabled: settings.chapter_locking_enabled,
375            base_module_completion_requires_n_submodule_completions: settings
376                .base_module_completion_requires_n_submodule_completions,
377            ai_policy: settings.ai_policy.into(),
378            audiences: settings
379                .audiences
380                .iter()
381                .map(|a| Audience {
382                    id: a.id,
383                    name: a.audience.clone(),
384                })
385                .collect(),
386            closed_at: settings.closed_at,
387        },
388        modules: state
389            .modules_in_order()
390            .into_iter()
391            .map(|m| ModuleSettings {
392                id: m.id,
393                name: m.name.clone(),
394                completion_policy: completion_policy(&m.completion_policy),
395                certification_enabled: m.certification_enabled,
396            })
397            .collect(),
398    }
399}
400
401/// Writes the editable fields of the settings document back. Read-only fields are left alone.
402pub fn apply_settings(state: &mut CourseState, document: &CourseSettings) {
403    let course = &document.course;
404    let settings = &mut state.settings;
405    settings.name = course.name.clone();
406    settings.description = course.description.clone();
407    settings.is_unlisted = course.is_unlisted;
408    settings.chapter_locking_enabled = course.chapter_locking_enabled;
409    settings.base_module_completion_requires_n_submodule_completions =
410        course.base_module_completion_requires_n_submodule_completions;
411    settings.ai_policy = course.ai_policy.into();
412    settings.audiences = course
413        .audiences
414        .iter()
415        .map(|a| CourseAudienceSnapshot {
416            id: a.id,
417            audience: a.name.clone(),
418        })
419        .collect();
420    settings.audiences.sort_by_key(|a| a.id);
421    for module_settings in &document.modules {
422        if let Some(module) = state
423            .modules
424            .iter_mut()
425            .find(|m| m.id == module_settings.id)
426        {
427            module.completion_policy =
428                model_completion_policy(module.id, &module_settings.completion_policy);
429            module.certification_enabled = module_settings.certification_enabled;
430        }
431    }
432}
433
434/// The glossary and references document of the glossary tool.
435pub fn glossary_document(state: &CourseState) -> GlossaryAndReferences {
436    let mut glossary: Vec<GlossaryTerm> = state
437        .glossary
438        .iter()
439        .map(|t| GlossaryTerm {
440            id: t.id,
441            term: t.term.clone(),
442            definition: t.definition.clone(),
443        })
444        .collect();
445    glossary.sort_by_key(|t| t.term.to_lowercase());
446    let mut references: Vec<Reference> = state
447        .references
448        .iter()
449        .map(|r| Reference {
450            id: r.id,
451            citation_key: r.citation_key.clone(),
452            bibtex: r.reference.clone(),
453        })
454        .collect();
455    references.sort_by(|a, b| a.citation_key.cmp(&b.citation_key));
456    GlossaryAndReferences {
457        glossary,
458        references,
459    }
460}
461
462/// Replaces the glossary terms and references with the document's.
463pub fn apply_glossary(state: &mut CourseState, document: &GlossaryAndReferences) {
464    state.glossary = document
465        .glossary
466        .iter()
467        .map(|t| GlossaryTermSnapshot {
468            id: t.id,
469            course_id: state.course_id(),
470            term: t.term.clone(),
471            definition: t.definition.clone(),
472        })
473        .collect();
474    state.references = document
475        .references
476        .iter()
477        .map(|r| ReferenceSnapshot {
478            id: r.id,
479            course_id: state.course_id(),
480            citation_key: r.citation_key.clone(),
481            reference: r.bibtex.clone(),
482        })
483        .collect();
484}
485
486#[cfg(test)]
487#[path = "snapshots_tests.rs"]
488mod tests;