1use std::collections::{BTreeMap, HashMap, HashSet};
4
5use async_trait::async_trait;
6use serde::Serialize;
7use serde_json::Value;
8use sqlx::PgConnection;
9use uuid::Uuid;
10
11use crate::access::{
12 connection, load_page_facts, open_course, page_in_course, staged_page_document,
13};
14use crate::engine::blocks::ExerciseCopy;
15use crate::engine::blocks::{copy_blocks, walk};
16use crate::engine::course_structure::{self, ChapterRenumber, LinkToOldPath};
17use crate::engine::glossary_references::Usage;
18use crate::engine::page_content::BlockPageMove;
19use crate::engine::read::{ReadOptions, ReadResult, Selector, read_page, read_structure};
20use crate::engine::{
21 CreatedIds, Document, DocumentKey, DocumentRef, EditError, EditErrorCode, PatchTarget, Warning,
22 course_settings, glossary_references, page_content, parse_ops,
23};
24use crate::hints;
25use crate::ids::RandomIds;
26use crate::instruction_lists::{self, ExerciseTextParts};
27use crate::page_readiness::PAGE_TITLE_BLOCKS;
28use crate::snapshots;
29use crate::staging::{self, WriteMode, WriteRequest, WriteSource};
30use crate::state::McpState;
31use crate::tools::args::{PatchCourseArgs, PatchCourseStructureArgs, PatchPageContentArgs};
32use crate::tools::{RequestContext, ToolError, ToolHandler, ToolOutcome};
33use headless_lms_models::library::agent_changesets::course_state::{CourseState, CourseView};
34use headless_lms_models::library::content_management::ChapterFrontPage;
35use headless_lms_models::page_language_groups::{self, PairingRefusal};
36use headless_lms_models::url_redirections::CreatedRedirect;
37use headless_lms_utils::document_schema_processor::GutenbergBlock;
38
39pub struct PatchPageContent;
41pub struct PatchCourseStructure;
43pub struct PatchCourseSettings;
45pub struct PatchGlossaryAndReferences;
47
48fn pairing_refused(page_id: Uuid, message: String) -> ToolError {
49 ToolError::Edit(
50 EditError::new(EditErrorCode::InvalidValue, format!("pair_with of new page {page_id}: {message}"))
51 .with_fix("pair_with takes the id of the published page this page translates, in another language version of the course (list_courses language_versions, get_course_structure page ids)"),
52 )
53}
54
55async fn pair_pages(
60 conn: &mut PgConnection,
61 view: &CourseView,
62 pairings: &BTreeMap<Uuid, Uuid>,
63 page_language_groups: &HashMap<Uuid, Uuid>,
64 after: &mut CourseState,
65) -> Result<(), ToolError> {
66 let mut paired_groups: HashSet<Uuid> = page_language_groups
67 .iter()
68 .filter(|(page, _)| after.page(**page).is_some())
69 .map(|(_, group)| *group)
70 .chain(
71 after
72 .pages
73 .iter()
74 .filter_map(|page| page.joins_page_language_group_id),
75 )
76 .collect();
77 for (&page_id, &source_id) in pairings {
78 if view.staged.page(source_id).is_some() || view.live.page(source_id).is_some() {
79 return Err(pairing_refused(
80 page_id,
81 format!("page {source_id} is in this course, not in another language version"),
82 ));
83 }
84 let group = page_language_groups::resolve_pairing(
85 conn,
86 source_id,
87 view.course.course_language_group_id,
88 )
89 .await?
90 .map_err(|refusal| {
91 pairing_refused(
92 page_id,
93 match refusal {
94 PairingRefusal::NotPublished => format!("no published page {source_id}; if it is a new page staged in its course, publish the source page first"),
95 PairingRefusal::NotInCourseLanguageGroup => format!("page {source_id} isn't in a language version of this course (a course in its language group)"),
96 },
97 )
98 })?;
99 if paired_groups.contains(&group) {
100 return Err(pairing_refused(
101 page_id,
102 format!("this course already has its version of page {source_id}"),
103 ));
104 }
105 if let Some(page) = after.page_mut(page_id) {
106 page.joins_page_language_group_id = Some(group);
107 paired_groups.insert(group);
108 }
109 }
110 Ok(())
111}
112
113fn check_read_back(selector: &Option<Selector>) -> Result<(), ToolError> {
115 if selector.as_ref().is_some_and(|s| s.state.is_some()) {
116 return Err(ToolError::Edit(
117 EditError::new(
118 EditErrorCode::FieldNotAllowed,
119 "read_back always reads the result of this write; it takes no state",
120 )
121 .with_fix("drop state from read_back"),
122 ));
123 }
124 Ok(())
125}
126
127fn read_back_options(ctx: &RequestContext) -> ReadOptions<'static> {
128 ReadOptions {
129 budget_bytes: ctx.client.read_back_budget_bytes(),
130 pending: None,
131 }
132}
133
134fn resolve_read_back(
135 selector: &Selector,
136 labels: &BTreeMap<String, Uuid>,
137) -> Result<Selector, ToolError> {
138 let mut selector = selector.clone();
139 if let Some(path) = &selector.path {
140 selector.path = Some(page_content::resolve_label_path(path, labels)?);
141 }
142 if let Some(through) = &selector.through {
143 selector.through = Some(page_content::resolve_label_path(through, labels)?);
144 }
145 Ok(selector)
146}
147
148#[derive(Serialize)]
151enum ReadBack {
152 #[serde(rename = "read_back")]
153 Read(Box<ReadResult>),
154 #[serde(rename = "read_back_error")]
155 Failed(Value),
156}
157
158impl From<Result<ReadResult, ToolError>> for ReadBack {
159 fn from(read: Result<ReadResult, ToolError>) -> Self {
160 match read {
161 Ok(read) => ReadBack::Read(Box::new(read)),
162 Err(error) => ReadBack::Failed(error.into_json()),
163 }
164 }
165}
166
167#[derive(Serialize)]
168struct PageContentSummary<'a> {
169 page: Uuid,
170 created: &'a [CreatedIds],
171 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
172 labels: &'a BTreeMap<String, Uuid>,
173 #[serde(skip_serializing_if = "<[_]>::is_empty")]
174 warnings: &'a [Warning],
175 #[serde(skip_serializing_if = "<[_]>::is_empty")]
176 moved_between_pages: &'a [BlockPageMove],
177 #[serde(skip_serializing_if = "<[_]>::is_empty")]
178 exercise_copies: &'a [ExerciseCopy],
179 #[serde(flatten)]
180 read_back: Option<ReadBack>,
181}
182
183#[derive(Serialize)]
185struct CreatedStructure<'a> {
186 #[serde(flatten)]
187 ids: &'a CreatedIds,
188 #[serde(skip_serializing_if = "Vec::is_empty")]
189 template_blocks: Vec<TemplateBlock>,
190}
191
192#[derive(Serialize)]
195struct TemplateBlock {
196 page: Uuid,
197 block: Uuid,
198 block_name: String,
199}
200
201#[derive(Serialize)]
202struct StructureSummary<'a> {
203 #[serde(skip_serializing_if = "Option::is_none")]
204 outline: Option<&'a course_structure::StructureOutline>,
205 created: Vec<CreatedStructure<'a>>,
206 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
207 labels: &'a BTreeMap<String, Uuid>,
208 #[serde(skip_serializing_if = "<[_]>::is_empty")]
209 warnings: &'a [Warning],
210 #[serde(skip_serializing_if = "<[_]>::is_empty")]
211 changed_paths: &'a [CreatedRedirect],
212 #[serde(skip_serializing_if = "<[_]>::is_empty")]
213 renumbered_chapters: &'a [ChapterRenumber],
214 #[serde(skip_serializing_if = "<[_]>::is_empty")]
215 links_to_old_paths: &'a [LinkToOldPath],
216 #[serde(flatten)]
217 read_back: Option<ReadBack>,
218}
219
220#[derive(Serialize)]
221struct CreatedSummary<'a> {
222 created: &'a [CreatedIds],
223}
224
225#[derive(Serialize)]
226struct GlossarySummary<'a> {
227 created: &'a [CreatedIds],
228 #[serde(skip_serializing_if = "Vec::is_empty")]
229 rewritten_pages: Vec<Uuid>,
230 #[serde(skip_serializing_if = "Vec::is_empty")]
231 rewritten_exercise_instructions: Vec<DocumentKey>,
232 #[serde(skip_serializing_if = "<[_]>::is_empty")]
233 usages: &'a [Usage],
234}
235
236#[async_trait(?Send)]
237impl ToolHandler<McpState> for PatchPageContent {
238 type Args = PatchPageContentArgs;
239
240 async fn call(
241 &self,
242 state: &McpState,
243 ctx: &RequestContext,
244 args: PatchPageContentArgs,
245 ) -> Result<ToolOutcome, ToolError> {
246 check_read_back(&args.read_back)?;
247 let ops = parse_ops(&args.ops, PatchTarget::PageContent)?;
248 let view = open_course(state, ctx, args.course.id()).await?;
249 let page_id = page_in_course(&view, args.page.id())?;
250 let page = staged_page_document(&view.staged, page_id)?;
251 let result = page_content::apply(
252 page,
253 &|to_page| snapshots::page_document(&view.staged, to_page),
254 &ops,
255 &RandomIds,
256 )?;
257
258 let mut after = view.staged.clone();
259 snapshots::apply_page_documents(
260 &mut after,
261 std::iter::once(result.page).chain(result.other_pages),
262 );
263 let report = staging::commit(
264 state,
265 ctx,
266 &view,
267 after,
268 WriteRequest {
269 source: WriteSource::Derived {
270 exercise_copies: result.exercise_copies.clone(),
271 generated_blocks: HashSet::new(),
272 },
273 ..WriteRequest::new(
274 Value::Array(args.ops),
275 format!("{} op(s) on page {page_id}", ops.len()),
276 WriteMode::from_dry_run(args.dry_run),
277 )
278 },
279 )
280 .await?;
281
282 let read_back = args.read_back.as_ref().map(|selector| {
283 ReadBack::from(
284 resolve_read_back(selector, &result.labels).and_then(|selector| {
285 let document = staged_page_document(&report.after, page_id)?;
286 Ok(read_page(&document, &selector, &read_back_options(ctx))?)
287 }),
288 )
289 });
290 let summary = PageContentSummary {
291 page: page_id,
292 created: &result.created,
293 labels: &result.labels,
294 warnings: &result.warnings,
295 moved_between_pages: &result.moved_between_pages,
296 exercise_copies: &result.exercise_copies,
297 read_back,
298 };
299 let mut consequences = Vec::new();
300 if !result.exercise_copies.is_empty() {
301 consequences.push(hints::exercises_copied(result.exercise_copies.len()));
302 }
303 if !result.moved_between_pages.is_empty() {
304 consequences.push(hints::blocks_moved_between_pages(
305 result.moved_between_pages.len(),
306 ));
307 }
308 Ok(report.into_result(&summary).with_hints(consequences).into())
309 }
310}
311
312#[async_trait(?Send)]
313impl ToolHandler<McpState> for PatchCourseStructure {
314 type Args = PatchCourseStructureArgs;
315
316 async fn call(
317 &self,
318 state: &McpState,
319 ctx: &RequestContext,
320 args: PatchCourseStructureArgs,
321 ) -> Result<ToolOutcome, ToolError> {
322 check_read_back(&args.read_back)?;
323 let ops = parse_ops(&args.ops, PatchTarget::CourseStructure)?;
324 let view = open_course(state, ctx, args.course.id()).await?;
325 let facts = load_page_facts(state, view.course.id).await?;
326 let before = snapshots::structure(&view.staged, &facts);
327 let ids = RandomIds;
328 let mut result = course_structure::apply(&before, &ops, view.staged.is_live(), &ids)?;
329
330 let mut contents = std::mem::take(&mut result.new_pages);
331 let mut generated_blocks = HashSet::new();
332 let mut template_blocks = Vec::new();
333 for (page_id, blocks) in contents.iter_mut() {
334 if result.structure.is_chapter_front_page(*page_id) {
335 if blocks.is_empty()
336 && let Some(chapter) = result.structure.chapter_of_page(*page_id)
337 {
338 *blocks = ChapterFrontPage::new(&chapter.name, chapter.chapter_number).content;
339 walk(blocks, &mut |block, _| {
340 generated_blocks.insert(block.client_id);
341 });
342 template_blocks.extend(blocks.iter().map(|block| TemplateBlock {
343 page: *page_id,
344 block: block.client_id,
345 block_name: block.name.clone(),
346 }));
347 }
348 } else if !blocks
349 .iter()
350 .any(|block| PAGE_TITLE_BLOCKS.contains(&block.name.as_str()))
351 && let Some(page) = result.structure.page(*page_id)
352 {
353 let hero = GutenbergBlock::hero_section(page.title.trim(), "");
354 generated_blocks.insert(hero.client_id);
355 template_blocks.push(TemplateBlock {
356 page: *page_id,
357 block: hero.client_id,
358 block_name: hero.name.clone(),
359 });
360 blocks.insert(0, hero);
361 }
362 }
363 let mut copies = Vec::new();
364 for copy in &result.page_copies {
365 let source = match contents.get(©.source_page_id) {
367 Some(blocks) => blocks.clone(),
368 None => view
369 .staged
370 .page(copy.source_page_id)
371 .map(|p| p.content.to_vec())
372 .ok_or_else(|| {
373 ToolError::Internal(format!(
374 "the copied page {} has no blocks to copy",
375 copy.source_page_id
376 ))
377 })?,
378 };
379 let (blocks, exercise_copies) = copy_blocks(&source, &ids, copy.new_page_id);
380 copies.extend(exercise_copies);
381 contents.insert(copy.new_page_id, blocks);
382 }
383 let mut after =
384 snapshots::apply_structure(&view.staged, &before, &result.structure, &contents);
385 pair_pages(
386 &mut *connection(state).await?,
387 &view,
388 &result.page_pairings,
389 &facts.page_language_groups,
390 &mut after,
391 )
392 .await?;
393 let links = result.links_to_old_paths(&snapshots::page_refs(&after), &view.course.slug);
394 let outline = args
395 .dry_run
396 .then(|| course_structure::outline(&before, &result.structure, !args.detail));
397 let copy_count = copies.len();
398 let report = staging::commit(
399 state,
400 ctx,
401 &view,
402 after,
403 WriteRequest {
404 source: WriteSource::Derived {
405 exercise_copies: copies,
406 generated_blocks,
407 },
408 is_dry_run_brief: !args.detail,
409 ..WriteRequest::new(
410 Value::Array(args.ops),
411 format!("{} structure op(s)", ops.len()),
412 WriteMode::from_dry_run(args.dry_run),
413 )
414 },
415 )
416 .await?;
417
418 let read_back = args.read_back.as_ref().map(|selector| {
419 ReadBack::from(
420 resolve_read_back(selector, &result.labels).and_then(|selector| {
421 let structure = snapshots::structure(&report.after, &facts);
422 Ok(read_structure(
423 &structure,
424 &selector,
425 &read_back_options(ctx),
426 )?)
427 }),
428 )
429 });
430 let created = result
431 .created
432 .iter()
433 .map(|ids| {
434 let (mut own, rest): (Vec<_>, Vec<_>) = std::mem::take(&mut template_blocks)
435 .into_iter()
436 .partition(|block| ids.ids.contains(&block.page));
437 template_blocks = rest;
438 own.sort_by_key(|block| ids.ids.iter().position(|id| *id == block.page));
439 CreatedStructure {
440 ids,
441 template_blocks: own,
442 }
443 })
444 .collect();
445 let summary = StructureSummary {
446 outline: outline.as_ref(),
447 created,
448 labels: &result.labels,
449 warnings: &result.warnings,
450 changed_paths: &result.changed_paths,
451 renumbered_chapters: &result.renumbered_chapters,
452 links_to_old_paths: &links,
453 read_back,
454 };
455 let mut consequences = Vec::new();
456 if !result.changed_paths.is_empty() {
457 consequences.push(hints::redirects_created(result.changed_paths.len()));
458 }
459 let reused_links = links.iter().filter(|l| l.now_opens_page.is_some()).count();
460 if links.len() > reused_links {
461 consequences.push(hints::links_to_old_paths(links.len() - reused_links));
462 }
463 if reused_links > 0 {
464 consequences.push(hints::links_to_reused_paths(reused_links));
465 }
466 if copy_count > 0 {
467 consequences.push(hints::exercises_copied(copy_count));
468 }
469 Ok(report.into_result(&summary).with_hints(consequences).into())
470 }
471}
472
473#[async_trait(?Send)]
474impl ToolHandler<McpState> for PatchCourseSettings {
475 type Args = PatchCourseArgs;
476
477 async fn call(
478 &self,
479 state: &McpState,
480 ctx: &RequestContext,
481 args: PatchCourseArgs,
482 ) -> Result<ToolOutcome, ToolError> {
483 let ops = parse_ops(&args.ops, PatchTarget::CourseSettings)?;
484 let view = open_course(state, ctx, args.course.id()).await?;
485 let ids = RandomIds;
486 let result = course_settings::apply(
487 &snapshots::settings_document(&view.staged, &view.course),
488 &ops,
489 &ids,
490 )?;
491 let mut after = view.staged.clone();
492 snapshots::apply_settings(&mut after, &result.settings);
493 let report = staging::commit(
494 state,
495 ctx,
496 &view,
497 after,
498 WriteRequest::new(
499 Value::Array(args.ops),
500 format!("{} settings op(s)", ops.len()),
501 WriteMode::from_dry_run(args.dry_run),
502 ),
503 )
504 .await?;
505 Ok(report
506 .into_result(&CreatedSummary {
507 created: &result.created,
508 })
509 .into())
510 }
511}
512
513#[async_trait(?Send)]
514impl ToolHandler<McpState> for PatchGlossaryAndReferences {
515 type Args = PatchCourseArgs;
516
517 async fn call(
518 &self,
519 state: &McpState,
520 ctx: &RequestContext,
521 args: PatchCourseArgs,
522 ) -> Result<ToolOutcome, ToolError> {
523 let ops = parse_ops(&args.ops, PatchTarget::GlossaryAndReferences)?;
524 let mut view = open_course(state, ctx, args.course.id()).await?;
525 let ids = RandomIds;
526 let document = snapshots::glossary_document(&view.staged);
527 let edit = glossary_references::apply(&document, &ops, &ids)?;
528 let texts = if edit.affects_pages() {
529 view.load_exercises(&mut *connection(state).await?).await?;
530 let page_ids: Vec<Uuid> = view.staged.pages.iter().map(|page| page.id).collect();
531 instruction_lists::text_documents(
532 &view.staged,
533 &page_ids,
534 ExerciseTextParts {
535 instructions: true,
536 course_default: true,
537 names: false,
538 },
539 )
540 } else {
541 Vec::new()
542 };
543 let documents: Vec<DocumentRef> = snapshots::page_refs(&view.staged)
544 .into_iter()
545 .map(DocumentRef::from)
546 .chain(texts.iter().map(Document::as_document_ref))
547 .collect();
548 let result = edit.with_page_effects(&documents);
549 let (rewritten_pages, rewritten_exercise_instructions) = result
550 .rewritten_pages
551 .iter()
552 .map(|document| document.key)
553 .partition::<Vec<_>, _>(|key| matches!(key, DocumentKey::Page(_)));
554 let mut after = view.staged.clone();
555 snapshots::apply_glossary(&mut after, &result.document);
556 instruction_lists::apply_documents(&mut after, result.rewritten_pages)?;
557 let report = staging::commit(
558 state,
559 ctx,
560 &view,
561 after,
562 WriteRequest::new(
563 Value::Array(args.ops),
564 format!("{} glossary or reference op(s)", ops.len()),
565 WriteMode::from_dry_run(args.dry_run),
566 ),
567 )
568 .await?;
569 Ok(report
570 .into_result(&GlossarySummary {
571 created: &result.created,
572 rewritten_pages: rewritten_pages
573 .into_iter()
574 .filter_map(DocumentKey::page_id)
575 .collect(),
576 rewritten_exercise_instructions,
577 usages: &result.usages,
578 })
579 .into())
580 }
581}