Skip to main content

headless_lms_mcp/engine/page_content/
address.rs

1//! What page paths name, and where blocks, lists, anchors and ranges are in the current trees.
2
3use headless_lms_utils::document_schema_processor::GutenbergBlock;
4use serde_json::json;
5use uuid::Uuid;
6
7use super::Editor;
8use crate::engine::blocks;
9use crate::engine::error::{EditError, EditErrorCode};
10use crate::engine::op::Position;
11use crate::engine::path::{BlockPart, BlockPath, parse_block_path};
12
13pub(super) type Anchor = crate::engine::Anchor<ListRef>;
14
15#[derive(Debug, Clone)]
16pub(super) enum Address {
17    /// A page's top level, by index into the editor's pages.
18    Root(usize),
19    Block(Uuid),
20    Children(Uuid),
21    Name(Uuid),
22    /// The block's attributes and a JSON Pointer into them, `""` for all of them.
23    Attribute(Uuid, String),
24}
25
26/// A children list: a page's top level, or a block's inner blocks.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub(super) struct ListRef {
29    pub(super) page: usize,
30    pub(super) parent: Option<Uuid>,
31}
32
33/// Where a block is: the page's index in the editor and the block's index path there.
34#[derive(Debug, Clone)]
35pub(super) struct Loc {
36    pub(super) page: usize,
37    pub(super) path: Vec<usize>,
38}
39
40impl Loc {
41    pub(super) fn parent_path(&self) -> &[usize] {
42        &self.path[..self.path.len() - 1]
43    }
44
45    pub(super) fn index(&self) -> usize {
46        self.path[self.path.len() - 1]
47    }
48}
49
50/// Siblings `start..=end` of one children list.
51pub(super) struct Range {
52    pub(super) page: usize,
53    pub(super) parent_path: Vec<usize>,
54    pub(super) start: usize,
55    pub(super) end: usize,
56}
57
58impl Range {
59    pub(super) fn first(&self) -> Loc {
60        Loc {
61            page: self.page,
62            path: [self.parent_path.as_slice(), &[self.start]].concat(),
63        }
64    }
65}
66
67impl Editor<'_> {
68    /// Where the block is: on the page being edited, or for `move` and `copy` also on a `to_page`.
69    pub(super) fn locate(&self, id: Uuid) -> Option<Loc> {
70        let reachable = if self.can_reach_other_pages {
71            self.pages.len()
72        } else {
73            1
74        };
75        self.pages
76            .iter()
77            .take(reachable)
78            .enumerate()
79            .find_map(|(page, doc)| {
80                blocks::find_path(&doc.blocks, id).map(|path| Loc { page, path })
81            })
82    }
83
84    pub(super) fn require(&self, id: Uuid) -> Result<Loc, EditError> {
85        self.locate(id).ok_or_else(|| self.not_found(id))
86    }
87
88    pub(super) fn not_found(&self, id: Uuid) -> EditError {
89        let other_page = self
90            .pages
91            .iter()
92            .skip(1)
93            .find(|doc| blocks::find_path(&doc.blocks, id).is_some());
94        match (self.removed.get(&id), other_page) {
95            (Some(op), _) => EditError::new(
96                EditErrorCode::NotFound,
97                format!("block {id} was removed by op {op} of this call"),
98            ),
99            (None, Some(doc)) => EditError::new(
100                EditErrorCode::NotFound,
101                format!(
102                    "block {id} is on page {}; this call edits only its own page, which move and copy can reach other pages from",
103                    doc.page_id
104                ),
105            )
106            .with_fix("edit the other page with its own call"),
107            (None, None) => EditError::new(
108                EditErrorCode::NotFound,
109                format!("no block {id} on this page"),
110            )
111            .with_fix("read the page outline with get_page_content to get current ids"),
112        }
113    }
114
115    /// Parses a page path. `root_page` is what `""` means: the edited page, or `to_page`.
116    pub(super) fn address(&self, path: &str, root_page: usize) -> Result<Address, EditError> {
117        let parsed = parse_block_path(path).ok_or_else(|| {
118            EditError::new(
119                EditErrorCode::InvalidPath,
120                format!("path \"{path}\" must start with / (or be \"\" for the page's top level)"),
121            )
122            .with_fix(format!("/{path}"))
123        })?;
124        let (root, part) = match parsed {
125            BlockPath::TopLevel => return Ok(Address::Root(root_page)),
126            BlockPath::Block { root, part } => (root, part),
127        };
128        if root.parse::<usize>().is_ok() {
129            return Err(numeric_index_error(
130                path,
131                &self.pages[root_page].blocks,
132                &root,
133            ));
134        }
135        let id = self.labels.resolve_segment(&root, path, self.ids.op())?;
136        let loc = self.require(id)?;
137        match part {
138            BlockPart::Whole => Ok(Address::Block(id)),
139            BlockPart::Children => Ok(Address::Children(id)),
140            BlockPart::ChildIndex(index) => Err(numeric_index_error(
141                path,
142                &self.block(&loc)?.inner_blocks,
143                &index,
144            )),
145            BlockPart::Attributes(pointer) => Ok(Address::Attribute(id, pointer.to_string())),
146            BlockPart::Name => Ok(Address::Name(id)),
147            BlockPart::Other { field, .. } if matches!(field.as_str(), "clientId" | "isValid") => {
148                Err(EditError::new(
149                    EditErrorCode::ReadOnlyField,
150                    format!("{field} is managed by the server"),
151                ))
152            }
153            BlockPart::Other { field, .. } => Err(EditError::new(
154                EditErrorCode::InvalidPath,
155                format!("blocks have no \"{field}\"; a block path continues with /innerBlocks, /attributes/<name> or /name"),
156            )
157            .with_fix(format!("/{id}/attributes/{field}"))),
158        }
159    }
160
161    pub(super) fn anchor(
162        &self,
163        address: &Address,
164        position: Option<Position>,
165    ) -> Result<Anchor, EditError> {
166        let Some(position) = position else {
167            return Err(EditError::position_required(
168                "position is before/after a sibling (\"path\":\"/p2\") or start/end of a children list (\"path\":\"/box1/innerBlocks\" or \"\" for the page)",
169            ));
170        };
171        match (address, position) {
172            (Address::Root(page), Position::Start) => Ok(Anchor::Start(ListRef { page: *page, parent: None })),
173            (Address::Root(page), Position::End) => Ok(Anchor::End(ListRef { page: *page, parent: None })),
174            (Address::Children(id), Position::Start | Position::End) => {
175                let loc = self.require(*id)?;
176                let list = ListRef { page: loc.page, parent: Some(*id) };
177                self.check_not_in_exercise(&loc, true, "add blocks inside it")?;
178                Ok(if position == Position::Start { Anchor::Start(list) } else { Anchor::End(list) })
179            }
180            (Address::Block(id), Position::Before | Position::After) => {
181                let loc = self.require(*id)?;
182                self.check_not_in_exercise(&loc, false, "add blocks inside it")?;
183                Ok(if position == Position::Before { Anchor::Before(*id) } else { Anchor::After(*id) })
184            }
185            (Address::Block(id), Position::Start | Position::End) => Err(EditError::new(
186                EditErrorCode::PositionNeedsChildren,
187                format!("position \"{position}\" needs a children list, not a block"),
188            )
189            .with_fix(format!("\"path\":\"/{id}/innerBlocks\" to place inside the block, or position before/after to place next to it"))),
190            (Address::Root(_), Position::Before | Position::After) => Err(EditError::new(
191                EditErrorCode::PositionNeedsSibling,
192                "\"\" is the page's top-level list; before/after need a sibling block",
193            )
194            .with_fix("use position \"start\" or \"end\" with path \"\", or before/after with \"path\":\"/<sibling id>\"")),
195            (Address::Children(id), Position::Before | Position::After) => Err(EditError::new(
196                EditErrorCode::PositionNeedsSibling,
197                format!("/{id}/innerBlocks is a children list; before/after need a sibling block"),
198            )
199            .with_fix(format!("use position \"start\" or \"end\" with \"/{id}/innerBlocks\", or before/after with the sibling's path"))),
200            (Address::Name(_) | Address::Attribute(..), _) => Err(EditError::new(
201                EditErrorCode::InvalidPath,
202                "blocks are placed next to a block or into a children list, not at an attribute",
203            )),
204        }
205    }
206
207    pub(super) fn anchor_page(&self, anchor: Anchor) -> Result<usize, EditError> {
208        match anchor {
209            Anchor::Before(id) | Anchor::After(id) => Ok(self.require(id)?.page),
210            Anchor::Start(list) | Anchor::End(list) => Ok(list.page),
211        }
212    }
213
214    /// (page, parent path, insertion index) for an anchor in the current tree.
215    pub(super) fn resolve_anchor(
216        &self,
217        anchor: Anchor,
218    ) -> Result<(usize, Vec<usize>, usize), EditError> {
219        let ((page, parent_path), index) = anchor.insertion_point(
220            |id| {
221                let loc = self.require(id)?;
222                Ok(((loc.page, loc.parent_path().to_vec()), loc.index()))
223            },
224            |list| {
225                let parent_path = self.list_parent_path(list)?;
226                let len = blocks::list(&self.pages[list.page].blocks, &parent_path)?.len();
227                Ok(((list.page, parent_path), len))
228            },
229        )?;
230        Ok((page, parent_path, index))
231    }
232
233    /// The index path of the block whose children `list` is; empty for a page's top level.
234    pub(super) fn list_parent_path(&self, list: ListRef) -> Result<Vec<usize>, EditError> {
235        Ok(match list.parent {
236            Some(parent) => self.require(parent)?.path,
237            None => Vec::new(),
238        })
239    }
240
241    pub(super) fn range(&self, first: Uuid, through: Option<Uuid>) -> Result<Range, EditError> {
242        let start = self.require(first)?;
243        let Some(last_id) = through else {
244            return Ok(Range {
245                page: start.page,
246                parent_path: start.parent_path().to_vec(),
247                start: start.index(),
248                end: start.index(),
249            });
250        };
251        let end = self.require(last_id)?;
252        if start.page != end.page || start.parent_path() != end.parent_path() {
253            let parent_of = |loc: &Loc| -> Result<String, EditError> {
254                Ok(if loc.path.len() == 1 {
255                    "the page's top level (\"\")".to_string()
256                } else {
257                    format!(
258                        "/{}/innerBlocks",
259                        blocks::get(&self.pages[loc.page].blocks, loc.parent_path())?.client_id
260                    )
261                })
262            };
263            return Err(EditError::new(
264                EditErrorCode::InvalidRange,
265                format!(
266                    "a range must be siblings under one parent; /{first} is in {} and /{last_id} is in {}",
267                    parent_of(&start)?,
268                    parent_of(&end)?
269                ),
270            )
271            .with_details(json!({
272                "from_siblings": self.siblings(start.page, start.parent_path()),
273                "through_siblings": self.siblings(end.page, end.parent_path()),
274            })));
275        }
276        if end.index() < start.index() {
277            return Err(EditError::reversed_range(
278                first,
279                last_id,
280                self.siblings(start.page, start.parent_path()),
281            ));
282        }
283        Ok(Range {
284            page: start.page,
285            parent_path: start.parent_path().to_vec(),
286            start: start.index(),
287            end: end.index(),
288        })
289    }
290
291    pub(super) fn range_blocks(&self, range: &Range) -> Result<&[GutenbergBlock], EditError> {
292        blocks::list(&self.pages[range.page].blocks, &range.parent_path)?
293            .get(range.start..=range.end)
294            .ok_or_else(blocks::stale_location)
295    }
296}
297
298fn numeric_index_error(path: &str, list: &[GutenbergBlock], index: &str) -> EditError {
299    let current = index
300        .parse::<usize>()
301        .ok()
302        .and_then(|i| list.get(i))
303        .map(|b| format!("; that position currently holds /{}", b.client_id))
304        .unwrap_or_default();
305    let append_hint = if index == "-" {
306        "; to append, use position \"end\" on the children list"
307    } else {
308        ""
309    };
310    let mut error = EditError::numeric_index(path, "blocks");
311    error.message.push_str(&current);
312    error.message.push_str(append_hint);
313    error.with_fix("use /<id> for a block, or the children list with a position, e.g. {\"path\":\"/box1/innerBlocks\",\"position\":\"end\"}")
314}