1use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value, json};
6
7use crate::engine::{EditError, EditErrorCode, PageType};
8use crate::validator::BlockSchemas;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
11#[serde(rename_all = "kebab-case")]
12pub enum GuideTopic {
13 Editing,
14 CourseStructure,
15 PublishingAndRisk,
16 PreviewsAndChecks,
17 CitationsAndGlossary,
18 RichText,
19 Exercises,
20}
21
22impl GuideTopic {
23 pub const ALL: [GuideTopic; 7] = [
24 GuideTopic::Editing,
25 GuideTopic::CourseStructure,
26 GuideTopic::PublishingAndRisk,
27 GuideTopic::PreviewsAndChecks,
28 GuideTopic::CitationsAndGlossary,
29 GuideTopic::RichText,
30 GuideTopic::Exercises,
31 ];
32
33 fn text(self) -> &'static str {
34 match self {
35 GuideTopic::Editing => EDITING,
36 GuideTopic::CourseStructure => COURSE_STRUCTURE,
37 GuideTopic::PublishingAndRisk => PUBLISHING_AND_RISK,
38 GuideTopic::PreviewsAndChecks => PREVIEWS_AND_CHECKS,
39 GuideTopic::CitationsAndGlossary => CITATIONS_AND_GLOSSARY,
40 GuideTopic::RichText => RICH_TEXT,
41 GuideTopic::Exercises => EXERCISES,
42 }
43 }
44}
45
46const EDITING: &str = "\
47patch_page_content takes ops {op, path, from, through, value, position, to_page, label, at_text}.
48- add: value is a block or a list of blocks; position before/after a block path or start/end of a children list (\"\" or /<id>/innerBlocks).
49- replace /<id>: a block, keeping its id; fields you leave out keep their values; a container's replacement needs innerBlocks (use {\"clientId\":\"…\"} stubs to keep children).
50- replace /<id>/attributes/<name>: sets one attribute at any depth; a missing key is added. remove on it deletes the attribute.
51- replace on a children list or \"\": array rules: a stub keeps a block, a block with clientId replaces it, a block without one is new; leaving out an existing block or naming an unknown id is an error. Use this to reorder.
52- move/copy: from (+through) to path+position; to_page moves or copies to another page. move keeps ids, copy assigns new ones (a copied exercise becomes a new exercise without submissions).
53- remove: a block or a range (path + through).
54- wrap (path, through, value: the container), unwrap (path), split (path, at_text: the second block starts there), merge (from into path; from must be the next sibling).
55- Exercise blocks can be moved, copied and removed, not replaced or edited; patch_exercise edits an exercise's name, own deadline and instructions.
56- dry_run: true stages nothing and returns the diff; read_back reads the result in the same call.";
57
58const COURSE_STRUCTURE: &str = "\
59get_course_structure returns {course: {pages, modules: [{chapters: [{pages}]}]}}. The first page of a chapter is its front page (is_chapter_front_page: true); the course front page (/) is always first in /course/pages; the default module is always first and has no name.
60patch_course_structure ops: replace fields (module name; chapter name, color, opens_at, deadline; page title, url_path, hidden), remove (field to null, or an entity or page range), add (module, chapter or page; a chapter gets a front page; a page may carry blocks), move, copy (page or chapter; new ids, -copy suffix), reorder (value: exactly the current ids in the new order).
61- Chapter numbers and /chapter-N paths are recomputed after every call. A chapter page's url_path is its last segment, e.g. \"loops\" for /chapter-2/loops; the front page's path is always /chapter-N. A hero section's label shows \"chapter N\" by itself while useDefaultTextForLabel is on and the label is unset or \"Chapter number...\"; leave it so, as a hand-set number goes stale when pages move.
62- url_path is unique in the course after the call; paths can be swapped in one call. Changed paths get redirects automatically; removed pages get none.
63- A new page that translates a page of another language version of the course takes pair_with: <that page's id>; it joins that page's page_language_group_id at publish, so language switching links the two. The source page must be published, and the group can't already have a page in this course.
64- dry_run: true returns an outline of the changed chapters and pages (counts, then one line each: old -> new path, title, position) and the redirects; read it against your plan, as detail: true adds the full diff.
65- Pages with exercises stay inside chapters. Settings (completion, certificates) are edited with patch_course_settings.";
66
67const PUBLISHING_AND_RISK: &str = "\
68Every write stages into your open changeset for the course; later calls build on it, and reads show it unless state is published. list_changesets shows it, discard_changeset drops it, publish_changeset publishes it.
69A change is risky only in live content (not draft, not test mode, not a hidden page, not a chapter that hasn't opened):
70- removing an exercise block whose exercise has submissions, or a page or chapter containing one
71- removing a visible page or an open chapter
72- moving a deadline earlier or adding one to an open chapter; moving an open chapter's opening time later
73- changing an exercise's own deadline so that the deadline that applies to it moves earlier, or it gets one, while its chapter is open
74- changing a completion policy or the module completion requirement after completions exist; turning chapter locking on or off
75publish_changeset refuses changesets with risky changes; the teacher publishes those on the review page. Size alone is never risky; moving exercises is never risky, even when the move changes which chapter deadline applies. Publishing fails if a staged page or entity changed live meanwhile: drop just those with discard_changeset and entities, read their published state again and redo your edits to them.
76A write fails with staged_state_changed when a parallel call changed what it read; read again and repeat it.
77After publishing, get_history lists changes and revert_change undoes one write call.";
78
79const PREVIEWS_AND_CHECKS: &str = "\
80get_preview_link returns read-only URLs, valid about 2 hours, no login needed, of staged state or, with nothing staged, of the published pages: without page or pages, one URL for all pages the changeset adds or changes (all course pages when nothing is staged), which the preview page lists and opens one at a time (?page=<id> picks one); page or pages gives a URL per page. Open it with your own browser tooling (playwright-cli) in a desktop or mobile viewport and check: render and console errors, LaTeX errors (.katex-error), broken links and images, and accessibility (axe). The preview shows each exercise's name, task instructions and review instructions, but not the exercise itself to answer, and has no chapter navigation; the changeset URL lists the changed pages and renders the Pages in chapter list on chapter front pages. Checks are expected before a changeset is published.";
81
82const CITATIONS_AND_GLOSSARY: &str = "\
83Citations are written in rich text as \\cite{key}, \\cite[postnote]{key} or \\cite[prenote][postnote]{key}, where key is a reference's citation_key. References hold one BibTeX entry each (fields citation_key, bibtex); the entry's key must equal citation_key and be unique in the course. The server checks only an entry's structure; pages render citations with a stricter parser, so keep to standard BibTeX and preview a page that cites a new entry.
84Glossary terms (fields term, definition) are highlighted automatically wherever the exact term appears, case-sensitively, as a whole word: a letter (ä and ö included), digit or _ at the term's edge never touches another one, so \"äly\" isn't highlighted in \"tekoäly\" and an inflected form like \"algoritmin\" isn't highlighted for \"algoritmi\". A tag counts as a word edge. Nothing is marked up in the content.
85Renaming a citation_key rewrites its citations in page content and exercise instructions. Removing a reference, or renaming or removing a term, changes no content; the result lists the pages and exercise instructions that use it.";
86
87const RICH_TEXT: &str = "\
88Rich-text attributes hold the HTML the editor stores, with inline formats only, in exactly the shapes the editor produces (see rich_text_formats). No block-level tags, scripts, event attributes, style attributes or inline images. Inline math is [latex]x^2[/latex], rendered with KaTeX like moocfi/latex blocks; inside it only & is decoded. The editor's <math> format renders as browser MathML and looks different: use it only where the page already does. Write &, < and > as entities. Matching in find_replace sees this markup: <em>for</em> loops doesn't match \"for loops\". The validator normalises harmless differences and rejects anything that would change meaning.";
89
90const EXERCISES: &str = "\
91An exercise block on a page holds only the exercise id (exercise_id in reads). get_exercise reads the exercise: name, tasks, review settings and its instructions; patch_exercise edits the name, the own deadline, each task's instructions (/tasks/<task id>) and the review instructions (/review_instructions) with page-content ops. Everything else (points, tries, task specs, review thresholds and questions) is read-only; the teacher edits it in the editor.
92- The effective deadline is the exercise's own, else its chapter's. Chapter deadlines are strongly preferred: own deadlines are easy to miss when dates change. Add one only when the teacher asks; deadline: null clears it. Times are RFC 3339 with an offset.
93- /tasks/<task id>/specs reads a task's private, public and model solution specs together; the private spec is what the editor saves and the only authoritative one, and the service derives the others from it, so they can lag behind a staged change until publish. Specs hold answer keys: never copy them into what students see.
94- Instructions take the blocks the editor allows there, which the validator checks; exercise blocks can't be nested in them.
95- Review instructions show only for exercises with peer or self reviews. Most exercises use the course default review config, so its instructions are shared: review_config edit_course_default edits them for every exercise using it. Prefer that. fork gives one exercise its own copy of the default settings and questions, for an exercise with special needs; use_course_default drops an exercise's own settings again, and the result lists what it drops.
96- Switching review settings is allowed after students have given reviews, but the result and the review page warn about it.
97- Name and instruction edits are never risky; a deadline change is risky as in publishing-and-risk.
98- find_replace and structural_replace include instructions by default (include_exercise_instructions) and find_replace exercise names with include_exercise_names; matches in the course default review instructions are listed once and change every exercise using it. Bulk tools never fork. Regexes (find_replace regex: true, structural_replace text_regex) use Rust regex syntax: no backreferences or lookaround; (?i) for case-insensitive.";
99
100pub fn answer(
103 schemas: Option<&BlockSchemas>,
104 block_types: &[String],
105 topics: &[GuideTopic],
106) -> Result<Value, EditError> {
107 let mut answer = Map::new();
108 if let Some(schemas) = schemas {
109 if block_types.is_empty() && topics.is_empty() {
110 return Ok(index(schemas));
111 }
112 if !block_types.is_empty() {
113 answer = blocks_guide(schemas, block_types)?;
114 }
115 }
116 if !topics.is_empty() {
117 answer.insert("topics".into(), topics_guide(topics));
118 }
119 Ok(Value::Object(answer))
120}
121
122fn index(schemas: &BlockSchemas) -> Value {
124 json!({
125 "topics": GuideTopic::ALL,
126 "block_types_by_page_type": schemas.page_types,
127 "schema_version": schemas.version,
128 "usage": "Call guide with block_types and/or topics for details.",
129 })
130}
131
132fn topics_guide(topics: &[GuideTopic]) -> Value {
134 json!(
135 topics
136 .iter()
137 .map(|topic| json!({"topic": topic, "text": topic.text()}))
138 .collect::<Vec<_>>()
139 )
140}
141
142fn blocks_guide(
145 schemas: &BlockSchemas,
146 block_types: &[String],
147) -> Result<Map<String, Value>, EditError> {
148 let blocks = block_types
149 .iter()
150 .map(|name| block_guide(schemas, name))
151 .collect::<Result<Vec<_>, _>>()?;
152 Ok(Map::from_iter([
153 ("blocks".to_string(), Value::Array(blocks)),
154 (
155 "rich_text_formats".to_string(),
156 json!(schemas.rich_text_formats),
157 ),
158 ("schema_version".to_string(), json!(schemas.version)),
159 ]))
160}
161
162fn block_guide(schemas: &BlockSchemas, name: &str) -> Result<Value, EditError> {
163 let Some(schema) = schemas.blocks.get(name) else {
164 let suffix = name.rsplit('/').next().unwrap_or(name);
165 let similar: Vec<&String> = schemas
166 .blocks
167 .keys()
168 .filter(|k| k.contains(suffix))
169 .collect();
170 return Err(
171 EditError::new(EditErrorCode::NotFound, format!("no block type \"{name}\""))
172 .with_details(
173 json!({"similar": similar, "all": schemas.blocks.keys().collect::<Vec<_>>()}),
174 ),
175 );
176 };
177 let page_types: Vec<&PageType> = schemas
178 .page_types
179 .iter()
180 .filter(|(_, names)| names.iter().any(|n| n == name))
181 .map(|(t, _)| t)
182 .collect();
183 let mut example_op =
184 json!({"op": "add", "path": "", "position": "end", "value": schema.example});
185 if schema
186 .allowed_parents
187 .as_ref()
188 .is_some_and(|p| !p.iter().any(|p| p == "root"))
189 {
190 example_op["path"] = json!("/<parent id>/innerBlocks");
191 }
192 Ok(json!({
193 "name": name,
194 "title": schema.title,
195 "description": schema.description,
196 "attributes": schema.attributes,
197 "allowed_parents": schema.allowed_parents,
198 "allowed_children": schema.allowed_children,
199 "template": schema.template,
200 "template_lock": schema.template_lock,
201 "page_types": page_types,
202 "enabled_by_course_setting": schemas.course_feature_gated_blocks.get(name),
203 "example_op": example_op,
204 }))
205}
206
207#[cfg(test)]
208mod tests {
209 use std::collections::BTreeMap;
210
211 use super::*;
212 use crate::validator::{BlockSchema, RichTextFormat};
213
214 fn schemas() -> BlockSchemas {
215 BlockSchemas {
216 version: "abc".into(),
217 page_types: BTreeMap::from([(
218 PageType::CoursePage,
219 vec!["core/paragraph".into(), "core/list-item".into()],
220 )]),
221 course_feature_gated_blocks: BTreeMap::new(),
222 max_request_bytes: 1_000_000,
223 rich_text_formats: vec![RichTextFormat {
224 name: "core/bold".into(),
225 tag: "strong".into(),
226 description: "Bold".into(),
227 }],
228 blocks: BTreeMap::from([
229 (
230 "core/paragraph".into(),
231 BlockSchema {
232 title: "Paragraph".into(),
233 description: String::new(),
234 attributes: serde_json::from_value(
235 json!({"content": {"type": "string", "x-rich-text": true}}),
236 )
237 .unwrap(),
238 allowed_parents: None,
239 allowed_children: Some(vec![]),
240 template: None,
241 template_lock: None,
242 example: json!({"name": "core/paragraph", "attributes": {"content": "Example text."}, "innerBlocks": []}),
243 },
244 ),
245 (
246 "core/list-item".into(),
247 BlockSchema {
248 title: "List item".into(),
249 description: String::new(),
250 attributes: Map::new(),
251 allowed_parents: Some(vec!["core/list".into()]),
252 allowed_children: None,
253 template: None,
254 template_lock: None,
255 example: json!({}),
256 },
257 ),
258 ]),
259 }
260 }
261
262 #[test]
263 fn index_blocks_and_topics() {
264 let index = index(&schemas());
265 assert_eq!(index["topics"].as_array().unwrap().len(), 7);
266 assert_eq!(index["topics"][1], json!("course-structure"));
267 let guide = blocks_guide(
268 &schemas(),
269 &["core/paragraph".into(), "core/list-item".into()],
270 )
271 .unwrap();
272 assert_eq!(guide["blocks"][0]["example_op"]["path"], json!(""));
273 assert_eq!(
274 guide["blocks"][1]["example_op"]["path"],
275 json!("/<parent id>/innerBlocks")
276 );
277 assert_eq!(guide["blocks"][0]["page_types"], json!(["course-page"]));
278 let topics = topics_guide(&[GuideTopic::RichText]);
279 assert_eq!(topics[0]["topic"], json!("rich-text"));
280 assert!(topics[0]["text"].as_str().unwrap().contains("[latex]"));
281 }
282
283 #[test]
284 fn unknown_block_lists_similar() {
285 let error = blocks_guide(&schemas(), &["moocfi/paragraph".into()]).unwrap_err();
286 assert_eq!(error.details.unwrap()["similar"], json!(["core/paragraph"]));
287 }
288}