headless_lms_mcp/engine/course_structure/
paths.rs1use std::collections::{HashMap, HashSet};
4
5use headless_lms_models::chapters;
6use headless_lms_models::pages::normalize_url_path_for_storage;
7use headless_lms_models::url_redirections::CreatedRedirect;
8use headless_lms_utils::strings::slugify;
9use serde::Serialize;
10use uuid::Uuid;
11
12use super::model::{CourseStructure, StructurePage};
13use crate::engine::error::{EditError, EditErrorCode, Warning, WarningCode};
14use crate::engine::{PageRef, blocks};
15
16#[derive(Debug, Clone, PartialEq, Serialize)]
18pub struct LinkToOldPath {
19 pub page_id: Uuid,
20 pub block_id: Uuid,
21 pub old_path: String,
22 pub new_path: String,
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub now_opens_page: Option<Uuid>,
26}
27
28pub(super) fn find_links_to_old_paths(
32 pages: &[PageRef],
33 course_slug: &str,
34 changed: &[CreatedRedirect],
35 structure: &CourseStructure,
36) -> Vec<LinkToOldPath> {
37 if changed.is_empty() {
38 return Vec::new();
39 }
40 let by_old_path: HashMap<&str, &CreatedRedirect> = changed
41 .iter()
42 .map(|change| (change.old_url_path.trim_end_matches('/'), change))
43 .collect();
44 let path_owners: HashMap<&str, Uuid> = structure
45 .pages()
46 .map(|(_, page)| (page.url_path.as_str(), page.id))
47 .collect();
48 let course_prefix = format!("/{course_slug}");
49 let mut found = Vec::new();
50 let mut is_found: HashSet<(Uuid, &str)> = HashSet::new();
51 for page in pages {
52 blocks::walk(page.blocks, &mut |block, _| {
53 blocks::for_each_string(&block.attributes, &mut |text| {
54 for (at, _) in text.match_indices(course_prefix.as_str()) {
55 let after = &text[at + course_prefix.len()..];
56 let path = &after[..after.find(['"', '\'', '#', '?']).unwrap_or(after.len())];
57 if let Some(change) = by_old_path.get(path)
58 && is_found.insert((block.client_id, change.old_url_path.as_str()))
59 {
60 found.push(LinkToOldPath {
61 page_id: page.page_id,
62 block_id: block.client_id,
63 old_path: change.old_url_path.clone(),
64 new_path: change.new_url_path.clone(),
65 now_opens_page: path_owners
66 .get(change.old_url_path.as_str())
67 .filter(|owner| **owner != change.page_id)
68 .copied(),
69 });
70 }
71 }
72 });
73 });
74 }
75 found
76}
77
78#[derive(Debug, Default)]
80pub(super) struct PathRequests {
81 explicit: HashMap<Uuid, String>,
83 copy_segments: HashMap<Uuid, String>,
86}
87
88impl PathRequests {
89 pub(super) fn set_explicit(&mut self, page_id: Uuid, path: String) {
91 self.explicit.insert(page_id, path);
92 }
93
94 pub(super) fn add_copy(&mut self, copy_id: Uuid, source: &StructurePage) {
96 let segment =
97 last_segment(&source.url_path).unwrap_or_else(|| title_segment(&source.title));
98 self.copy_segments
99 .insert(copy_id, format!("{segment}-copy"));
100 }
101
102 fn derived_segment(
105 &self,
106 page: &StructurePage,
107 before_paths: &HashMap<Uuid, String>,
108 ) -> String {
109 before_paths
110 .get(&page.id)
111 .and_then(|old| last_segment(old))
112 .or_else(|| self.copy_segments.get(&page.id).cloned())
113 .unwrap_or_else(|| title_segment(&page.title))
114 }
115}
116
117struct PlannedPath {
119 page_id: Uuid,
120 path: String,
121 is_explicit: bool,
123}
124
125pub(super) fn assign_paths(
129 doc: &mut CourseStructure,
130 requests: &PathRequests,
131 before_paths: &HashMap<Uuid, String>,
132 front_page: Option<Uuid>,
133) -> Result<Vec<Warning>, EditError> {
134 let mut planned: Vec<PlannedPath> = Vec::new();
135 let mut plan = |page_id, path, is_explicit| {
136 planned.push(PlannedPath {
137 page_id,
138 path,
139 is_explicit,
140 })
141 };
142 for page in &doc.pages {
143 if Some(page.id) == front_page {
144 plan(page.id, "/".to_string(), true);
145 continue;
146 }
147 match requests.explicit.get(&page.id) {
148 Some(raw) => plan(page.id, normalize_top_level_path(raw)?, true),
149 None => {
150 let path = match before_paths.get(&page.id) {
151 Some(old) if !old.starts_with("/chapter-") && !old.is_empty() => old.clone(),
152 _ => format!("/{}", requests.derived_segment(page, before_paths)),
153 };
154 plan(page.id, path, false);
155 }
156 }
157 }
158 for chapter in doc.chapters() {
159 let prefix = chapters::front_page_path(chapter.chapter_number);
160 for (index, page) in chapter.pages.iter().enumerate() {
161 if index == 0 {
162 if let Some(raw) = requests.explicit.get(&page.id) {
163 let accepted = raw.trim_end_matches('/') == prefix || raw.trim().is_empty();
164 if !accepted {
165 return Err(EditError::new(
166 EditErrorCode::InvalidUrlPath,
167 format!(
168 "page {} is the first page of chapter {}, so its path is always {prefix}",
169 page.id, chapter.chapter_number
170 ),
171 ));
172 }
173 }
174 plan(page.id, prefix.clone(), true);
175 continue;
176 }
177 let (segment, is_explicit) = match requests.explicit.get(&page.id) {
178 Some(raw) => (chapter_page_segment(raw, &prefix)?, true),
179 None => (requests.derived_segment(page, before_paths), false),
180 };
181 plan(page.id, format!("{prefix}/{segment}"), is_explicit);
182 }
183 }
184
185 let mut taken: HashMap<String, Uuid> = HashMap::new();
186 for PlannedPath {
189 page_id: id,
190 path,
191 is_explicit,
192 } in &planned
193 {
194 let is_kept = before_paths.get(id).is_some_and(|old| old == path);
195 if (*is_explicit || is_kept)
196 && let Some(other) = taken.insert(path.clone(), *id)
197 {
198 return Err(EditError::new(
199 EditErrorCode::DuplicatePath,
200 format!("pages {other} and {id} would both have the path {path}"),
201 )
202 .with_fix("give one of them another url_path"));
203 }
204 }
205 let mut warnings = Vec::new();
206 let mut final_paths: HashMap<Uuid, String> = HashMap::new();
207 for PlannedPath {
208 page_id: id,
209 path,
210 is_explicit,
211 } in planned
212 {
213 if is_explicit || taken.get(&path) == Some(&id) {
214 final_paths.insert(id, path);
215 continue;
216 }
217 let mut candidate = path.clone();
218 let mut suffix = 2;
219 while taken.get(&candidate).is_some_and(|holder| *holder != id) {
220 candidate = format!("{path}-{suffix}");
221 suffix += 1;
222 }
223 if candidate != path && before_paths.get(&id).is_none_or(|old| *old != candidate) {
224 warnings.push(Warning::new(
225 WarningCode::UrlPathAdjusted,
226 format!("page {id} got the path {candidate} because {path} was taken"),
227 ));
228 }
229 taken.insert(candidate.clone(), id);
230 final_paths.insert(id, candidate);
231 }
232 for page in doc.pages_mut() {
233 if let Some(path) = final_paths.remove(&page.id) {
234 page.url_path = path;
235 }
236 }
237 Ok(warnings)
238}
239
240pub(super) fn path_changes(
242 doc: &CourseStructure,
243 before_paths: &HashMap<Uuid, String>,
244) -> Vec<CreatedRedirect> {
245 doc.pages()
246 .filter_map(|(_, page)| {
247 let old = before_paths.get(&page.id)?;
248 (*old != page.url_path).then(|| CreatedRedirect {
249 page_id: page.id,
250 old_url_path: old.clone(),
251 new_url_path: page.url_path.clone(),
252 })
253 })
254 .collect()
255}
256
257fn is_chapter_segment(segment: &str) -> bool {
258 segment
259 .strip_prefix("chapter-")
260 .is_some_and(|n| n.parse::<u32>().is_ok())
261}
262
263fn last_segment(path: &str) -> Option<String> {
264 path.rsplit('/')
265 .next()
266 .filter(|s| !s.is_empty() && !is_chapter_segment(s))
267 .map(str::to_string)
268}
269
270fn title_segment(title: &str) -> String {
272 let slug = slugify(title);
273 if slug.is_empty() {
274 "page".to_string()
275 } else {
276 slug
277 }
278}
279
280fn normalize_segment(raw: &str) -> Result<String, EditError> {
283 let segment = normalize_url_path_for_storage(raw.trim());
284 if segment.is_empty() || segment == "." || segment == ".." || segment.contains('/') {
286 return Err(EditError::new(
287 EditErrorCode::InvalidUrlPath,
288 format!("\"{raw}\" isn't a valid path segment; use letters, digits and dashes"),
289 )
290 .with_fix(title_segment(raw)));
291 }
292 Ok(segment)
293}
294
295fn normalize_top_level_path(raw: &str) -> Result<String, EditError> {
296 let trimmed = raw.trim().trim_end_matches('/');
297 let Some(rest) = trimmed.strip_prefix('/') else {
298 return Err(EditError::new(
299 EditErrorCode::InvalidUrlPath,
300 format!("\"{raw}\" must start with /"),
301 )
302 .with_fix(format!("/{}", raw.trim())));
303 };
304 let segments = rest
305 .split('/')
306 .map(normalize_segment)
307 .collect::<Result<Vec<_>, _>>()?;
308 if segments
309 .first()
310 .is_some_and(|first| is_chapter_segment(first))
311 {
312 return Err(EditError::new(
313 EditErrorCode::InvalidUrlPath,
314 "paths starting with /chapter-N belong to chapter pages",
315 ));
316 }
317 Ok(format!("/{}", segments.join("/")))
318}
319
320fn chapter_page_segment(raw: &str, prefix: &str) -> Result<String, EditError> {
322 let trimmed = raw.trim().trim_end_matches('/');
323 let candidate = match trimmed.strip_prefix('/') {
324 None => trimmed,
325 Some(rest) => match rest.split_once('/') {
326 Some((first, last)) if is_chapter_segment(first) && !last.contains('/') => last,
327 _ => {
328 let segment = title_segment(trimmed);
329 return Err(EditError::new(
330 EditErrorCode::InvalidUrlPath,
331 format!("chapter pages live under {prefix}/; \"{raw}\" doesn't fit"),
332 )
333 .with_fix(format!(
334 "give only the last part, e.g. \"{segment}\", which becomes {prefix}/{segment}"
335 )));
336 }
337 },
338 };
339 normalize_segment(candidate)
340}