headless_lms_models/library/
migration.rs1use 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
17pub 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
81fn extract_title_from_blocks(blocks: &[GutenbergBlock]) -> Option<String> {
83 fn extract_from_block(block: &GutenbergBlock) -> Option<String> {
84 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 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
122fn 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
143async 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}