1use std::num::NonZeroUsize;
27
28use schemars::JsonSchema;
29use serde::{Deserialize, Serialize};
30use systemprompt_identifiers::{DocumentNodeId, DocumentRevisionId};
31
32use super::codec::CodecError;
33use super::layout_op::{ApplyTheme, InsertBlock, SetParagraph, SetSection, SetStyle, UpdateTable};
34use super::projection::NodeKind;
35use super::rich_op::{InsertImage, InsertTable, SetBlockType, SetMarks, TableDelete, TableInsert};
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
44#[serde(untagged)]
45pub enum NodeTarget {
46 Id(DocumentNodeId),
47 Find(QuoteTarget),
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
51pub struct QuoteTarget {
52 pub find: String,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub nth: Option<NonZeroUsize>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub within: Option<DocumentNodeId>,
57}
58
59#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
60#[serde(rename_all = "snake_case")]
61pub enum ContentAs {
62 #[default]
63 Text,
64 Markdown,
65}
66
67impl ContentAs {
68 #[must_use]
69 pub const fn is_text(&self) -> bool {
70 matches!(self, Self::Text)
71 }
72}
73
74impl NodeTarget {
75 #[must_use]
76 pub const fn id(&self) -> Option<&DocumentNodeId> {
77 match self {
78 Self::Id(id) => Some(id),
79 Self::Find(_) => None,
80 }
81 }
82
83 pub const fn resolved(&self) -> Result<&DocumentNodeId, CodecError> {
84 match self {
85 Self::Id(id) => Ok(id),
86 Self::Find(_) => Err(CodecError::Malformed {
87 detail: "a quote target reached the codec unresolved",
88 }),
89 }
90 }
91}
92
93impl EditOp {
94 pub fn check_resolved(&self) -> Result<(), CodecError> {
95 match self {
96 Self::ReplaceText {
97 content_as: ContentAs::Markdown,
98 ..
99 } => Err(CodecError::Malformed {
100 detail: "replace_text with markdown content reached the codec unresolved",
101 }),
102 Self::ReplaceText { node, .. }
103 | Self::InsertAfter { node, .. }
104 | Self::Delete { node, .. } => node.resolved().map(|_| ()),
105 Self::Restore { .. } | Self::InsertFromRevision { .. } => Err(CodecError::Malformed {
106 detail: "a revision op reached the codec unexpanded",
107 }),
108 _ => self
109 .rich_guard()
110 .map_or(Ok(()), |(node, _)| node.resolved().map(|_| ())),
111 }
112 }
113}
114
115pub fn required_hash(hash: Option<&String>) -> Result<&str, CodecError> {
116 hash.map(String::as_str).ok_or(CodecError::Malformed {
117 detail: "expected_hash is required when node names a node id",
118 })
119}
120
121const fn paragraph() -> NodeKind {
122 NodeKind::Paragraph
123}
124
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
126#[serde(untagged)]
127pub enum CellValue {
128 Bool(bool),
129 Number(f64),
130 Text(String),
131 Empty,
132}
133
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
135#[serde(tag = "op", rename_all = "snake_case")]
136pub enum EditOp {
137 ReplaceText {
138 node: NodeTarget,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 expected_hash: Option<String>,
141 text: String,
142 #[serde(rename = "as", default, skip_serializing_if = "ContentAs::is_text")]
143 content_as: ContentAs,
144 },
145 InsertAfter {
146 node: NodeTarget,
147 #[serde(default = "paragraph")]
148 kind: NodeKind,
149 text: String,
150 #[serde(rename = "as", default, skip_serializing_if = "ContentAs::is_text")]
151 content_as: ContentAs,
152 },
153 Delete {
154 node: NodeTarget,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 expected_hash: Option<String>,
157 },
158 AppendMarkdown {
159 markdown: String,
160 },
161 SetCells {
162 sheet: String,
163 range: String,
164 values: Vec<Vec<CellValue>>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
166 expected_hash: Option<String>,
167 },
168 InsertSlide {
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 after: Option<DocumentNodeId>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 layout: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 title: Option<String>,
175 #[serde(default)]
176 body: Vec<String>,
177 },
178 StrReplace {
179 old_str: String,
180 new_str: String,
181 },
182 SetHtml {
183 html: String,
184 },
185 SetText {
186 text: String,
187 },
188 Restore {
189 revision_id: DocumentRevisionId,
190 },
191 InsertFromRevision {
192 revision_id: DocumentRevisionId,
193 node: DocumentNodeId,
194 after: NodeTarget,
195 },
196 SetMarks(SetMarks),
197 SetBlockType(SetBlockType),
198 InsertTable(InsertTable),
199 TableInsertRow(TableInsert),
200 TableInsertCol(TableInsert),
201 TableDelete(TableDelete),
202 InsertImage(InsertImage),
203 SetParagraph(SetParagraph),
204 InsertBlock(InsertBlock),
205 UpdateTable(UpdateTable),
206 SetSection(SetSection),
207 SetStyle(SetStyle),
208 ApplyTheme(ApplyTheme),
209}
210
211impl EditOp {
212 #[must_use]
213 pub const fn name(&self) -> &'static str {
214 match self {
215 Self::ReplaceText { .. } => "replace_text",
216 Self::InsertAfter { .. } => "insert_after",
217 Self::Delete { .. } => "delete",
218 Self::SetCells { .. } => "set_cells",
219 Self::InsertSlide { .. } => "insert_slide",
220 Self::StrReplace { .. } => "str_replace",
221 Self::SetHtml { .. } => "set_html",
222 Self::SetText { .. } => "set_text",
223 Self::AppendMarkdown { .. } => "append_markdown",
224 Self::Restore { .. } => "restore",
225 Self::InsertFromRevision { .. } => "insert_from_revision",
226 Self::SetMarks(_) => "set_marks",
227 Self::SetBlockType(_) => "set_block_type",
228 Self::InsertTable(_) => "insert_table",
229 Self::TableInsertRow(_) => "table_insert_row",
230 Self::TableInsertCol(_) => "table_insert_col",
231 Self::TableDelete(_) => "table_delete",
232 Self::InsertImage(_) => "insert_image",
233 Self::SetParagraph(_) => "set_paragraph",
234 Self::InsertBlock(_) => "insert_block",
235 Self::UpdateTable(_) => "update_table",
236 Self::SetSection(_) => "set_section",
237 Self::SetStyle(_) => "set_style",
238 Self::ApplyTheme(_) => "apply_theme",
239 }
240 }
241
242 #[must_use]
243 pub const fn touches(&self) -> Option<&DocumentNodeId> {
244 match self {
245 Self::ReplaceText { node, .. }
246 | Self::InsertAfter { node, .. }
247 | Self::Delete { node, .. } => node.id(),
248 Self::InsertFromRevision { after, .. } => after.id(),
249 Self::InsertSlide { after, .. } => after.as_ref(),
250 Self::SetMarks(_)
251 | Self::SetBlockType(_)
252 | Self::InsertTable(_)
253 | Self::TableInsertRow(_)
254 | Self::TableInsertCol(_)
255 | Self::TableDelete(_)
256 | Self::InsertImage(_)
257 | Self::SetParagraph(_)
258 | Self::InsertBlock(_)
259 | Self::UpdateTable(_) => match self.rich_guard() {
260 Some((node, _)) => node.id(),
261 None => None,
262 },
263 Self::SetCells { .. }
264 | Self::StrReplace { .. }
265 | Self::SetHtml { .. }
266 | Self::SetText { .. }
267 | Self::AppendMarkdown { .. }
268 | Self::SetSection(_)
269 | Self::SetStyle(_)
270 | Self::ApplyTheme(_)
271 | Self::Restore { .. } => None,
272 }
273 }
274}