atproto blogging
1//! Document sync helpers for site.standard.document records.
2//!
3//! Creates/updates site.standard.document records for notebook entries
4//! when the notebook has publishGlobal enabled.
5
6use jacquard::IntoStatic;
7use jacquard::cowstr::ToCowStr;
8use jacquard::prelude::*;
9use jacquard::to_data;
10use jacquard::types::ident::AtIdentifier;
11use jacquard::types::recordkey::RecordKey;
12use jacquard::types::string::{AtUri, Nsid};
13use weaver_api::com_atproto::repo::create_record::CreateRecord;
14use weaver_api::com_atproto::repo::get_record::GetRecord;
15use weaver_api::sh_weaver::domain::generate_document::GenerateDocument;
16use weaver_api::sh_weaver::notebook::entry::Entry;
17use weaver_common::{WeaverError, slugify};
18
19use crate::fetch::Fetcher;
20
21const DOCUMENT_NSID: &str = "site.standard.document";
22
23/// Create a site.standard.document for an entry if the notebook has publishGlobal.
24///
25/// Returns Ok(Some(document_uri)) if created, Ok(None) if not needed, Err on failure.
26pub async fn create_document_for_entry(
27 fetcher: &Fetcher,
28 entry_uri: &AtUri<'_>,
29 entry_record: &Entry<'_>,
30 publication_uri: &AtUri<'_>,
31) -> Result<Option<AtUri<'static>>, WeaverError> {
32 // Build the document path from entry path (or fallback to slugified title).
33 let path = if !entry_record.path.is_empty() {
34 entry_record.path.to_cowstr()
35 } else if !entry_record.title.is_empty() {
36 slugify(entry_record.title.as_ref())
37 .to_cowstr()
38 .into_static()
39 } else {
40 "untitled".to_cowstr()
41 };
42
43 // Serialize entry record for the generateDocument call.
44 let entry_data = to_data(entry_record)
45 .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to serialize entry: {}", e)))?;
46
47 // Call generateDocument endpoint (proxied through PDS with auth).
48 let request = GenerateDocument::new()
49 .entry(entry_uri.clone())
50 .entry_record(entry_data)
51 .publication(publication_uri.clone())
52 .path(path)
53 .build();
54
55 let response = fetcher
56 .send(request)
57 .await
58 .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to generate document: {}", e)))?;
59
60 let output = response
61 .into_output()
62 .map_err(|e| WeaverError::InvalidNotebook(format!("generateDocument failed: {}", e)))?;
63
64 // Write the document to the PDS.
65 let did = fetcher
66 .current_did()
67 .await
68 .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?;
69
70 // Use entry rkey for 1:1 mapping.
71 let entry_rkey = entry_uri
72 .rkey()
73 .ok_or_else(|| WeaverError::InvalidNotebook("Entry URI missing rkey".into()))?;
74
75 let collection = Nsid::new(DOCUMENT_NSID).map_err(WeaverError::AtprotoString)?;
76 let document_data = to_data(&output.record).map_err(|e| {
77 WeaverError::InvalidNotebook(format!("Failed to serialize document: {}", e))
78 })?;
79
80 let rkey = RecordKey::any(entry_rkey.as_ref())
81 .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?;
82
83 let request = CreateRecord::new()
84 .repo(AtIdentifier::Did(did))
85 .collection(collection)
86 .rkey(rkey)
87 .record(document_data)
88 .build();
89
90 let response = fetcher
91 .send(request)
92 .await
93 .map_err(jacquard::client::AgentError::from)?;
94
95 let create_output = response
96 .into_output()
97 .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to create document: {}", e)))?;
98
99 Ok(Some(create_output.uri.into_static()))
100}
101
102/// Check if a site.standard.document exists for an entry.
103pub async fn document_exists(fetcher: &Fetcher, entry_rkey: &str) -> Result<bool, WeaverError> {
104 let did = fetcher
105 .current_did()
106 .await
107 .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?;
108
109 let collection = Nsid::new(DOCUMENT_NSID).map_err(WeaverError::AtprotoString)?;
110 let rkey =
111 RecordKey::any(entry_rkey).map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?;
112
113 let request = GetRecord::new()
114 .repo(AtIdentifier::Did(did))
115 .collection(collection)
116 .rkey(rkey)
117 .build();
118
119 match fetcher.send(request).await {
120 Ok(response) => Ok(response.into_output().is_ok()),
121 Err(_) => Ok(false),
122 }
123}
124
125/// Get the publication URI for a notebook (same rkey).
126pub fn publication_uri_for_notebook(notebook_uri: &AtUri<'_>) -> Option<AtUri<'static>> {
127 let did = notebook_uri.authority();
128 let rkey = notebook_uri.rkey()?;
129
130 let uri_str = format!("at://{}/site.standard.publication/{}", did, rkey.as_ref());
131 AtUri::new(&uri_str).ok().map(|u| u.into_static())
132}