1mod images;
10mod inline_fmt;
11mod resolve;
12mod tables;
13
14use serde::{Deserialize, Serialize};
15
16use crate::block::{BlockKind, BlockNode, EditablePolicy};
17use crate::fingerprint::{BlockFingerprint, BlockId};
18use crate::index::MarkdownIndex;
19use crate::island::RawIslandType;
20
21pub use inline_fmt::resolve_toggle_inline;
22pub use resolve::resolve_form_edit;
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct FormBlock {
27 pub block_id: BlockId,
28 pub kind: BlockKind,
29 pub editable_policy: EditablePolicy,
30 pub display: FormBlockDisplay,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub enum FormBlockDisplay {
36 Heading {
37 level: u8,
38 text: String,
39 level_editable: bool,
41 },
42 Paragraph {
43 text: String,
44 },
45 List {
46 ordered: bool,
47 items: Vec<FormListItem>,
48 },
49 Blockquote {
50 text: String,
51 },
52 Code {
53 language: Option<String>,
54 code: String,
55 },
56 HorizontalRule,
57 Image {
59 alt: String,
60 src: String,
61 },
62 Table {
64 headers: Vec<String>,
65 rows: Vec<Vec<String>>,
66 col_count: usize,
68 },
69 RawIsland {
70 island_type: RawIslandType,
71 label_key: String,
73 text: String,
74 editable: bool,
75 },
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct FormListItem {
81 pub ordinal: u32,
82 pub text: String,
83 pub task_checked: Option<bool>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct FormProjection {
90 pub document_revision: u64,
91 pub blocks: Vec<FormBlock>,
92}
93
94impl FormProjection {
95 pub fn build(text: &str, index: &MarkdownIndex) -> Self {
97 let blocks = index
98 .blocks
99 .iter()
100 .map(|b| FormBlock {
101 block_id: b.block_id,
102 kind: b.kind,
103 editable_policy: b.editable_policy,
104 display: display_for(text, index, b),
105 })
106 .collect();
107 Self {
108 document_revision: index.document_revision,
109 blocks,
110 }
111 }
112}
113
114fn display_for(text: &str, index: &MarkdownIndex, block: &BlockNode) -> FormBlockDisplay {
115 if block.editable_policy == EditablePolicy::RawIslandOnly {
116 let island = index
117 .raw_islands
118 .iter()
119 .find(|i| i.block_id == block.block_id);
120 let island_type = island
121 .map(|i| i.island_type)
122 .unwrap_or(RawIslandType::UnknownExtension);
123 return FormBlockDisplay::RawIsland {
124 island_type,
125 label_key: island_type.label_key().to_string(),
126 text: slice(text, block.source_range.start, block.source_range.end),
127 editable: true,
128 };
129 }
130 let content = |r: Option<crate::range::ByteRange>| {
131 r.map(|r| slice(text, r.start, r.end)).unwrap_or_default()
132 };
133 match block.kind {
134 BlockKind::Heading => {
135 let first = slice(text, block.source_range.start, block.source_range.end);
136 let level_editable = first.trim_start().starts_with('#');
137 FormBlockDisplay::Heading {
138 level: block.heading_level.unwrap_or(1),
139 text: content(block.content_range),
140 level_editable,
141 }
142 }
143 BlockKind::Paragraph => FormBlockDisplay::Paragraph {
144 text: content(block.content_range),
145 },
146 BlockKind::Blockquote => FormBlockDisplay::Blockquote {
147 text: content(block.content_range),
148 },
149 BlockKind::FencedCode => FormBlockDisplay::Code {
150 language: block.code_language.clone(),
151 code: content(block.content_range),
152 },
153 BlockKind::HorizontalRule => FormBlockDisplay::HorizontalRule,
154 BlockKind::BulletList | BlockKind::OrderedList | BlockKind::TaskList => {
155 FormBlockDisplay::List {
156 ordered: block.kind == BlockKind::OrderedList,
157 items: block
158 .items
159 .iter()
160 .map(|it| FormListItem {
161 ordinal: it.ordinal,
162 text: slice(text, it.content_range.start, it.content_range.end),
163 task_checked: it.task_checked,
164 })
165 .collect(),
166 }
167 }
168 BlockKind::SimpleTable => {
169 let source = slice(text, block.source_range.start, block.source_range.end);
170 let (headers, rows) = parse_simple_table(&source);
172 let col_count = headers.len();
173 FormBlockDisplay::Table {
174 headers,
175 rows,
176 col_count,
177 }
178 }
179 BlockKind::HtmlBlock => FormBlockDisplay::RawIsland {
180 island_type: RawIslandType::HtmlBlock,
181 label_key: RawIslandType::HtmlBlock.label_key().to_string(),
182 text: slice(text, block.source_range.start, block.source_range.end),
183 editable: true,
184 },
185 _ => FormBlockDisplay::RawIsland {
186 island_type: RawIslandType::UnknownExtension,
187 label_key: RawIslandType::UnknownExtension.label_key().to_string(),
188 text: slice(text, block.source_range.start, block.source_range.end),
189 editable: true,
190 },
191 }
192}
193
194fn slice(text: &str, start: usize, end: usize) -> String {
195 text.get(start..end).unwrap_or_default().to_string()
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200pub enum InlineFormat {
201 Bold,
202 Italic,
203 Code,
204 Link,
205}
206
207impl InlineFormat {
208 pub fn open_marker(self) -> &'static str {
210 match self {
211 InlineFormat::Bold => "**",
212 InlineFormat::Italic => "_",
213 InlineFormat::Code => "`",
214 InlineFormat::Link => "[",
215 }
216 }
217 pub fn close_marker(self) -> &'static str {
218 match self {
219 InlineFormat::Bold => "**",
220 InlineFormat::Italic => "_",
221 InlineFormat::Code => "`",
222 InlineFormat::Link => "]", }
224 }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub enum FormBlockEdit {
232 ReplacePlainText {
233 text: String,
234 },
235 SetHeadingLevel {
236 level: u8,
237 },
238 ToggleTaskChecked {
239 item_ordinal: u32,
240 checked: bool,
241 },
242 ReplaceListItemText {
243 item_ordinal: u32,
244 text: String,
245 },
246 ReplaceCodeBlock {
247 language: Option<String>,
248 code: String,
249 },
250 ReplaceRawIsland {
251 text: String,
252 },
253 DeleteBlock,
254 ReplaceImage {
256 alt: String,
257 src: String,
258 },
259 ReplaceTableCell {
261 row: usize,
262 col: usize,
263 text: String,
264 },
265 AddTableRow,
267 ToggleInline {
271 kind: InlineFormat,
272 utf16_start: usize,
273 utf16_len: usize,
274 link_url: Option<String>,
276 },
277}
278
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282pub struct FormEditCommand {
283 pub base_revision: u64,
284 pub block_id: BlockId,
285 pub client_block_fingerprint: Option<BlockFingerprint>,
287 pub edit: FormBlockEdit,
288}
289
290#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
293pub enum FormEditError {
294 #[error("document revision mismatch: command base {base}, current {current}")]
295 DocumentRevisionMismatch { base: u64, current: u64 },
296 #[error("block not found for the given id")]
297 BlockNotFound,
298 #[error("block fingerprint mismatch; projection is stale")]
299 BlockFingerprintMismatch,
300 #[error("list item {ordinal} not found")]
301 ItemNotFound { ordinal: u32 },
302 #[error("edit operation is not supported for this block: {reason}")]
303 UnsupportedEditOperation { reason: String },
304 #[error("invalid edit payload: {reason}")]
305 InvalidEditPayload { reason: String },
306}
307
308fn parse_simple_table(source: &str) -> (Vec<String>, Vec<Vec<String>>) {
310 let parse_row = |line: &str| -> Vec<String> {
311 let trimmed = line.trim().trim_start_matches('|').trim_end_matches('|');
312 trimmed.split('|').map(|c| c.trim().to_string()).collect()
313 };
314 let is_sep = |line: &str| {
315 let t = line.trim();
316 t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ')) && t.contains('-')
317 };
318 let mut rows = source.lines().filter(|l| !is_sep(l)).map(parse_row);
319 let headers = rows.next().unwrap_or_default();
320 (headers, rows.collect())
321}