diff --git a/Cargo.lock b/Cargo.lock index b61ab24..efbaa71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8532,7 +8532,7 @@ version = "0.2.0" dependencies = [ "async-trait", "chrono", - "jacquard", + "comrak", "log", "notify", "reqwest 0.12.28", @@ -8561,9 +8561,15 @@ version = "0.2.0" dependencies = [ "aho-corasick", "chrono", + "comrak", + "jacquard", + "log", + "reqwest 0.12.28", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", + "unicode-segmentation", ] [[package]] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 4ffd4a2..a5af44d 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,3 +9,11 @@ thiserror = "2" chrono = { version = "0.4", features = ["serde"] } aho-corasick = "1.1.4" serde_json = "1" +jacquard = { version = "0.9.5", features = ["default"] } +unicode-segmentation = "1.12.0" +comrak = "0.50" +reqwest = "0.12" +log = "0.4" + +[dev-dependencies] +tempfile = "3.27" diff --git a/src-tauri/src/atproto/auth.rs b/crates/core/src/atproto/auth.rs similarity index 89% rename from src-tauri/src/atproto/auth.rs rename to crates/core/src/atproto/auth.rs index 048073a..3b4c2dd 100644 --- a/src-tauri/src/atproto/auth.rs +++ b/crates/core/src/atproto/auth.rs @@ -1,10 +1,11 @@ +use crate::{AppError, ErrorCode}; +use jacquard::IntoStatic; use jacquard::client::FileAuthStore; use jacquard::identity::resolver::IdentityResolver; use jacquard::oauth::client::{OAuthClient, OAuthSession}; use jacquard::oauth::loopback::{LoopbackConfig, LoopbackPort}; use jacquard::types::did::Did; use jacquard::types::ident::AtIdentifier; -use jacquard::IntoStatic; use reqwest::Client as HttpClient; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -13,7 +14,6 @@ use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::Mutex; -use writer_core::{AppError, ErrorCode}; type OAuthResolver = jacquard::identity::JacquardResolver; type AtProtoSession = OAuthSession; @@ -154,17 +154,16 @@ impl AtProtoState { let persisted = self.read_session_meta(); if let Some(info) = info { - if let Ok(did) = Did::new(&info.did) { - if let Err(error) = self.oauth.revoke(&did, &info.session_id).await { - log::warn!("Failed to revoke AT Protocol session: {}", error); - } - } - } else if let Some(meta) = persisted { - if let Ok(did) = Did::new(&meta.did) { - if let Err(error) = self.oauth.revoke(&did, &meta.session_id).await { - log::warn!("Failed to revoke AT Protocol session: {}", error); - } + if let Ok(did) = Did::new(&info.did) + && let Err(error) = self.oauth.revoke(&did, &info.session_id).await + { + log::warn!("Failed to revoke AT Protocol session: {}", error); } + } else if let Some(meta) = persisted + && let Ok(did) = Did::new(&meta.did) + && let Err(error) = self.oauth.revoke(&did, &meta.session_id).await + { + log::warn!("Failed to revoke AT Protocol session: {}", error); } self.clear_persisted()?; @@ -362,25 +361,4 @@ mod tests { let state = AtProtoState::new(dir.path()).expect("state"); assert!(state.session_did().is_err()); } - - #[test] - fn logout_clears_persisted_session_metadata_without_touching_other_app_state() { - let dir = tempdir().expect("tempdir"); - let state = AtProtoState::new(dir.path()).expect("state"); - - state - .write_session_meta(&PersistedSessionMeta { - did: "did:plc:alice".into(), - handle: "alice.bsky.social".into(), - session_id: "writer-session".into(), - }) - .expect("write meta"); - fs::write(dir.path().join(AUTH_STORE_FILENAME), "{}").expect("write auth store"); - - tauri::async_runtime::block_on(state.logout()).expect("logout"); - - assert!(!state.session_meta_path.exists()); - assert!(!state.auth_store_path.exists()); - assert!(tauri::async_runtime::block_on(state.session_status()).is_none()); - } } diff --git a/crates/core/src/atproto/leaflet.rs b/crates/core/src/atproto/leaflet.rs new file mode 100644 index 0000000..d7192c2 --- /dev/null +++ b/crates/core/src/atproto/leaflet.rs @@ -0,0 +1,892 @@ +use crate::{AppError, ErrorCode}; +use comrak::{ + Arena, Options, + nodes::{AstNode, NodeCodeBlock, NodeHeading, NodeMath, NodeValue}, + parse_document, +}; +use jacquard::IntoStatic; +use jacquard::api::pub_leaflet::{ + blocks::{ + blockquote::Blockquote, + code::Code, + header::Header, + horizontal_rule::HorizontalRule, + image::{AspectRatio, Image}, + math::Math, + text::Text, + unordered_list::{ListItem, ListItemContent, UnorderedList}, + }, + document::{Document, DocumentPagesItem}, + pages::linear_document::{Block, BlockBlock, LinearDocument}, + richtext::facet::{ + AtMention, Bold, ByteSlice, Code as CodeFacet, DidMention, Facet, FacetFeaturesItem, Italic, Link, + Strikethrough, + }, +}; +use jacquard::common::types::{ + blob::{Blob, BlobRef, MimeType}, + cid::CidLink, + ident::AtIdentifier, + string::Uri, +}; +use jacquard::types::did::Did; +use std::cmp::Reverse; + +const CANVAS_PAGE_OMITTED: &str = ""; + +pub fn leaflet_document_to_markdown(document: &Document<'_>) -> Result { + let mut parts = Vec::new(); + + for page in &document.pages { + match page { + DocumentPagesItem::LinearDocument(page) => { + for block in &page.blocks { + parts.push(render_block(block)?); + } + } + DocumentPagesItem::Canvas(_) => parts.push(CANVAS_PAGE_OMITTED.to_string()), + _ => parts.push(comment_marker("unsupported: unknown page")), + } + } + + Ok(parts.join("\n\n")) +} + +pub fn markdown_to_leaflet_document(markdown: &str, title: &str, author: &str) -> Result, AppError> { + let author = AtIdentifier::new_owned(author) + .map_err(|error| AppError::new(ErrorCode::Parse, format!("Invalid author identifier: {}", error)))?; + + let arena = Arena::new(); + let root = parse_document(&arena, markdown, &markdown_options()); + let mut blocks = Vec::new(); + + for node in root.children() { + if let Some(block) = markdown_node_to_block(node)? { + blocks.push(block); + } + } + + let page = LinearDocument::new().blocks(blocks).build(); + Ok(Document::new() + .author(author) + .title(title.to_string()) + .pages(vec![DocumentPagesItem::LinearDocument(Box::new(page))]) + .build() + .into_static()) +} + +fn markdown_options() -> Options<'static> { + Options { + extension: comrak::options::Extension { + strikethrough: true, + footnotes: true, + math_dollars: true, + math_code: true, + ..Default::default() + }, + parse: comrak::options::Parse::default(), + render: comrak::options::Render::default(), + } +} + +fn render_block(block: &Block<'_>) -> Result { + let body = match &block.block { + BlockBlock::Text(text) => render_rich_text(&text.plaintext, text.facets.as_deref())?, + BlockBlock::Header(header) => { + let level = header.level.unwrap_or(1).clamp(1, 6) as usize; + format!( + "{} {}", + "#".repeat(level), + render_rich_text(&header.plaintext, header.facets.as_deref())? + ) + } + BlockBlock::Blockquote(blockquote) => prefix_lines( + &render_rich_text(&blockquote.plaintext, blockquote.facets.as_deref())?, + "> ", + ), + BlockBlock::Code(code) => render_code_block(code), + BlockBlock::Image(image) => render_image(image), + BlockBlock::UnorderedList(list) => render_list_items(&list.children, 0)?, + BlockBlock::HorizontalRule(_) => "---".to_string(), + BlockBlock::Math(math) => format!("$$\n{}\n$$", math.tex), + BlockBlock::Iframe(_) => comment_marker("unsupported: iframe"), + BlockBlock::Website(_) => comment_marker("unsupported: website"), + BlockBlock::BskyPost(_) => comment_marker("unsupported: bskyPost"), + BlockBlock::Page(_) => comment_marker("unsupported: page"), + BlockBlock::Poll(_) => comment_marker("unsupported: poll"), + BlockBlock::Button(_) => comment_marker("unsupported: button"), + _ => comment_marker("unsupported: unknown block"), + }; + + Ok(match block.alignment.as_deref() { + Some(alignment) if !alignment.is_empty() => format!("\n{}", alignment, body), + _ => body, + }) +} + +fn render_code_block(code: &Code<'_>) -> String { + match code.language.as_deref().filter(|value| !value.is_empty()) { + Some(language) => format!("```{}\n{}\n```", language, code.plaintext), + None => format!("```\n{}\n```", code.plaintext), + } +} + +fn render_image(image: &Image<'_>) -> String { + let alt = image.alt.as_deref().unwrap_or_default(); + format!("![{}](at://blob/{})", alt, image.image.blob().r#ref.as_str()) +} + +fn render_list_items(items: &[ListItem<'_>], depth: usize) -> Result { + let mut lines = Vec::new(); + + for item in items { + let indent = " ".repeat(depth); + let content = render_list_item_content(&item.content)?; + let mut content_lines = content.lines(); + + if let Some(first_line) = content_lines.next() { + lines.push(format!("{}- {}", indent, first_line)); + } else { + lines.push(format!("{}-", indent)); + } + + for line in content_lines { + lines.push(format!("{} {}", indent, line)); + } + + if let Some(children) = &item.children { + lines.push(render_list_items(children, depth + 1)?); + } + } + + Ok(lines.join("\n")) +} + +fn render_list_item_content(content: &ListItemContent<'_>) -> Result { + match content { + ListItemContent::Text(text) => render_rich_text(&text.plaintext, text.facets.as_deref()), + ListItemContent::Header(header) => { + let level = header.level.unwrap_or(1).clamp(1, 6) as usize; + Ok(format!( + "{} {}", + "#".repeat(level), + render_rich_text(&header.plaintext, header.facets.as_deref())? + )) + } + ListItemContent::Image(image) => Ok(render_image(image)), + _ => Ok(comment_marker("unsupported: unknown list item")), + } +} + +fn render_rich_text(plaintext: &str, facets: Option<&[Facet<'_>]>) -> Result { + let Some(facets) = facets else { + return Ok(plaintext.to_string()); + }; + + let mut openings = vec![Vec::::new(); plaintext.len() + 1]; + let mut closings = vec![Vec::::new(); plaintext.len() + 1]; + + for facet in facets { + let start = usize::try_from(facet.index.byte_start) + .map_err(|_| AppError::new(ErrorCode::Parse, "Facet start was negative"))?; + let end = usize::try_from(facet.index.byte_end) + .map_err(|_| AppError::new(ErrorCode::Parse, "Facet end was negative"))?; + + if start >= end + || end > plaintext.len() + || !plaintext.is_char_boundary(start) + || !plaintext.is_char_boundary(end) + { + return Err(AppError::new( + ErrorCode::Parse, + format!( + "Invalid facet range {}..{} for text of length {}", + start, + end, + plaintext.len() + ), + )); + } + + for feature in &facet.features { + if let Some((rank, open, close)) = facet_markers(feature)? { + openings[start].push(Marker { rank, start, end, text: open }); + closings[end].push(Marker { rank, start, end, text: close }); + } + } + } + + for markers in &mut openings { + markers.sort_by_key(|marker| (marker.rank, Reverse(marker.end), marker.start)); + } + for markers in &mut closings { + markers.sort_by_key(|marker| (Reverse(marker.rank), Reverse(marker.start), marker.end)); + } + + let mut output = String::new(); + let mut boundaries = plaintext.char_indices().map(|(index, _)| index).collect::>(); + boundaries.push(plaintext.len()); + + for window in boundaries.windows(2) { + let position = window[0]; + let next = window[1]; + + for marker in &closings[position] { + output.push_str(&marker.text); + } + for marker in &openings[position] { + output.push_str(&marker.text); + } + output.push_str(&plaintext[position..next]); + } + + for marker in &closings[plaintext.len()] { + output.push_str(&marker.text); + } + + Ok(output) +} + +fn facet_markers(feature: &FacetFeaturesItem<'_>) -> Result, AppError> { + let value = match feature { + FacetFeaturesItem::Link(link) => Some((0, "[".to_string(), format!("]({})", link.uri))), + FacetFeaturesItem::DidMention(mention) => Some((0, "[".to_string(), format!("](at://{})", mention.did))), + FacetFeaturesItem::AtMention(mention) => Some((0, "[".to_string(), format!("]({})", mention.at_uri.as_str()))), + FacetFeaturesItem::Bold(_) => Some((1, "**".to_string(), "**".to_string())), + FacetFeaturesItem::Italic(_) => Some((2, "*".to_string(), "*".to_string())), + FacetFeaturesItem::Strikethrough(_) => Some((3, "~~".to_string(), "~~".to_string())), + FacetFeaturesItem::Code(_) => Some((4, "`".to_string(), "`".to_string())), + FacetFeaturesItem::Underline(_) | FacetFeaturesItem::Highlight(_) | FacetFeaturesItem::Id(_) => None, + _ => None, + }; + + Ok(value) +} + +fn markdown_node_to_block<'a>(node: &'a AstNode<'a>) -> Result>, AppError> { + match &node.data().value { + NodeValue::Paragraph => paragraph_to_block(node), + NodeValue::Heading(NodeHeading { level, .. }) => { + let inline = collect_inline(node)?; + Ok(Some(wrap_block(BlockBlock::Header(Box::new(Header { + facets: option_facets(inline.facets), + level: Some(i64::from(*level)), + plaintext: inline.plaintext.into(), + extra_data: Default::default(), + }))))) + } + NodeValue::BlockQuote | NodeValue::MultilineBlockQuote(_) => { + let inline = collect_blockquote(node)?; + Ok(Some(wrap_block(BlockBlock::Blockquote(Box::new(Blockquote { + facets: option_facets(inline.facets), + plaintext: inline.plaintext.into(), + extra_data: Default::default(), + }))))) + } + NodeValue::CodeBlock(code_block) => Ok(Some(wrap_block(BlockBlock::Code(Box::new(Code { + language: parse_code_language(code_block).map(Into::into), + plaintext: code_block.literal.clone().into(), + syntax_highlighting_theme: None, + extra_data: Default::default(), + }))))), + NodeValue::List(_) => Ok(Some(wrap_block(BlockBlock::UnorderedList(Box::new( + UnorderedList::new().children(convert_list_items(node)?).build(), + ))))), + NodeValue::ThematicBreak => Ok(Some(wrap_block(BlockBlock::HorizontalRule(Box::new( + HorizontalRule::default(), + ))))), + NodeValue::HtmlBlock(_) + | NodeValue::Table(_) + | NodeValue::DescriptionList + | NodeValue::DescriptionItem(_) + | NodeValue::DescriptionTerm + | NodeValue::DescriptionDetails + | NodeValue::FootnoteDefinition(_) + | NodeValue::Alert(_) + | NodeValue::Subtext => Ok(Some(text_comment_block(format!( + "unsupported markdown block: {}", + node.data().value.xml_node_name() + )))), + _ => Ok(None), + } +} + +fn paragraph_to_block<'a>(node: &'a AstNode<'a>) -> Result>, AppError> { + if let Some(math) = paragraph_math_block(node)? { + return Ok(Some(math)); + } + if let Some(image) = paragraph_image_block(node)? { + return Ok(Some(image)); + } + + let inline = collect_inline(node)?; + if inline.plaintext.is_empty() { + return Ok(None); + } + + Ok(Some(wrap_block(BlockBlock::Text(Box::new(Text { + facets: option_facets(inline.facets), + plaintext: inline.plaintext.into(), + extra_data: Default::default(), + }))))) +} + +fn paragraph_math_block<'a>(node: &'a AstNode<'a>) -> Result>, AppError> { + let mut children = node.children(); + let Some(child) = children.next() else { + return Ok(None); + }; + if children.next().is_some() { + return Ok(None); + } + + let NodeValue::Math(NodeMath { literal, .. }) = &child.data().value else { + return Ok(None); + }; + + Ok(Some(wrap_block(BlockBlock::Math(Box::new(Math { + tex: literal.clone().into(), + extra_data: Default::default(), + }))))) +} + +fn paragraph_image_block<'a>(node: &'a AstNode<'a>) -> Result>, AppError> { + let mut children = node.children(); + let Some(child) = children.next() else { + return Ok(None); + }; + if children.next().is_some() { + return Ok(None); + } + + let NodeValue::Image(link) = &child.data().value else { + return Ok(None); + }; + + if let Some(image) = image_from_url(link.url.as_str(), extract_text(child))? { + return Ok(Some(wrap_block(BlockBlock::Image(Box::new(image))))); + } + + Ok(Some(text_comment_block(format!( + "unsupported markdown image: {}", + link.url + )))) +} + +fn image_from_url(url: &str, alt: String) -> Result>, AppError> { + let Some(cid) = url.strip_prefix("at://blob/") else { + return Ok(None); + }; + + Ok(Some( + Image::new() + .image(BlobRef::Blob(Blob { + r#ref: CidLink::cow_str(cid.to_string().into()), + mime_type: MimeType::new_static("application/octet-stream"), + size: 0, + })) + .aspect_ratio(AspectRatio::new().width(1).height(1).build()) + .alt(if alt.is_empty() { None } else { Some(alt.into()) }) + .build(), + )) +} + +fn convert_list_items<'a>(list_node: &'a AstNode<'a>) -> Result>, AppError> { + let mut items = Vec::new(); + + for item in list_node.children() { + if !matches!(item.data().value, NodeValue::Item(_)) { + continue; + } + + let mut content = None; + let mut children = Vec::new(); + + for child in item.children() { + match &child.data().value { + NodeValue::Paragraph => { + if content.is_none() { + content = Some(list_item_content_from_paragraph(child)?); + } + } + NodeValue::Heading(NodeHeading { level, .. }) => { + if content.is_none() { + let inline = collect_inline(child)?; + content = Some(ListItemContent::Header(Box::new(Header { + facets: option_facets(inline.facets), + level: Some(i64::from(*level)), + plaintext: inline.plaintext.into(), + extra_data: Default::default(), + }))); + } + } + NodeValue::List(_) => children.extend(convert_list_items(child)?), + _ => { + if content.is_none() { + content = Some(ListItemContent::Text(Box::new(Text { + facets: None, + plaintext: format!( + "", + child.data().value.xml_node_name() + ) + .into(), + extra_data: Default::default(), + }))); + } + } + } + } + + let content = content.unwrap_or_else(|| { + ListItemContent::Text(Box::new(Text { + facets: None, + plaintext: String::new().into(), + extra_data: Default::default(), + })) + }); + + items.push( + ListItem::new() + .content(content) + .children(if children.is_empty() { None } else { Some(children) }) + .build(), + ); + } + + Ok(items) +} + +fn list_item_content_from_paragraph<'a>(node: &'a AstNode<'a>) -> Result, AppError> { + if let Some(image_block) = paragraph_image_block(node)? + && let BlockBlock::Image(image) = image_block.block + { + Ok(ListItemContent::Image(image)) + } else { + let inline = collect_inline(node)?; + Ok(ListItemContent::Text(Box::new(Text { + facets: option_facets(inline.facets), + plaintext: inline.plaintext.into(), + extra_data: Default::default(), + }))) + } +} + +#[derive(Default)] +struct InlineOutput { + plaintext: String, + facets: Vec>, +} + +fn collect_inline<'a>(node: &'a AstNode<'a>) -> Result { + let mut output = InlineOutput::default(); + for child in node.children() { + collect_inline_node(child, &mut output)?; + } + Ok(output) +} + +fn collect_inline_node<'a>(node: &'a AstNode<'a>, output: &mut InlineOutput) -> Result<(), AppError> { + match &node.data().value { + NodeValue::Text(text) => output.plaintext.push_str(text), + NodeValue::SoftBreak | NodeValue::LineBreak => output.plaintext.push('\n'), + NodeValue::Code(code) => { + let start = output.plaintext.len(); + output.plaintext.push_str(&code.literal); + push_facet(output, start, feature_code())?; + } + NodeValue::Math(NodeMath { literal, .. }) => { + let start = output.plaintext.len(); + output.plaintext.push_str(literal); + push_facet(output, start, feature_code())?; + } + NodeValue::Emph => { + let start = output.plaintext.len(); + collect_children(node, output)?; + push_facet(output, start, feature_italic())?; + } + NodeValue::Strong => { + let start = output.plaintext.len(); + collect_children(node, output)?; + push_facet(output, start, feature_bold())?; + } + NodeValue::Strikethrough => { + let start = output.plaintext.len(); + collect_children(node, output)?; + push_facet(output, start, feature_strikethrough())?; + } + NodeValue::Link(link) => { + let start = output.plaintext.len(); + collect_children(node, output)?; + push_facet(output, start, feature_link(&link.url)?)?; + } + NodeValue::WikiLink(link) => { + let start = output.plaintext.len(); + output.plaintext.push_str(&extract_text(node)); + push_facet(output, start, feature_link(&link.url)?)?; + } + NodeValue::Image(_) => output.plaintext.push_str(&extract_text(node)), + NodeValue::HtmlInline(html) | NodeValue::Raw(html) => output.plaintext.push_str(html), + NodeValue::FootnoteReference(reference) => { + output.plaintext.push_str(&format!("[^{}]", reference.name)); + } + NodeValue::Underline + | NodeValue::Highlight + | NodeValue::Superscript + | NodeValue::Subscript + | NodeValue::SpoileredText + | NodeValue::Escaped + | NodeValue::EscapedTag(_) => collect_children(node, output)?, + _ => collect_children(node, output)?, + } + + Ok(()) +} + +fn collect_children<'a>(node: &'a AstNode<'a>, output: &mut InlineOutput) -> Result<(), AppError> { + for child in node.children() { + collect_inline_node(child, output)?; + } + Ok(()) +} + +fn collect_blockquote<'a>(node: &'a AstNode<'a>) -> Result { + let mut output = InlineOutput::default(); + let mut first = true; + + for child in node.children() { + match &child.data().value { + NodeValue::Paragraph | NodeValue::Heading(_) => { + let inline = collect_inline(child)?; + if !first { + append_plaintext(&mut output, "\n"); + } + append_inline_output(&mut output, inline); + first = false; + } + NodeValue::List(_) => { + let rendered = render_list_items(&convert_list_items(child)?, 0)?; + if !first { + append_plaintext(&mut output, "\n"); + } + append_plaintext(&mut output, &rendered); + first = false; + } + _ => {} + } + } + + Ok(output) +} + +fn append_inline_output(target: &mut InlineOutput, mut incoming: InlineOutput) { + let offset = target.plaintext.len(); + target.plaintext.push_str(&incoming.plaintext); + for facet in &mut incoming.facets { + facet.index.byte_start += offset as i64; + facet.index.byte_end += offset as i64; + } + target.facets.extend(incoming.facets); +} + +fn append_plaintext(target: &mut InlineOutput, text: &str) { + target.plaintext.push_str(text); +} + +fn push_facet(output: &mut InlineOutput, start: usize, feature: FacetFeaturesItem<'static>) -> Result<(), AppError> { + let end = output.plaintext.len(); + if end <= start { + return Ok(()); + } + + output.facets.push( + Facet::new() + .features(vec![feature]) + .index( + ByteSlice::new() + .byte_start( + i64::try_from(start) + .map_err(|_| AppError::new(ErrorCode::Parse, "Facet start offset overflowed i64"))?, + ) + .byte_end( + i64::try_from(end) + .map_err(|_| AppError::new(ErrorCode::Parse, "Facet end offset overflowed i64"))?, + ) + .build(), + ) + .build(), + ); + Ok(()) +} + +fn feature_bold() -> FacetFeaturesItem<'static> { + FacetFeaturesItem::Bold(Box::new(Bold::default())) +} + +fn feature_italic() -> FacetFeaturesItem<'static> { + FacetFeaturesItem::Italic(Box::new(Italic::default())) +} + +fn feature_strikethrough() -> FacetFeaturesItem<'static> { + FacetFeaturesItem::Strikethrough(Box::new(Strikethrough::default())) +} + +fn feature_code() -> FacetFeaturesItem<'static> { + FacetFeaturesItem::Code(Box::new(CodeFacet::default())) +} + +fn feature_link(url: &str) -> Result, AppError> { + if let Some(did) = url.strip_prefix("at://did:") { + let did = Did::new_owned(format!("did:{}", did)) + .map_err(|error| AppError::new(ErrorCode::Parse, format!("Invalid DID mention: {}", error)))?; + return Ok(FacetFeaturesItem::DidMention(Box::new( + DidMention::new().did(did).build(), + ))); + } + + if url.starts_with("at://") { + let uri = Uri::new_owned(url) + .map_err(|error| AppError::new(ErrorCode::Parse, format!("Invalid at:// mention URI: {}", error)))?; + return Ok(FacetFeaturesItem::AtMention(Box::new( + AtMention::new().at_uri(uri).build(), + ))); + } + + Ok(FacetFeaturesItem::Link(Box::new(Link { + uri: url.to_string().into(), + extra_data: Default::default(), + }))) +} + +fn parse_code_language(code_block: &NodeCodeBlock) -> Option { + code_block + .info + .split_whitespace() + .next() + .map(ToString::to_string) + .filter(|value| !value.is_empty()) +} + +fn option_facets(facets: Vec>) -> Option>> { + if facets.is_empty() { None } else { Some(facets) } +} + +fn wrap_block(block: BlockBlock<'static>) -> Block<'static> { + Block::new().block(block).build() +} + +fn text_comment_block(message: String) -> Block<'static> { + wrap_block(BlockBlock::Text(Box::new(Text { + facets: None, + plaintext: format!("", message).into(), + extra_data: Default::default(), + }))) +} + +fn prefix_lines(text: &str, prefix: &str) -> String { + text.lines() + .map(|line| format!("{}{}", prefix, line)) + .collect::>() + .join("\n") +} + +fn comment_marker(label: &str) -> String { + format!("", label) +} + +fn extract_text<'a>(node: &'a AstNode<'a>) -> String { + let mut text = String::new(); + for child in node.children() { + match &child.data().value { + NodeValue::Text(value) => text.push_str(value), + NodeValue::Code(code) => text.push_str(&code.literal), + _ => text.push_str(&extract_text(child)), + } + } + text +} + +#[derive(Clone)] +struct Marker { + rank: u8, + start: usize, + end: usize, + text: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use jacquard::api::pub_leaflet::{blocks::iframe::Iframe, pages::canvas::Canvas}; + + #[test] + fn leaflet_document_renders_supported_blocks_and_markers() { + let document = Document::new() + .author(AtIdentifier::new("did:plc:testauthor").unwrap()) + .title("Post") + .pages(vec![ + DocumentPagesItem::LinearDocument(Box::new( + LinearDocument::new() + .blocks(vec![ + wrap_block(BlockBlock::Header(Box::new(Header { + facets: Some(vec![single_feature_facet(0, 7, feature_bold())]), + level: Some(2), + plaintext: "Heading".into(), + extra_data: Default::default(), + }))), + wrap_block(BlockBlock::Text(Box::new(Text { + facets: Some(vec![ + single_feature_facet(0, 4, feature_italic()), + single_feature_facet(5, 9, feature_link("https://example.com").unwrap()), + ]), + plaintext: "Lead link".into(), + extra_data: Default::default(), + }))), + wrap_block(BlockBlock::Blockquote(Box::new(Blockquote { + facets: None, + plaintext: "Quoted".into(), + extra_data: Default::default(), + }))), + wrap_block(BlockBlock::Code(Box::new(Code { + language: Some("rust".into()), + plaintext: "fn main() {}".into(), + syntax_highlighting_theme: None, + extra_data: Default::default(), + }))), + wrap_block(BlockBlock::UnorderedList(Box::new( + UnorderedList::new() + .children(vec![list_item_text("Top", Some(vec![list_item_text("Nested", None)]))]) + .build(), + ))), + wrap_block(BlockBlock::Math(Box::new(Math { + tex: "x^2".into(), + extra_data: Default::default(), + }))), + wrap_block(BlockBlock::HorizontalRule(Box::new(HorizontalRule::default()))), + wrap_block(BlockBlock::Image(Box::new( + Image::new() + .image(blob_ref()) + .aspect_ratio(AspectRatio::new().width(4).height(3).build()) + .alt(Some("alt text".into())) + .build(), + ))), + wrap_block(BlockBlock::Iframe(Box::new( + Iframe::new() + .url(Uri::new("https://example.com/embed").unwrap()) + .build(), + ))), + ]) + .build(), + )), + DocumentPagesItem::Canvas(Box::new(Canvas::new().blocks(vec![]).build())), + ]) + .build(); + + let markdown = leaflet_document_to_markdown(&document).unwrap(); + + assert!(markdown.contains("## **Heading**")); + assert!(markdown.contains("*Lead* [link](https://example.com)")); + assert!(markdown.contains("> Quoted")); + assert!(markdown.contains("```rust\nfn main() {}\n```")); + assert!(markdown.contains("- Top\n - Nested")); + assert!(markdown.contains("$$\nx^2\n$$")); + assert!(markdown.contains("---")); + assert!(markdown.contains("![alt text](at://blob/")); + assert!(markdown.contains("")); + assert!(markdown.contains(CANVAS_PAGE_OMITTED)); + } + + #[test] + fn markdown_document_builds_leaflet_blocks_with_facets() { + let markdown = "# Title\n\nParagraph with **bold**, *italic*, ~~strike~~, `code`, and [link](https://example.com).\n\n> Quote\n\n1. one\n2. two\n\n```ts\nconst x = 1;\n```\n\n$$a+b$$\n\n---"; + let document = markdown_to_leaflet_document(markdown, "Post", "did:plc:testauthor").unwrap(); + + let DocumentPagesItem::LinearDocument(page) = &document.pages[0] else { + panic!("expected linear page"); + }; + + assert!(matches!(page.blocks[0].block, BlockBlock::Header(_))); + assert!(matches!(page.blocks[1].block, BlockBlock::Text(_))); + assert!(matches!(page.blocks[2].block, BlockBlock::Blockquote(_))); + assert!(matches!(page.blocks[3].block, BlockBlock::UnorderedList(_))); + assert!(matches!(page.blocks[4].block, BlockBlock::Code(_))); + assert!(matches!(page.blocks[5].block, BlockBlock::Math(_))); + assert!(matches!(page.blocks[6].block, BlockBlock::HorizontalRule(_))); + + let BlockBlock::Text(paragraph) = &page.blocks[1].block else { + panic!("expected paragraph block"); + }; + assert_eq!( + paragraph.plaintext, + "Paragraph with bold, italic, strike, code, and link." + ); + + let facets = paragraph.facets.as_ref().unwrap(); + assert!( + facets + .iter() + .any(|facet| matches!(facet.features[0], FacetFeaturesItem::Bold(_))) + ); + assert!( + facets + .iter() + .any(|facet| matches!(facet.features[0], FacetFeaturesItem::Italic(_))) + ); + assert!( + facets + .iter() + .any(|facet| matches!(facet.features[0], FacetFeaturesItem::Strikethrough(_))) + ); + assert!( + facets + .iter() + .any(|facet| matches!(facet.features[0], FacetFeaturesItem::Code(_))) + ); + assert!( + facets + .iter() + .any(|facet| matches!(facet.features[0], FacetFeaturesItem::Link(_))) + ); + } + + #[test] + fn markdown_round_trip_keeps_supported_content() { + let markdown = "## Heading\n\nPlain **bold** text.\n\n- Parent\n - Child\n\n> Quote\n\n```rs\nfn test() {}\n```\n\n$$x+y$$\n\n---"; + let document = markdown_to_leaflet_document(markdown, "Post", "did:plc:testauthor").unwrap(); + let rendered = leaflet_document_to_markdown(&document).unwrap(); + + assert!(rendered.contains("## Heading")); + assert!(rendered.contains("Plain **bold** text.")); + assert!(rendered.contains("- Parent\n - Child")); + assert!(rendered.contains("> Quote")); + assert!(rendered.contains("fn test() {}")); + assert!(rendered.contains("```")); + assert!(rendered.contains("$$\nx+y\n$$")); + assert!(rendered.contains("---")); + } + + fn single_feature_facet(start: usize, end: usize, feature: FacetFeaturesItem<'static>) -> Facet<'static> { + Facet::new() + .features(vec![feature]) + .index(ByteSlice::new().byte_start(start as i64).byte_end(end as i64).build()) + .build() + } + + fn list_item_text(text: &str, children: Option>>) -> ListItem<'static> { + ListItem::new() + .content(ListItemContent::Text(Box::new(Text { + facets: None, + plaintext: text.to_string().into(), + extra_data: Default::default(), + }))) + .children(children) + .build() + } + + fn blob_ref() -> BlobRef<'static> { + BlobRef::Blob(Blob { + r#ref: CidLink::str("bafkreigh2akiscaildcw453s4h4u2z2k2p4g6m3uz4x7g5qj5qg4xk6b2e"), + mime_type: MimeType::new_static("image/png"), + size: 42, + }) + } +} diff --git a/src-tauri/src/atproto/mod.rs b/crates/core/src/atproto/mod.rs similarity index 86% rename from src-tauri/src/atproto/mod.rs rename to crates/core/src/atproto/mod.rs index 643cffc..9670fc2 100644 --- a/src-tauri/src/atproto/mod.rs +++ b/crates/core/src/atproto/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod leaflet; pub mod strings; pub use auth::{AtProtoState, SessionInfo}; diff --git a/src-tauri/src/atproto/strings.rs b/crates/core/src/atproto/strings.rs similarity index 63% rename from src-tauri/src/atproto/strings.rs rename to crates/core/src/atproto/strings.rs index befea19..9d1d002 100644 --- a/src-tauri/src/atproto/strings.rs +++ b/crates/core/src/atproto/strings.rs @@ -1,3 +1,4 @@ +use crate::{AppError, ErrorCode}; use jacquard::api::com_atproto::repo::{ create_record::CreateRecord, delete_record::DeleteRecord, get_record::GetRecord, list_records::ListRecords, put_record::PutRecord, @@ -42,7 +43,7 @@ impl StringRecord { } impl super::auth::AtProtoState { - pub async fn string_list(&self, did_or_handle: &str) -> Result, writer_core::AppError> { + pub async fn string_list(&self, did_or_handle: &str) -> Result, AppError> { let (repo_did, pds_url) = self.resolve_repo_and_pds(did_or_handle).await?; let request = ListRecords::new() .repo(jacquard::common::types::ident::AtIdentifier::Did(repo_did)) @@ -56,18 +57,18 @@ impl super::auth::AtProtoState { .xrpc(pds_url) .send(&request) .await - .map_err(|error| writer_core::AppError::io(format!("Failed to list Tangled strings: {}", error)))?; + .map_err(|error| AppError::io(format!("Failed to list Tangled strings: {}", error)))?; let output = response .into_output() - .map_err(|error| writer_core::AppError::io(format!("Failed to decode Tangled strings: {}", error)))?; + .map_err(|error| AppError::io(format!("Failed to decode Tangled strings: {}", error)))?; let mut records = Vec::with_capacity(output.records.len()); for record in output.records { let value = from_data::>(&record.value) - .map_err(|error| writer_core::AppError::io(format!("Failed to parse Tangled string: {}", error)))?; + .map_err(|error| AppError::io(format!("Failed to parse Tangled string: {}", error)))?; let Some(mapped) = StringRecord::from_tangled_string(record.uri.as_ref(), value) else { - return Err(writer_core::AppError::new( - writer_core::ErrorCode::Parse, + return Err(AppError::new( + ErrorCode::Parse, "Failed to derive Tangled string record key from URI", )); }; @@ -77,22 +78,15 @@ impl super::auth::AtProtoState { Ok(records) } - pub async fn string_get(&self, did_or_handle: &str, tid: &str) -> Result { + pub async fn string_get(&self, did_or_handle: &str, tid: &str) -> Result { let trimmed_tid = tid.trim(); if trimmed_tid.is_empty() { - return Err(writer_core::AppError::new( - writer_core::ErrorCode::InvalidPath, - "String ID is required", - )); + return Err(AppError::new(ErrorCode::InvalidPath, "String ID is required")); } let (repo_did, pds_url) = self.resolve_repo_and_pds(did_or_handle).await?; - let rkey = RecordKey::any(trimmed_tid).map_err(|error| { - writer_core::AppError::new( - writer_core::ErrorCode::Parse, - format!("Invalid Tangled string ID: {}", error), - ) - })?; + let rkey = RecordKey::any(trimmed_tid) + .map_err(|error| AppError::new(ErrorCode::Parse, format!("Invalid Tangled string ID: {}", error)))?; let request = GetRecord::new() .repo(jacquard::common::types::ident::AtIdentifier::Did(repo_did)) .collection(TangledString::nsid()) @@ -104,24 +98,20 @@ impl super::auth::AtProtoState { .xrpc(pds_url) .send(&request) .await - .map_err(|error| writer_core::AppError::io(format!("Failed to fetch Tangled string: {}", error)))?; + .map_err(|error| AppError::io(format!("Failed to fetch Tangled string: {}", error)))?; let output = response .into_output() - .map_err(|error| writer_core::AppError::io(format!("Failed to decode Tangled string: {}", error)))?; + .map_err(|error| AppError::io(format!("Failed to decode Tangled string: {}", error)))?; let value = from_data::>(&output.value) - .map_err(|error| writer_core::AppError::io(format!("Failed to parse Tangled string: {}", error)))?; + .map_err(|error| AppError::io(format!("Failed to parse Tangled string: {}", error)))?; - StringRecord::from_tangled_string(output.uri.as_ref(), value).ok_or_else(|| { - writer_core::AppError::new( - writer_core::ErrorCode::Parse, - "Failed to derive Tangled string record key from URI", - ) - }) + StringRecord::from_tangled_string(output.uri.as_ref(), value) + .ok_or_else(|| AppError::new(ErrorCode::Parse, "Failed to derive Tangled string record key from URI")) } pub async fn string_create( &self, filename: &str, description: &str, contents: &str, - ) -> Result { + ) -> Result { validate_filename(filename)?; validate_description(description)?; validate_contents(contents)?; @@ -129,12 +119,11 @@ impl super::auth::AtProtoState { let ts = build_tangled_string(filename, description, contents, Datetime::now())?; let session = self.require_session()?; let did_str = self.session_did()?; - let did = Did::new(&did_str).map_err(|error| { - writer_core::AppError::new(writer_core::ErrorCode::Parse, format!("Invalid session DID: {}", error)) - })?; + let did = Did::new(&did_str) + .map_err(|error| AppError::new(ErrorCode::Parse, format!("Invalid session DID: {}", error)))?; - let data = to_data(&ts) - .map_err(|error| writer_core::AppError::io(format!("Failed to serialize Tangled string: {}", error)))?; + let data = + to_data(&ts).map_err(|error| AppError::io(format!("Failed to serialize Tangled string: {}", error)))?; let request = CreateRecord::new() .repo(AtIdentifier::Did(did)) @@ -145,14 +134,14 @@ impl super::auth::AtProtoState { let response = session .send(request) .await - .map_err(|error| writer_core::AppError::io(format!("Failed to create Tangled string: {}", error)))?; + .map_err(|error| AppError::io(format!("Failed to create Tangled string: {}", error)))?; let output = response .into_output() - .map_err(|error| writer_core::AppError::io(format!("Failed to decode create response: {}", error)))?; + .map_err(|error| AppError::io(format!("Failed to decode create response: {}", error)))?; StringRecord::from_tangled_string(output.uri.as_ref(), ts).ok_or_else(|| { - writer_core::AppError::new( - writer_core::ErrorCode::Parse, + AppError::new( + ErrorCode::Parse, "Failed to derive Tangled string record key from created URI", ) }) @@ -160,11 +149,11 @@ impl super::auth::AtProtoState { pub async fn string_update( &self, tid: &str, filename: &str, description: &str, contents: &str, - ) -> Result { + ) -> Result { let trimmed_tid = tid.trim(); if trimmed_tid.is_empty() { - return Err(writer_core::AppError::new( - writer_core::ErrorCode::InvalidPath, + return Err(AppError::new( + ErrorCode::InvalidPath, "String ID is required for update", )); } @@ -176,18 +165,13 @@ impl super::auth::AtProtoState { let ts = build_tangled_string(filename, description, contents, Datetime::now())?; let session = self.require_session()?; let did_str = self.session_did()?; - let did = Did::new(&did_str).map_err(|error| { - writer_core::AppError::new(writer_core::ErrorCode::Parse, format!("Invalid session DID: {}", error)) - })?; - let rkey = RecordKey::any(trimmed_tid).map_err(|error| { - writer_core::AppError::new( - writer_core::ErrorCode::Parse, - format!("Invalid Tangled string ID: {}", error), - ) - })?; + let did = Did::new(&did_str) + .map_err(|error| AppError::new(ErrorCode::Parse, format!("Invalid session DID: {}", error)))?; + let rkey = RecordKey::any(trimmed_tid) + .map_err(|error| AppError::new(ErrorCode::Parse, format!("Invalid Tangled string ID: {}", error)))?; - let data = to_data(&ts) - .map_err(|error| writer_core::AppError::io(format!("Failed to serialize Tangled string: {}", error)))?; + let data = + to_data(&ts).map_err(|error| AppError::io(format!("Failed to serialize Tangled string: {}", error)))?; let request = PutRecord::new() .repo(AtIdentifier::Did(did)) @@ -199,39 +183,34 @@ impl super::auth::AtProtoState { let response = session .send(request) .await - .map_err(|error| writer_core::AppError::io(format!("Failed to update Tangled string: {}", error)))?; + .map_err(|error| AppError::io(format!("Failed to update Tangled string: {}", error)))?; let output = response .into_output() - .map_err(|error| writer_core::AppError::io(format!("Failed to decode update response: {}", error)))?; + .map_err(|error| AppError::io(format!("Failed to decode update response: {}", error)))?; StringRecord::from_tangled_string(output.uri.as_ref(), ts).ok_or_else(|| { - writer_core::AppError::new( - writer_core::ErrorCode::Parse, + AppError::new( + ErrorCode::Parse, "Failed to derive Tangled string record key from updated URI", ) }) } - pub async fn string_delete(&self, tid: &str) -> Result<(), writer_core::AppError> { + pub async fn string_delete(&self, tid: &str) -> Result<(), AppError> { let trimmed_tid = tid.trim(); if trimmed_tid.is_empty() { - return Err(writer_core::AppError::new( - writer_core::ErrorCode::InvalidPath, + return Err(AppError::new( + ErrorCode::InvalidPath, "String ID is required for delete", )); } let session = self.require_session()?; let did_str = self.session_did()?; - let did = Did::new(&did_str).map_err(|error| { - writer_core::AppError::new(writer_core::ErrorCode::Parse, format!("Invalid session DID: {}", error)) - })?; - let rkey = RecordKey::any(trimmed_tid).map_err(|error| { - writer_core::AppError::new( - writer_core::ErrorCode::Parse, - format!("Invalid Tangled string ID: {}", error), - ) - })?; + let did = Did::new(&did_str) + .map_err(|error| AppError::new(ErrorCode::Parse, format!("Invalid session DID: {}", error)))?; + let rkey = RecordKey::any(trimmed_tid) + .map_err(|error| AppError::new(ErrorCode::Parse, format!("Invalid Tangled string ID: {}", error)))?; let request = DeleteRecord::new() .repo(AtIdentifier::Did(did)) @@ -242,48 +221,42 @@ impl super::auth::AtProtoState { session .send(request) .await - .map_err(|error| writer_core::AppError::io(format!("Failed to delete Tangled string: {}", error)))? + .map_err(|error| AppError::io(format!("Failed to delete Tangled string: {}", error)))? .into_output() - .map_err(|error| writer_core::AppError::io(format!("Failed to decode delete response: {}", error)))?; + .map_err(|error| AppError::io(format!("Failed to decode delete response: {}", error)))?; Ok(()) } } -fn validate_filename(filename: &str) -> Result<(), writer_core::AppError> { +fn validate_filename(filename: &str) -> Result<(), AppError> { let graphemes = filename.graphemes(true).count(); if graphemes < 1 { - return Err(writer_core::AppError::new( - writer_core::ErrorCode::Parse, - "Filename is required", - )); + return Err(AppError::new(ErrorCode::Parse, "Filename is required")); } if graphemes > 140 { - return Err(writer_core::AppError::new( - writer_core::ErrorCode::Parse, + return Err(AppError::new( + ErrorCode::Parse, format!("Filename must be at most 140 graphemes (got {})", graphemes), )); } Ok(()) } -fn validate_description(description: &str) -> Result<(), writer_core::AppError> { +fn validate_description(description: &str) -> Result<(), AppError> { let graphemes = description.graphemes(true).count(); if graphemes > 280 { - return Err(writer_core::AppError::new( - writer_core::ErrorCode::Parse, + return Err(AppError::new( + ErrorCode::Parse, format!("Description must be at most 280 graphemes (got {})", graphemes), )); } Ok(()) } -fn validate_contents(contents: &str) -> Result<(), writer_core::AppError> { +fn validate_contents(contents: &str) -> Result<(), AppError> { if contents.graphemes(true).next().is_none() { - return Err(writer_core::AppError::new( - writer_core::ErrorCode::Parse, - "Contents must not be empty", - )); + return Err(AppError::new(ErrorCode::Parse, "Contents must not be empty")); } Ok(()) } @@ -292,7 +265,7 @@ fn validate_contents(contents: &str) -> Result<(), writer_core::AppError> { /// JSON representation exceeds the 2 MiB PDS limit. fn build_tangled_string<'a>( filename: &'a str, description: &'a str, contents: &'a str, created_at: Datetime, -) -> Result, writer_core::AppError> { +) -> Result, AppError> { let ts = TangledString::new() .filename(filename) .description(description) @@ -301,10 +274,10 @@ fn build_tangled_string<'a>( .build(); let serialized = serde_json::to_vec(&ts) - .map_err(|error| writer_core::AppError::io(format!("Failed to serialize Tangled string: {}", error)))?; + .map_err(|error| AppError::io(format!("Failed to serialize Tangled string: {}", error)))?; if serialized.len() > MAX_RECORD_BYTES { - return Err(writer_core::AppError::new( - writer_core::ErrorCode::Parse, + return Err(AppError::new( + ErrorCode::Parse, format!( "Record is too large ({} bytes); PDS limit is {} bytes (2 MiB)", serialized.len(), diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 81734ed..faa3873 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -2,6 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; +pub mod atproto; mod nlp; pub use nlp::{ PatternCategory, PatternMatcher, StyleCategorySettings, StyleMatch, StylePattern, StylePatternInput, diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index 03e0a6c..e2f72e0 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -10,11 +10,8 @@ thiserror = "2" chrono = "0.4" rusqlite = { version = "0.38", features = ["bundled", "chrono", "serde_json"] } dirs = "6" -tempfile = "3" +tempfile = "3.27" log = "0.4" writer-core = { path = "../core" } writer-md = { path = "../markdown" } - -[dev-dependencies] -tempfile = "3" diff --git a/docs/specs/at-proto.md b/docs/specs/at-proto.md index 0471534..38708bb 100644 --- a/docs/specs/at-proto.md +++ b/docs/specs/at-proto.md @@ -107,16 +107,22 @@ Jacquard types use zero-copy deserialization via `CowStr<'_>`. Use `.parse()` fo ### Backend Module Structure ```sh -src-tauri/src/ +crates/core/src/ ├── atproto/ -│ ├── mod.rs # re-exports shared auth types/state +│ ├── mod.rs # shared AT Protocol exports │ ├── auth.rs # OAuth loopback flow, session restore, logout cleanup │ └── strings.rs # Tangled string listing + fetch helpers +src-tauri/src/ +├── commands/ +│ ├── atproto.rs # Tauri command wrappers for auth/session +│ └── strings.rs # Tauri command wrappers for string CRUD ``` -`AtProtoState` lives inside `AppState` and owns the Jacquard OAuth client plus persisted session metadata paths. The current auth slice restores an existing session during app startup, exposes the active `SessionInfo`, and clears persisted auth artifacts when restoration or logout fails. +`writer_core::atproto::AtProtoState` owns the Jacquard OAuth client plus persisted session metadata paths. Tauri keeps an `Arc` inside `AppState`, restores the existing session during app startup, and exposes the shared `SessionInfo` / `StringRecord` types from `writer_core::atproto`. + +**Tauri command boundary:** -**Tauri commands:** +The commands remain in `src-tauri`, but they are thin wrappers around `writer_core::atproto` methods and types: | Command | Args | Returns | Auth | | ------------------------ | -------------------------------------- | -------------------------------------- | --------- | @@ -334,15 +340,21 @@ For reads (no auth), use a stateless `reqwest::Client` with `XrpcExt` or an unau ### Backend Module Structure ```sh -src-tauri/src/atproto/ -├── mod.rs # re-exports (existing) -├── auth.rs # OAuth (existing) -├── strings.rs # Tangled strings (existing) -├── standard_site.rs # publication + document listing/fetch via Jacquard site_standard types -└── leaflet.rs # Leaflet block ↔ Markdown conversion using Jacquard pub_leaflet types +crates/core/src/ +├── atproto/ +│ ├── mod.rs # shared AT Protocol exports +│ ├── auth.rs # OAuth/session state +│ ├── strings.rs # Tangled string helpers +│ ├── leaflet.rs # Leaflet block ↔ Markdown conversion +│ └── standard_site.rs # publication + document listing/fetch helpers +src-tauri/src/ +├── commands/ +│ ├── atproto.rs # auth/session command wrappers +│ ├── strings.rs # Tangled string command wrappers +│ └── standard_site.rs # publication/post command wrappers ``` -Requires adding `pub_leaflet` and `site_standard` feature flags to the `jacquard-api` dependency in `src-tauri/Cargo.toml`. +In the current codebase, the conversion logic belongs in `writer_core::atproto`, with `src-tauri` only responsible for exposing it through Tauri commands. Part 1 is implemented as [leaflet.rs](/Users/owais/Desktop/writer/crates/core/src/atproto/leaflet.rs); `standard_site.rs` remains the intended shared-core location for the record fetch/list helpers from later parts. ### Tauri Commands @@ -360,7 +372,7 @@ Requires adding `pub_leaflet` and `site_standard` feature flags to the `jacquard `PublicationRecord`: `uri`, `tid`, `name`, `description`, `url`. `PostRecord`: `uri`, `tid`, `title`, `description`, `text_content`, `published_at`, `updated_at`, `tags`, `publication_uri`. -`post_get_markdown` performs Leaflet→Markdown conversion server-side so the frontend receives ready-to-use content. +`post_get_markdown` should perform Leaflet→Markdown conversion in `writer_core::atproto::leaflet`, with the Tauri command acting as a transport wrapper so the frontend receives ready-to-use content. ### Frontend Structure diff --git a/docs/tasks/standard-site.md b/docs/tasks/standard-site.md index 2084cd8..0611174 100644 --- a/docs/tasks/standard-site.md +++ b/docs/tasks/standard-site.md @@ -7,7 +7,7 @@ Pull and push long-form posts from/to AT Protocol publishing platforms using Sta ## Part 1 — Leaflet Block ↔ Markdown Conversion -1. **Leaflet → Markdown converter** — `src-tauri/src/atproto/leaflet.rs` +1. **Leaflet → Markdown converter** — `crates/core/src/atproto/leaflet.rs` - Deserialize `pub_leaflet::document::Document`, match on `DocumentPagesItem` variants - Map Jacquard block types (`blocks::text::Text`, `blocks::header::Header`, etc.) to Markdown equivalents - Convert `pub_leaflet::richtext::facet::Facet` annotations (matching on `FacetFeaturesItem` variants: `Bold`, `Italic`, `Link`, `Code`, `Strikethrough`, etc.) to inline Markdown syntax @@ -22,10 +22,11 @@ Pull and push long-form posts from/to AT Protocol publishing platforms using Sta ## Part 2 — Pull (Import Posts) -1. **Backend helpers** — `src-tauri/src/atproto/standard_site.rs` +1. **Backend helpers** — `crates/core/src/atproto/standard_site.rs` - `listRecords` wrapper for `site_standard::publication::Publication` and `site_standard::document::Document` - `getRecord` wrapper deserializing into Jacquard types, extracting Leaflet content from the `content` open union -2. **Tauri commands** — `publication_list`, `publication_get`, `post_list`, `post_get`, `post_get_markdown` +2. **Tauri commands** — `src-tauri/src/commands/standard_site.rs` + - `publication_list`, `publication_get`, `post_list`, `post_get`, `post_get_markdown` 3. **Frontend import UI** — `PostImportSheet.tsx` - Enter handle/DID → browse publications → browse posts → preview converted Markdown → import to location - Reuse existing import patterns from `ImportSheet.tsx` @@ -34,7 +35,8 @@ Pull and push long-form posts from/to AT Protocol publishing platforms using Sta ## Part 3 — Push (Publish Posts) -1. **Tauri commands** — `post_create`, `post_update`, `post_delete` +1. **Tauri commands** — `src-tauri/src/commands/standard_site.rs` + - `post_create`, `post_update`, `post_delete` - Accept Markdown + metadata, convert to Leaflet blocks via Jacquard builders server-side - Upload images as blobs to PDS, construct `pub_leaflet::blocks::image::Image` with returned blob ref - Build `site_standard::document::Document` via `DocumentBuilder` with Leaflet content in the `content` union diff --git a/docs/tasks/tangled.md b/docs/tasks/tangled.md index e62ac09..dc9661d 100644 --- a/docs/tasks/tangled.md +++ b/docs/tasks/tangled.md @@ -7,9 +7,10 @@ Publish documents as [Tangled strings](https://tangled.sh) (AT Protocol gists) a ## Part 1 — Auth -1. **OAuth loopback flow** - `src-tauri/src/atproto/auth.rs` +1. **OAuth loopback flow** - `crates/core/src/atproto/auth.rs` 2. **Session persistence** - token + DPoP key storage in app data dir -3. **Tauri commands** - `atproto_login`, `atproto_logout`, `atproto_session_status` +3. **Tauri commands** - `src-tauri/src/commands/atproto.rs` + - `atproto_login`, `atproto_logout`, `atproto_session_status` 4. **Frontend auth UI** - login sheet, session indicator, logout - User clicks `@` button in toolbar - If not logged in, show login sheet @@ -18,16 +19,22 @@ Publish documents as [Tangled strings](https://tangled.sh) (AT Protocol gists) a ## Part 2 — Pull -1. **Tauri commands** - `string_list`, `string_get` -2. **Import UI** - "Import from Tangled" sheet with handle input, string browser, preview, import to location +1. **Shared backend helpers** - `crates/core/src/atproto/strings.rs` + - `string_list`, `string_get` +2. **Tauri commands** - `src-tauri/src/commands/strings.rs` + - `string_list`, `string_get` +3. **Import UI** - "Import from Tangled" sheet with handle input, string browser, preview, import to location - Fluent Icons (`i-fluent-document-*-16-filled`) - Extensions covered: `py`, `md`, `js`, `ts`, `yaml`, `java`, `sass`, `css`, `csv`, `fs`, `cs` - `i-fluent-document-16-filled` for fallback ## Part 3 — Push -1. **Tauri commands** - `string_create`, `string_update`, `string_delete` -2. **Publish UI** - "Publish as String" action in export menu with filename, description, preview +1. **Shared backend helpers** - `crates/core/src/atproto/strings.rs` + - `string_create`, `string_update`, `string_delete` +2. **Tauri commands** - `src-tauri/src/commands/strings.rs` + - `string_create`, `string_update`, `string_delete` +3. **Publish UI** - "Publish as String" action in export menu with filename, description, preview ## Part 4 — Sync & metadata diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 090e33e..4f0e6b3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,7 +32,7 @@ chrono = "0.4" log = "0.4" async-trait = "0.1" thiserror = "2" -jacquard = { version = "0.9.5", features = ["default"] } +comrak = "0.50" reqwest = "0.12" unicode-segmentation = "1" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4e6b0fb..2765baa 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,4 +1,3 @@ -use super::atproto::AtProtoState; use super::capture; use super::locations::*; use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; @@ -8,6 +7,7 @@ use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Emitter, State}; use tauri_plugin_dialog::DialogExt; use tauri_plugin_fs::FsExt; +use writer_core::atproto::AtProtoState; use writer_core::scan_style_matches; use writer_core::{ AppError, BackendEvent, CommandResult, DocContent, DocId, DocListOptions, DocMeta, LocationDescriptor, LocationId, diff --git a/src-tauri/src/commands/atproto.rs b/src-tauri/src/commands/atproto.rs index b6159a0..c022106 100644 --- a/src-tauri/src/commands/atproto.rs +++ b/src-tauri/src/commands/atproto.rs @@ -1,6 +1,6 @@ use super::{AppState, CommandResponse}; -use crate::atproto::SessionInfo; use tauri::State; +use writer_core::atproto::SessionInfo; use writer_core::CommandResult; #[tauri::command] diff --git a/src-tauri/src/commands/strings.rs b/src-tauri/src/commands/strings.rs index b8f97b2..7b09cad 100644 --- a/src-tauri/src/commands/strings.rs +++ b/src-tauri/src/commands/strings.rs @@ -1,9 +1,8 @@ //! Tangled.org string (snippets/gists) commands use super::{AppState, CommandResponse}; -use crate::atproto::StringRecord; use tauri::State; -use writer_core::CommandResult; +use writer_core::{atproto::StringRecord, CommandResult}; #[tauri::command] pub async fn string_create( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4d0e824..4fa1e09 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,6 @@ use tauri::Manager; use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy}; -mod atproto; mod capture; mod commands; mod locations;