Skip to main content

headless_lms_models/library/
migration.rs

1use futures::future::BoxFuture;
2use headless_lms_utils::document_schema_processor::{
3    GutenbergBlock, contains_blocks_not_allowed_in_top_level_pages,
4};
5use headless_lms_utils::strings::strip_html_tags;
6use url::Url;
7
8use std::collections::HashSet;
9
10use crate::{
11    SpecFetcher,
12    exercise_service_info::ExerciseServiceInfoApi,
13    pages::{CmsPageUpdate, NewPage, PageVisibility, normalize_url_path_for_storage},
14    prelude::*,
15};
16
17/// Creates a new course page from a CMS update payload and returns its id.
18///
19/// When `title` or `url_path` are empty they are derived from the page content
20/// (the first hero-section or heading block, slugified respectively).
21pub async fn create_page(
22    conn: &mut PgConnection,
23    course_id: Uuid,
24    mut cms_update: CmsPageUpdate,
25    author: Uuid,
26    spec_fetcher: impl SpecFetcher,
27    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
28) -> ModelResult<Uuid> {
29    if cms_update.title.trim().is_empty() {
30        cms_update.title = extract_title_from_blocks(&cms_update.content)
31            .unwrap_or_else(|| "Untitled Page".to_string());
32    }
33    if cms_update.url_path.trim().is_empty() {
34        let mut slug = slugify(&cms_update.title);
35        if slug.is_empty() {
36            slug = "untitled-page".to_string();
37        }
38        cms_update.url_path =
39            ensure_unique_url_path(conn, course_id, &format!("/{}", slug)).await?;
40    }
41
42    cms_update.validate_exercise_data()?;
43
44    if cms_update.chapter_id.is_none()
45        && contains_blocks_not_allowed_in_top_level_pages(&cms_update.content)
46    {
47        return Err(model_err!(
48            Generic,
49            "Top level pages cannot contain exercises, exercise tasks or a list of exercises in the chapter".to_string()
50        ));
51    }
52
53    let new_page = NewPage {
54        exercises: cms_update.exercises,
55        exercise_slides: cms_update.exercise_slides,
56        exercise_tasks: cms_update.exercise_tasks,
57        content: cms_update.content,
58        url_path: cms_update.url_path,
59        title: cms_update.title,
60        course_id: Some(course_id),
61        exam_id: None,
62        chapter_id: cms_update.chapter_id,
63        front_page_of_chapter_id: None,
64        content_search_language: None,
65        hidden: cms_update.hidden,
66    };
67
68    let created = crate::pages::create_for_course_id(
69        conn,
70        course_id,
71        new_page,
72        author,
73        spec_fetcher,
74        fetch_service_info,
75    )
76    .await?;
77
78    Ok(created.id)
79}
80
81/// Extract title from Gutenberg blocks (looks for hero-section or first heading)
82fn extract_title_from_blocks(blocks: &[GutenbergBlock]) -> Option<String> {
83    fn extract_from_block(block: &GutenbergBlock) -> Option<String> {
84        // Blank candidates fall through (not returned) so the search continues to the
85        // next block; if nothing usable is found the caller defaults to "Untitled Page".
86        if block.name == "moocfi/hero-section" {
87            let attrs = &block.attributes;
88            if let Some(title) = attrs.get("title").and_then(|v| v.as_str()) {
89                let title = title.trim_matches('\'').trim_matches('"').trim();
90                if !title.is_empty() {
91                    return Some(title.to_string());
92                }
93            }
94        }
95        if block.name.starts_with("core/heading") {
96            let attrs = &block.attributes;
97            if let Some(content) = attrs.get("content").and_then(|v| v.as_str()) {
98                let clean = strip_html_tags(content);
99                let clean = clean.trim();
100                if !clean.is_empty() {
101                    return Some(clean.to_string());
102                }
103            }
104        }
105        // Recursively check inner blocks
106        for inner in &block.inner_blocks {
107            if let Some(title) = extract_from_block(inner) {
108                return Some(title);
109            }
110        }
111        None
112    }
113
114    for block in blocks {
115        if let Some(title) = extract_from_block(block) {
116            return Some(title);
117        }
118    }
119    None
120}
121
122/// Generate a URL-safe slug from a title.
123///
124/// Lowercases, keeps alphanumeric characters, and collapses every run of other characters
125/// (whitespace and punctuation alike) into a single `-`, with no leading or trailing separator.
126fn slugify(text: &str) -> String {
127    let mut slug = String::with_capacity(text.len());
128    let mut pending_separator = false;
129    for c in text.to_lowercase().chars() {
130        if c.is_alphanumeric() {
131            if pending_separator && !slug.is_empty() {
132                slug.push('-');
133            }
134            pending_separator = false;
135            slug.push(c);
136        } else {
137            pending_separator = true;
138        }
139    }
140    slug
141}
142
143/// Appends a numeric suffix to `base_path` until it no longer collides with an existing
144/// (non-deleted) page in the course, so deriving slugs from duplicate or empty titles cannot
145/// hit the unique `(course_id, url_path)` constraint and abort the migration.
146async fn ensure_unique_url_path(
147    conn: &mut PgConnection,
148    course_id: Uuid,
149    base_path: &str,
150) -> ModelResult<String> {
151    let existing: HashSet<String> =
152        crate::pages::get_all_by_course_id_and_visibility(conn, course_id, PageVisibility::Any)
153            .await?
154            .into_iter()
155            .map(|p| p.url_path)
156            .collect();
157
158    let mut candidate = base_path.to_string();
159    let mut counter = 2;
160    while existing.contains(&normalize_url_path_for_storage(&candidate)) {
161        candidate = format!("{}-{}", base_path, counter);
162        counter += 1;
163    }
164    Ok(candidate)
165}