mod images;
mod inline_fmt;
mod resolve;
mod tables;
use serde::{Deserialize, Serialize};
use crate::block::{BlockKind, BlockNode, EditablePolicy};
use crate::fingerprint::{BlockFingerprint, BlockId};
use crate::index::MarkdownIndex;
use crate::island::RawIslandType;
pub use inline_fmt::resolve_toggle_inline;
pub use resolve::resolve_form_edit;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FormBlock {
pub block_id: BlockId,
pub kind: BlockKind,
pub editable_policy: EditablePolicy,
pub display: FormBlockDisplay,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum FormBlockDisplay {
Heading {
level: u8,
text: String,
level_editable: bool,
},
Paragraph {
text: String,
},
List {
ordered: bool,
items: Vec<FormListItem>,
},
Blockquote {
text: String,
},
Code {
language: Option<String>,
code: String,
},
HorizontalRule,
Image {
alt: String,
src: String,
},
Table {
headers: Vec<String>,
rows: Vec<Vec<String>>,
col_count: usize,
},
RawIsland {
island_type: RawIslandType,
label_key: String,
text: String,
editable: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FormListItem {
pub ordinal: u32,
pub text: String,
pub task_checked: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FormProjection {
pub document_revision: u64,
pub blocks: Vec<FormBlock>,
}
impl FormProjection {
pub fn build(text: &str, index: &MarkdownIndex) -> Self {
let blocks = index
.blocks
.iter()
.map(|b| FormBlock {
block_id: b.block_id,
kind: b.kind,
editable_policy: b.editable_policy,
display: display_for(text, index, b),
})
.collect();
Self {
document_revision: index.document_revision,
blocks,
}
}
}
fn display_for(text: &str, index: &MarkdownIndex, block: &BlockNode) -> FormBlockDisplay {
if block.editable_policy == EditablePolicy::RawIslandOnly {
let island = index
.raw_islands
.iter()
.find(|i| i.block_id == block.block_id);
let island_type = island
.map(|i| i.island_type)
.unwrap_or(RawIslandType::UnknownExtension);
return FormBlockDisplay::RawIsland {
island_type,
label_key: island_type.label_key().to_string(),
text: slice(text, block.source_range.start, block.source_range.end),
editable: true,
};
}
let content = |r: Option<crate::range::ByteRange>| {
r.map(|r| slice(text, r.start, r.end)).unwrap_or_default()
};
match block.kind {
BlockKind::Heading => {
let first = slice(text, block.source_range.start, block.source_range.end);
let level_editable = first.trim_start().starts_with('#');
FormBlockDisplay::Heading {
level: block.heading_level.unwrap_or(1),
text: content(block.content_range),
level_editable,
}
}
BlockKind::Paragraph => FormBlockDisplay::Paragraph {
text: content(block.content_range),
},
BlockKind::Blockquote => FormBlockDisplay::Blockquote {
text: content(block.content_range),
},
BlockKind::FencedCode => FormBlockDisplay::Code {
language: block.code_language.clone(),
code: content(block.content_range),
},
BlockKind::HorizontalRule => FormBlockDisplay::HorizontalRule,
BlockKind::BulletList | BlockKind::OrderedList | BlockKind::TaskList => {
FormBlockDisplay::List {
ordered: block.kind == BlockKind::OrderedList,
items: block
.items
.iter()
.map(|it| FormListItem {
ordinal: it.ordinal,
text: slice(text, it.content_range.start, it.content_range.end),
task_checked: it.task_checked,
})
.collect(),
}
}
BlockKind::SimpleTable => {
let source = slice(text, block.source_range.start, block.source_range.end);
let (headers, rows) = parse_simple_table(&source);
let col_count = headers.len();
FormBlockDisplay::Table {
headers,
rows,
col_count,
}
}
BlockKind::HtmlBlock => FormBlockDisplay::RawIsland {
island_type: RawIslandType::HtmlBlock,
label_key: RawIslandType::HtmlBlock.label_key().to_string(),
text: slice(text, block.source_range.start, block.source_range.end),
editable: true,
},
_ => FormBlockDisplay::RawIsland {
island_type: RawIslandType::UnknownExtension,
label_key: RawIslandType::UnknownExtension.label_key().to_string(),
text: slice(text, block.source_range.start, block.source_range.end),
editable: true,
},
}
}
fn slice(text: &str, start: usize, end: usize) -> String {
text.get(start..end).unwrap_or_default().to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum InlineFormat {
Bold,
Italic,
Code,
Link,
}
impl InlineFormat {
pub fn open_marker(self) -> &'static str {
match self {
InlineFormat::Bold => "**",
InlineFormat::Italic => "_",
InlineFormat::Code => "`",
InlineFormat::Link => "[",
}
}
pub fn close_marker(self) -> &'static str {
match self {
InlineFormat::Bold => "**",
InlineFormat::Italic => "_",
InlineFormat::Code => "`",
InlineFormat::Link => "]", }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum FormBlockEdit {
ReplacePlainText {
text: String,
},
SetHeadingLevel {
level: u8,
},
ToggleTaskChecked {
item_ordinal: u32,
checked: bool,
},
ReplaceListItemText {
item_ordinal: u32,
text: String,
},
ReplaceCodeBlock {
language: Option<String>,
code: String,
},
ReplaceRawIsland {
text: String,
},
DeleteBlock,
ReplaceImage {
alt: String,
src: String,
},
ReplaceTableCell {
row: usize,
col: usize,
text: String,
},
AddTableRow,
ToggleInline {
kind: InlineFormat,
utf16_start: usize,
utf16_len: usize,
link_url: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FormEditCommand {
pub base_revision: u64,
pub block_id: BlockId,
pub client_block_fingerprint: Option<BlockFingerprint>,
pub edit: FormBlockEdit,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
pub enum FormEditError {
#[error("document revision mismatch: command base {base}, current {current}")]
DocumentRevisionMismatch { base: u64, current: u64 },
#[error("block not found for the given id")]
BlockNotFound,
#[error("block fingerprint mismatch; projection is stale")]
BlockFingerprintMismatch,
#[error("list item {ordinal} not found")]
ItemNotFound { ordinal: u32 },
#[error("edit operation is not supported for this block: {reason}")]
UnsupportedEditOperation { reason: String },
#[error("invalid edit payload: {reason}")]
InvalidEditPayload { reason: String },
}
fn parse_simple_table(source: &str) -> (Vec<String>, Vec<Vec<String>>) {
let parse_row = |line: &str| -> Vec<String> {
let trimmed = line.trim().trim_start_matches('|').trim_end_matches('|');
trimmed.split('|').map(|c| c.trim().to_string()).collect()
};
let is_sep = |line: &str| {
let t = line.trim();
t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ')) && t.contains('-')
};
let mut rows = source.lines().filter(|l| !is_sep(l)).map(parse_row);
let headers = rows.next().unwrap_or_default();
(headers, rows.collect())
}