From 04a2aa06d98807d366ec2b6e922f21827961bec5 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Fri, 20 Mar 2026 10:33:25 -0500 Subject: [PATCH] feat: add Standard.Site API integration (backend) * resolve jacquard via git --- Cargo.lock | 211 +++++--------- crates/core/Cargo.toml | 2 +- crates/core/src/atproto/leaflet.rs | 28 +- crates/core/src/atproto/mod.rs | 2 + crates/core/src/atproto/standard_site.rs | 343 +++++++++++++++++++++++ src-tauri/src/commands.rs | 2 + src-tauri/src/commands/standard_site.rs | 85 ++++++ src-tauri/src/lib.rs | 5 + 8 files changed, 526 insertions(+), 152 deletions(-) create mode 100644 crates/core/src/atproto/standard_site.rs create mode 100644 src-tauri/src/commands/standard_site.rs diff --git a/Cargo.lock b/Cargo.lock index efbaa71..acac452 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,25 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "abnf" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "087113bd50d9adce24850eed5d0476c7d199d532fce8fab5173650331e09033a" -dependencies = [ - "abnf-core", - "nom", -] - -[[package]] -name = "abnf-core" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c44e09c43ae1c368fb91a03a566472d0087c26cf7e1b9e8e289c14ede681dd7d" -dependencies = [ - "nom", -] - [[package]] name = "adler2" version = "2.0.1" @@ -74,6 +55,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_log-sys" version = "0.3.2" @@ -573,30 +560,6 @@ dependencies = [ "alloc-stdlib", ] -[[package]] -name = "btree-range-map" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1be5c9672446d3800bcbcaabaeba121fe22f1fb25700c4562b22faf76d377c33" -dependencies = [ - "btree-slab", - "cc-traits", - "range-traits", - "serde", - "slab", -] - -[[package]] -name = "btree-slab" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2b56d3029f075c4fa892428a098425b86cef5c89ae54073137ece416aef13c" -dependencies = [ - "cc-traits", - "slab", - "smallvec", -] - [[package]] name = "buf_redux" version = "0.8.4" @@ -769,15 +732,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cc-traits" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "060303ef31ef4a522737e1b1ab68c67916f2a787bb2f4f54f383279adba962b5" -dependencies = [ - "slab", -] - [[package]] name = "cesu8" version = "1.1.0" @@ -2460,6 +2414,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", ] @@ -2519,12 +2475,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hex_fmt" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b07f60793ff0a4d9cef0f18e63b5357e06209987153a64648c972c1e5aff336f" - [[package]] name = "hickory-proto" version = "0.24.4" @@ -2919,15 +2869,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - [[package]] name = "infer" version = "0.19.0" @@ -3039,8 +2980,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jacquard" version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c1fdbcf1153e6e6b87fde20036c1ffe7473c4852f1c6369bc4ef1fe47ccb9f" +source = "git+https://tangled.org/nonbinary.computer/jacquard#298b03bb6ed9b2a308a82eed83fd497622042e70" dependencies = [ "bytes", "getrandom 0.2.17", @@ -3070,8 +3010,7 @@ dependencies = [ [[package]] name = "jacquard-api" version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4979fb1848c1dd7ac8fd12745bc71f56f6da61374407d5f9b06005467a954e5a" +source = "git+https://tangled.org/nonbinary.computer/jacquard#298b03bb6ed9b2a308a82eed83fd497622042e70" dependencies = [ "bon", "bytes", @@ -3090,28 +3029,32 @@ dependencies = [ [[package]] name = "jacquard-common" version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1751921e0bdae5e0077afade6161545e9ef7698306c868f800916e99ecbcaae9" +source = "git+https://tangled.org/nonbinary.computer/jacquard#298b03bb6ed9b2a308a82eed83fd497622042e70" dependencies = [ "base64 0.22.1", "bon", "bytes", "chrono", + "ciborium", + "ciborium-io", "cid", "getrandom 0.2.17", "getrandom 0.3.4", + "hashbrown 0.15.5", "http", "ipld-core", "k256", - "langtag", + "maitake-sync", "miette", "multibase", "multihash", "ouroboros", + "oxilangtag", "p256", "postcard", "rand 0.9.2", "regex", + "regex-automata", "regex-lite", "reqwest 0.12.28", "serde", @@ -3121,6 +3064,7 @@ dependencies = [ "serde_json", "signature", "smol_str", + "spin 0.10.0", "thiserror 2.0.18", "tokio", "tokio-util", @@ -3131,8 +3075,7 @@ dependencies = [ [[package]] name = "jacquard-derive" version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d73dfee07943fdab93569ed1c28b06c6921ed891c08b415c4a323ff67e593" +source = "git+https://tangled.org/nonbinary.computer/jacquard#298b03bb6ed9b2a308a82eed83fd497622042e70" dependencies = [ "heck 0.5.0", "jacquard-lexicon", @@ -3144,8 +3087,7 @@ dependencies = [ [[package]] name = "jacquard-identity" version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7aaefa819fa4213cf59f180dba932f018a7cd0599582fd38474ee2a38c16cf2" +source = "git+https://tangled.org/nonbinary.computer/jacquard#298b03bb6ed9b2a308a82eed83fd497622042e70" dependencies = [ "bon", "bytes", @@ -3172,8 +3114,7 @@ dependencies = [ [[package]] name = "jacquard-lexicon" version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8411aff546569b0a1e0ef669bed2380cec1c00d48f02f3fcd57a71545321b3d8" +source = "git+https://tangled.org/nonbinary.computer/jacquard#298b03bb6ed9b2a308a82eed83fd497622042e70" dependencies = [ "cid", "dashmap", @@ -3188,6 +3129,7 @@ dependencies = [ "serde", "serde_ipld_dagcbor", "serde_json", + "serde_path_to_error", "serde_repr", "serde_with", "sha2", @@ -3199,8 +3141,7 @@ dependencies = [ [[package]] name = "jacquard-oauth" version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68bf0b0e061d85b09cfa78588dc098918d5b62f539a719165c6a806a1d2c0ef2" +source = "git+https://tangled.org/nonbinary.computer/jacquard#298b03bb6ed9b2a308a82eed83fd497622042e70" dependencies = [ "base64 0.22.1", "bytes", @@ -3451,17 +3392,6 @@ dependencies = [ "selectors", ] -[[package]] -name = "langtag" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ecb4c689a30e48ebeaa14237f34037e300dd072e6ad21a9ec72e810ff3c6600" -dependencies = [ - "serde", - "static-regular-grammar", - "thiserror 1.0.69", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -3615,6 +3545,19 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "maitake-sync" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6816ab14147f80234c675b80ed6dc4f440d8a1cefc158e766067aedb84c0bcd5" +dependencies = [ + "cordyceps", + "loom", + "mycelium-bitfield", + "pin-project", + "portable-atomic", +] + [[package]] name = "markup5ever" version = "0.12.1" @@ -3748,8 +3691,7 @@ dependencies = [ [[package]] name = "mini-moka-wasm" version = "0.10.99" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0102b9a2ad50fa47ca89eead2316c8222285ecfbd3f69ce99564fbe4253866e8" +source = "git+https://tangled.org/nonbinary.computer/jacquard#298b03bb6ed9b2a308a82eed83fd497622042e70" dependencies = [ "crossbeam-channel", "crossbeam-utils", @@ -3760,12 +3702,6 @@ dependencies = [ "web-time", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3860,6 +3796,12 @@ dependencies = [ "twoway", ] +[[package]] +name = "mycelium-bitfield" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24e0cc5e2c585acbd15c5ce911dff71e1f4d5313f43345873311c4f5efd741cc" + [[package]] name = "n0-future" version = "0.1.3" @@ -3940,16 +3882,6 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "notify" version = "8.2.0" @@ -4421,6 +4353,15 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "oxilangtag" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f3f87617a86af77fa3691e6350483e7154c2ead9f1261b75130e21ca0f8acb" +dependencies = [ + "serde", +] + [[package]] name = "p256" version = "0.13.2" @@ -4814,6 +4755,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "postcard" version = "1.1.3" @@ -5193,12 +5140,6 @@ dependencies = [ "rand_core 0.5.1", ] -[[package]] -name = "range-traits" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d20581732dd76fa913c7dff1a2412b714afe3573e94d41c34719de73337cc8ab" - [[package]] name = "raw-window-handle" version = "0.6.2" @@ -5850,14 +5791,13 @@ dependencies = [ [[package]] name = "serde_html_form" -version = "0.2.8" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" +checksum = "2acf96b1d9364968fce46ebb548f1c0e1d7eceae27bdff73865d42e6c7369d94" dependencies = [ "form_urlencoded", "indexmap 2.13.0", "itoa", - "ryu", "serde_core", ] @@ -5886,6 +5826,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -6228,26 +6179,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static-regular-grammar" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f4a6c40247579acfbb138c3cd7de3dab113ab4ac6227f1b7de7d626ee667957" -dependencies = [ - "abnf", - "btree-range-map", - "ciborium", - "hex_fmt", - "indoc", - "proc-macro-error", - "proc-macro2", - "quote", - "serde", - "sha2", - "syn 2.0.116", - "thiserror 1.0.69", -] - [[package]] name = "static_assertions" version = "1.1.0" diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index a5af44d..2794702 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,7 +9,7 @@ thiserror = "2" chrono = { version = "0.4", features = ["serde"] } aho-corasick = "1.1.4" serde_json = "1" -jacquard = { version = "0.9.5", features = ["default"] } +jacquard = { git = "https://tangled.org/nonbinary.computer/jacquard" } unicode-segmentation = "1.12.0" comrak = "0.50" reqwest = "0.12" diff --git a/crates/core/src/atproto/leaflet.rs b/crates/core/src/atproto/leaflet.rs index d7192c2..86decde 100644 --- a/crates/core/src/atproto/leaflet.rs +++ b/crates/core/src/atproto/leaflet.rs @@ -4,7 +4,6 @@ use comrak::{ nodes::{AstNode, NodeCodeBlock, NodeHeading, NodeMath, NodeValue}, parse_document, }; -use jacquard::IntoStatic; use jacquard::api::pub_leaflet::{ blocks::{ blockquote::Blockquote, @@ -30,6 +29,7 @@ use jacquard::common::types::{ string::Uri, }; use jacquard::types::did::Did; +use jacquard::{IntoStatic, api::pub_leaflet::pages::linear_document::BlockAlignment}; use std::cmp::Reverse; const CANVAS_PAGE_OMITTED: &str = ""; @@ -118,9 +118,15 @@ fn render_block(block: &Block<'_>) -> Result { _ => comment_marker("unsupported: unknown block"), }; - Ok(match block.alignment.as_deref() { - Some(alignment) if !alignment.is_empty() => format!("\n{}", alignment, body), - _ => body, + Ok(match &block.alignment { + // Some(alignment) if !alignment.is_empty() => format!("\n{}", alignment, body), + Some(alignment) => match alignment { + BlockAlignment::TextAlignCenter => format!("\n{}", body), + BlockAlignment::TextAlignLeft => format!("\n{}", body), + BlockAlignment::TextAlignRight => format!("\n{}", body), + _ => body, + }, + None => body, }) } @@ -327,7 +333,7 @@ fn paragraph_to_block<'a>(node: &'a AstNode<'a>) -> Result Ok(Some(wrap_block(BlockBlock::Text(Box::new(Text { facets: option_facets(inline.facets), plaintext: inline.plaintext.into(), - extra_data: Default::default(), + ..Default::default() }))))) } @@ -430,7 +436,7 @@ fn convert_list_items<'a>(list_node: &'a AstNode<'a>) -> Result(list_node: &'a AstNode<'a>) -> Result(node: &'a AstNode<'a>) -> Result Block<'static> { wrap_block(BlockBlock::Text(Box::new(Text { facets: None, plaintext: format!("", message).into(), - extra_data: Default::default(), + ..Default::default() }))) } @@ -739,7 +745,7 @@ mod tests { single_feature_facet(5, 9, feature_link("https://example.com").unwrap()), ]), plaintext: "Lead link".into(), - extra_data: Default::default(), + ..Default::default() }))), wrap_block(BlockBlock::Blockquote(Box::new(Blockquote { facets: None, @@ -876,7 +882,7 @@ mod tests { .content(ListItemContent::Text(Box::new(Text { facets: None, plaintext: text.to_string().into(), - extra_data: Default::default(), + ..Default::default() }))) .children(children) .build() diff --git a/crates/core/src/atproto/mod.rs b/crates/core/src/atproto/mod.rs index 9670fc2..0c711fe 100644 --- a/crates/core/src/atproto/mod.rs +++ b/crates/core/src/atproto/mod.rs @@ -1,6 +1,8 @@ pub mod auth; pub mod leaflet; +pub mod standard_site; pub mod strings; pub use auth::{AtProtoState, SessionInfo}; +pub use standard_site::{PostRecord, PublicationRecord}; pub use strings::StringRecord; diff --git a/crates/core/src/atproto/standard_site.rs b/crates/core/src/atproto/standard_site.rs new file mode 100644 index 0000000..9593331 --- /dev/null +++ b/crates/core/src/atproto/standard_site.rs @@ -0,0 +1,343 @@ +use crate::{AppError, ErrorCode}; +use jacquard::IntoStatic; +use jacquard::api::com_atproto::repo::{get_record::GetRecord, list_records::ListRecords}; +use jacquard::api::pub_leaflet::{ + content::{Content as LeafletContent, ContentPagesItem}, + document::{Document as LeafletDocument, DocumentPagesItem}, +}; +use jacquard::api::site_standard::{document::Document as StandardSiteDocument, publication::Publication}; +use jacquard::common::CowStr; +use jacquard::common::types::value::{Data, from_data}; +use jacquard::common::types::{collection::Collection, ident::AtIdentifier, recordkey::RecordKey, string::AtUri}; +use jacquard::types::did::Did; +use jacquard::xrpc::XrpcExt; +use serde::Serialize; + +use super::leaflet::leaflet_document_to_markdown; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PublicationRecord { + pub uri: String, + pub tid: String, + pub name: String, + pub description: String, + pub url: String, +} + +impl PublicationRecord { + fn from_publication(uri: &str, value: Publication<'_>) -> Option { + Some(Self { + uri: uri.to_string(), + tid: record_key_from_uri(uri)?, + name: value.name.to_string(), + description: cow_str_option_to_string(value.description), + url: value.url.as_str().to_string(), + }) + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PostRecord { + pub uri: String, + pub tid: String, + pub title: String, + pub description: String, + pub text_content: String, + pub published_at: String, + pub updated_at: String, + pub tags: Vec, + pub publication_uri: String, +} + +impl PostRecord { + fn from_document(uri: &str, value: StandardSiteDocument<'_>) -> Option { + Some(Self { + uri: uri.to_string(), + tid: record_key_from_uri(uri)?, + title: value.title.to_string(), + description: cow_str_option_to_string(value.description), + text_content: cow_str_option_to_string(value.text_content), + published_at: value.published_at.to_string(), + updated_at: value.updated_at.map(|value| value.to_string()).unwrap_or_default(), + tags: value + .tags + .unwrap_or_default() + .into_iter() + .map(|value| value.to_string()) + .collect(), + publication_uri: value.site.as_str().to_string(), + }) + } +} + +impl super::auth::AtProtoState { + pub async fn publication_list(&self, did_or_handle: &str) -> Result, AppError> { + let publications = self + .list_records(did_or_handle, Publication::nsid(), "Standard.Site publications") + .await?; + + publications + .into_iter() + .map(|(uri, data)| { + let publication = parse_publication(&data)?; + PublicationRecord::from_publication(&uri, publication).ok_or_else(|| { + AppError::new( + ErrorCode::Parse, + "Failed to derive Standard.Site publication record key from URI", + ) + }) + }) + .collect() + } + + pub async fn publication_get(&self, did_or_handle: &str, tid: &str) -> Result { + let (uri, data) = self + .get_record(did_or_handle, tid, Publication::nsid(), "Standard.Site publication") + .await?; + let publication = parse_publication(&data)?; + + PublicationRecord::from_publication(&uri, publication).ok_or_else(|| { + AppError::new( + ErrorCode::Parse, + "Failed to derive Standard.Site publication record key from URI", + ) + }) + } + + pub async fn post_list( + &self, did_or_handle: &str, publication_tid: Option<&str>, + ) -> Result, AppError> { + let documents = self + .list_records(did_or_handle, StandardSiteDocument::nsid(), "Standard.Site posts") + .await?; + let publication_tid = normalize_optional_filter(publication_tid); + + let mut posts = Vec::with_capacity(documents.len()); + for (uri, data) in documents { + let document = parse_document(&data)?; + if let Some(filter) = publication_tid + && !publication_matches_filter(document.site.as_str(), filter) + { + continue; + } + + let post = PostRecord::from_document(&uri, document).ok_or_else(|| { + AppError::new( + ErrorCode::Parse, + "Failed to derive Standard.Site post record key from URI", + ) + })?; + posts.push(post); + } + + Ok(posts) + } + + pub async fn post_get(&self, did_or_handle: &str, tid: &str) -> Result { + let (uri, data) = self + .get_record(did_or_handle, tid, StandardSiteDocument::nsid(), "Standard.Site post") + .await?; + let document = parse_document(&data)?; + + PostRecord::from_document(&uri, document).ok_or_else(|| { + AppError::new( + ErrorCode::Parse, + "Failed to derive Standard.Site post record key from URI", + ) + }) + } + + pub async fn post_get_markdown(&self, did_or_handle: &str, tid: &str) -> Result { + let (repo_did, document) = self.standard_site_document_get(did_or_handle, tid).await?; + + match document.content { + Some(content) => { + let leaflet_document = LeafletDocument::new() + .author(AtIdentifier::Did(repo_did)) + .title(document.title) + .maybe_description(document.description) + .maybe_publication(site_as_at_uri(&document.site)?) + .maybe_published_at(Some(document.published_at)) + .maybe_tags(document.tags) + .pages(content_pages_to_document_pages(content)) + .build(); + + leaflet_document_to_markdown(&leaflet_document) + } + None if document.text_content.is_some() => Ok(document.text_content.unwrap().to_string()), + None => Err(AppError::new( + ErrorCode::Parse, + "Standard.Site post does not contain importable Leaflet content or textContent", + )), + } + } + + async fn standard_site_document_get( + &self, did_or_handle: &str, tid: &str, + ) -> Result<(Did<'static>, StandardSiteDocument<'static>), AppError> { + let trimmed_tid = trim_tid(tid, "Post 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| AppError::new(ErrorCode::Parse, format!("Invalid Standard.Site post ID: {}", error)))?; + let request = GetRecord::new() + .repo(AtIdentifier::Did(repo_did.clone())) + .collection(StandardSiteDocument::nsid()) + .rkey(rkey) + .build(); + + let response = self + .http + .xrpc(pds_url) + .send(&request) + .await + .map_err(|error| AppError::io(format!("Failed to fetch Standard.Site post: {}", error)))?; + let output = response + .into_output() + .map_err(|error| AppError::io(format!("Failed to decode Standard.Site post: {}", error)))?; + let record = parse_document(&output.value.into_static())?; + + Ok((repo_did, record)) + } + + async fn list_records( + &self, did_or_handle: &str, collection: jacquard::common::types::string::Nsid<'static>, label: &str, + ) -> Result)>, AppError> { + let (repo_did, pds_url) = self.resolve_repo_and_pds(did_or_handle).await?; + let request = ListRecords::new() + .repo(AtIdentifier::Did(repo_did)) + .collection(collection) + .limit(Some(100)) + .reverse(Some(true)) + .build(); + + let response = self + .http + .xrpc(pds_url) + .send(&request) + .await + .map_err(|error| AppError::io(format!("Failed to list {}: {}", label, error)))?; + let output = response + .into_output() + .map_err(|error| AppError::io(format!("Failed to decode {}: {}", label, error)))?; + + Ok(output + .records + .into_iter() + .map(|record| (record.uri.to_string(), record.value.into_static())) + .collect()) + } + + async fn get_record( + &self, did_or_handle: &str, tid: &str, collection: jacquard::common::types::string::Nsid<'static>, label: &str, + ) -> Result<(String, Data<'static>), AppError> { + let trimmed_tid = trim_tid(tid, &format!("{} ID is required", label))?; + let (repo_did, pds_url) = self.resolve_repo_and_pds(did_or_handle).await?; + let rkey = RecordKey::any(trimmed_tid) + .map_err(|error| AppError::new(ErrorCode::Parse, format!("Invalid {} ID: {}", label, error)))?; + let request = GetRecord::new() + .repo(AtIdentifier::Did(repo_did)) + .collection(collection) + .rkey(rkey) + .build(); + + let response = self + .http + .xrpc(pds_url) + .send(&request) + .await + .map_err(|error| AppError::io(format!("Failed to fetch {}: {}", label, error)))?; + let output = response + .into_output() + .map_err(|error| AppError::io(format!("Failed to decode {}: {}", label, error)))?; + + Ok((output.uri.to_string(), output.value.into_static())) + } +} + +fn parse_publication(data: &Data<'static>) -> Result, AppError> { + from_data::>(data) + .map(|value| value.into_static()) + .map_err(|error| AppError::io(format!("Failed to parse Standard.Site publication: {}", error))) +} + +fn parse_document(data: &Data<'static>) -> Result, AppError> { + from_data::>(data) + .map(|value| value.into_static()) + .map_err(|error| AppError::io(format!("Failed to parse Standard.Site post: {}", error))) +} + +fn cow_str_option_to_string(value: Option>) -> String { + value.map(|value| value.to_string()).unwrap_or_default() +} + +fn record_key_from_uri(uri: &str) -> Option { + AtUri::new(uri).ok()?.rkey().map(|rkey| rkey.as_ref().to_string()) +} + +fn trim_tid<'a>(tid: &'a str, message: &str) -> Result<&'a str, AppError> { + let trimmed = tid.trim(); + if trimmed.is_empty() { Err(AppError::new(ErrorCode::InvalidPath, message)) } else { Ok(trimmed) } +} + +fn normalize_optional_filter(filter: Option<&str>) -> Option<&str> { + filter.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { None } else { Some(trimmed) } + }) +} + +fn publication_matches_filter(publication_uri: &str, filter: &str) -> bool { + publication_uri == filter || record_key_from_uri(publication_uri).as_deref() == Some(filter) +} + +fn site_as_at_uri(site: &jacquard::common::types::string::Uri<'_>) -> Result>, AppError> { + if !site.as_str().starts_with("at://") { + return Ok(None); + } + + AtUri::new(site.as_str()) + .map(|uri| Some(uri.into_static())) + .map_err(|error| AppError::new(ErrorCode::Parse, format!("Invalid Standard.Site site URI: {}", error))) +} + +fn content_pages_to_document_pages(content: LeafletContent<'static>) -> Vec> { + content + .pages + .into_iter() + .filter_map(|page| match page { + ContentPagesItem::LinearDocument(page) => Some(DocumentPagesItem::LinearDocument(page)), + ContentPagesItem::Canvas(page) => Some(DocumentPagesItem::Canvas(page)), + ContentPagesItem::Unknown(_) => None, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use jacquard::api::pub_leaflet::pages::linear_document::LinearDocument; + + #[test] + fn publication_filter_matches_tid() { + assert!(publication_matches_filter( + "at://did:plc:alice/site.standard.publication/3kxyz", + "3kxyz" + )); + } + + #[test] + fn content_pages_convert_to_document_pages() { + let content = LeafletContent::new() + .pages(vec![ContentPagesItem::LinearDocument(Box::new( + LinearDocument::new().blocks(Vec::new()).build(), + ))]) + .build() + .into_static(); + + let pages = content_pages_to_document_pages(content); + + assert!(matches!(pages.first(), Some(DocumentPagesItem::LinearDocument(_)))); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 2765baa..745b8da 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -17,10 +17,12 @@ use writer_store::{Store, StyleCheckSettings, UiLayoutSettings}; mod atproto; mod md; +mod standard_site; mod strings; pub use atproto::*; pub use md::*; +pub use standard_site::*; pub use strings::*; type CommandResponse = std::result::Result, AppError>; diff --git a/src-tauri/src/commands/standard_site.rs b/src-tauri/src/commands/standard_site.rs new file mode 100644 index 0000000..9efb511 --- /dev/null +++ b/src-tauri/src/commands/standard_site.rs @@ -0,0 +1,85 @@ +//! Standard.Site publication/post commands + +use super::{AppState, CommandResponse}; +use tauri::State; +use writer_core::{ + atproto::{PostRecord, PublicationRecord}, + CommandResult, +}; + +#[tauri::command] +pub async fn publication_list( + state: State<'_, AppState>, did_or_handle: String, +) -> CommandResponse> { + log::info!("Listing Standard.Site publications"); + + match state.atproto.publication_list(&did_or_handle).await { + Ok(records) => Ok(CommandResult::ok(records)), + Err(error) => { + log::error!("Failed to list Standard.Site publications: {}", error); + Ok(CommandResult::err(error)) + } + } +} + +#[tauri::command] +pub async fn publication_get( + state: State<'_, AppState>, did_or_handle: String, tid: String, +) -> CommandResponse { + log::info!("Fetching Standard.Site publication: {}", tid); + + match state.atproto.publication_get(&did_or_handle, &tid).await { + Ok(record) => Ok(CommandResult::ok(record)), + Err(error) => { + log::error!("Failed to fetch Standard.Site publication: {}", error); + Ok(CommandResult::err(error)) + } + } +} + +#[tauri::command] +pub async fn post_list( + state: State<'_, AppState>, did_or_handle: String, publication_tid: Option, +) -> CommandResponse> { + log::info!("Listing Standard.Site posts"); + + match state + .atproto + .post_list(&did_or_handle, publication_tid.as_deref()) + .await + { + Ok(records) => Ok(CommandResult::ok(records)), + Err(error) => { + log::error!("Failed to list Standard.Site posts: {}", error); + Ok(CommandResult::err(error)) + } + } +} + +#[tauri::command] +pub async fn post_get(state: State<'_, AppState>, did_or_handle: String, tid: String) -> CommandResponse { + log::info!("Fetching Standard.Site post: {}", tid); + + match state.atproto.post_get(&did_or_handle, &tid).await { + Ok(record) => Ok(CommandResult::ok(record)), + Err(error) => { + log::error!("Failed to fetch Standard.Site post: {}", error); + Ok(CommandResult::err(error)) + } + } +} + +#[tauri::command] +pub async fn post_get_markdown( + state: State<'_, AppState>, did_or_handle: String, tid: String, +) -> CommandResponse { + log::info!("Fetching Standard.Site post markdown: {}", tid); + + match state.atproto.post_get_markdown(&did_or_handle, &tid).await { + Ok(markdown) => Ok(CommandResult::ok(markdown)), + Err(error) => { + log::error!("Failed to fetch Standard.Site post markdown: {}", error); + Ok(CommandResult::err(error)) + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4fa1e09..4f1e6bf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -89,6 +89,11 @@ pub fn run() { cmd::atproto_login, cmd::atproto_logout, cmd::atproto_session_status, + cmd::publication_list, + cmd::publication_get, + cmd::post_list, + cmd::post_get, + cmd::post_get_markdown, cmd::string_list, cmd::string_get, cmd::string_create, -- 2.51.2