From 37f428ce4184a65d9b2456a6947d73c28c0bf6b8 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Thu, 19 Mar 2026 23:32:36 -0500 Subject: [PATCH] feat: crud ops for tangled strings --- Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/atproto/auth.rs | 60 ++++++- src-tauri/src/atproto/strings.rs | 277 +++++++++++++++++++++++++++++- src-tauri/src/commands/strings.rs | 49 ++++++ src-tauri/src/lib.rs | 3 + 6 files changed, 384 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f506284..b61ab24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8549,6 +8549,7 @@ dependencies = [ "tauri-plugin-store", "tempfile", "thiserror 2.0.18", + "unicode-segmentation", "writer-core", "writer-md", "writer-store", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0a3ecd1..090e33e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,6 +34,7 @@ async-trait = "0.1" thiserror = "2" jacquard = { version = "0.9.5", features = ["default"] } reqwest = "0.12" +unicode-segmentation = "1" writer-core = { path = "../crates/core" } writer-md = { path = "../crates/markdown" } diff --git a/src-tauri/src/atproto/auth.rs b/src-tauri/src/atproto/auth.rs index 1a88813..048073a 100644 --- a/src-tauri/src/atproto/auth.rs +++ b/src-tauri/src/atproto/auth.rs @@ -55,6 +55,8 @@ pub struct AtProtoState { auth_store_path: PathBuf, session_meta_path: PathBuf, session_info: Mutex>, + /// Live session for authenticated writes; `None` when logged out. + pub(crate) session: Mutex>>, } impl AtProtoState { @@ -75,6 +77,7 @@ impl AtProtoState { auth_store_path, session_meta_path, session_info: Mutex::new(None), + session: Mutex::new(None), }) } @@ -180,7 +183,13 @@ impl AtProtoState { session_id: info.session_id.clone(), })?; - let _ = session; + let mut session_guard = self + .session + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock AT Protocol session state"))?; + *session_guard = Some(Arc::new(session)); + drop(session_guard); + let mut guard = self .session_info .lock() @@ -190,6 +199,13 @@ impl AtProtoState { } fn clear_in_memory(&self) -> Result<(), AppError> { + let mut session_guard = self + .session + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock AT Protocol session state"))?; + *session_guard = None; + drop(session_guard); + let mut guard = self .session_info .lock() @@ -231,6 +247,34 @@ impl AtProtoState { Some(PersistedSessionMeta { did: did.clone(), handle: did, session_id: entry.oauth.session_id }) } + /// Returns the live session for authenticated XRPC writes. Errors if the user is not logged in. + pub(crate) fn require_session(&self) -> Result, AppError> { + let guard = self + .session + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock AT Protocol session state"))?; + guard.clone().ok_or_else(|| { + AppError::new( + ErrorCode::Io, + "Not logged in to AT Protocol — please authenticate first", + ) + }) + } + + /// Returns the DID of the currently authenticated user, or an error if not logged in. + pub(crate) fn session_did(&self) -> Result { + let guard = self + .session_info + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock AT Protocol session state"))?; + guard.as_ref().map(|info| info.did.clone()).ok_or_else(|| { + AppError::new( + ErrorCode::Io, + "Not logged in to AT Protocol — please authenticate first", + ) + }) + } + pub(crate) async fn resolve_repo_and_pds( &self, did_or_handle: &str, ) -> Result<(Did<'static>, reqwest::Url), AppError> { @@ -305,6 +349,20 @@ mod tests { assert_eq!(meta.session_id, "writer-session"); } + #[test] + fn require_session_errors_when_not_logged_in() { + let dir = tempdir().expect("tempdir"); + let state = AtProtoState::new(dir.path()).expect("state"); + assert!(state.require_session().is_err()); + } + + #[test] + fn session_did_errors_when_not_logged_in() { + let dir = tempdir().expect("tempdir"); + 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"); diff --git a/src-tauri/src/atproto/strings.rs b/src-tauri/src/atproto/strings.rs index 54824a8..befea19 100644 --- a/src-tauri/src/atproto/strings.rs +++ b/src-tauri/src/atproto/strings.rs @@ -1,11 +1,16 @@ -use jacquard::api::com_atproto::repo::get_record::GetRecord; -use jacquard::api::com_atproto::repo::list_records::ListRecords; +use jacquard::api::com_atproto::repo::{ + create_record::CreateRecord, delete_record::DeleteRecord, get_record::GetRecord, list_records::ListRecords, + put_record::PutRecord, +}; use jacquard::api::sh_tangled::string::TangledString; -use jacquard::common::types::collection::Collection; -use jacquard::common::types::recordkey::RecordKey; -use jacquard::common::types::value::from_data; -use jacquard::common::xrpc::XrpcExt; +use jacquard::common::types::value::{from_data, to_data}; +use jacquard::common::types::{collection::Collection, ident::AtIdentifier, recordkey::RecordKey, string::Datetime}; +use jacquard::types::did::Did; +use jacquard::xrpc::{XrpcClient, XrpcExt}; use serde::Serialize; +use unicode_segmentation::UnicodeSegmentation; + +const MAX_RECORD_BYTES: usize = 2 * 1024 * 1024; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -113,4 +118,264 @@ impl super::auth::AtProtoState { ) }) } + + pub async fn string_create( + &self, filename: &str, description: &str, contents: &str, + ) -> Result { + validate_filename(filename)?; + validate_description(description)?; + validate_contents(contents)?; + + 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 data = to_data(&ts) + .map_err(|error| writer_core::AppError::io(format!("Failed to serialize Tangled string: {}", error)))?; + + let request = CreateRecord::new() + .repo(AtIdentifier::Did(did)) + .collection(TangledString::nsid()) + .record(data) + .build(); + + let response = session + .send(request) + .await + .map_err(|error| writer_core::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)))?; + + StringRecord::from_tangled_string(output.uri.as_ref(), ts).ok_or_else(|| { + writer_core::AppError::new( + writer_core::ErrorCode::Parse, + "Failed to derive Tangled string record key from created URI", + ) + }) + } + + pub async fn string_update( + &self, tid: &str, filename: &str, description: &str, contents: &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 for update", + )); + } + + validate_filename(filename)?; + validate_description(description)?; + validate_contents(contents)?; + + 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 data = to_data(&ts) + .map_err(|error| writer_core::AppError::io(format!("Failed to serialize Tangled string: {}", error)))?; + + let request = PutRecord::new() + .repo(AtIdentifier::Did(did)) + .collection(TangledString::nsid()) + .rkey(rkey) + .record(data) + .build(); + + let response = session + .send(request) + .await + .map_err(|error| writer_core::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)))?; + + StringRecord::from_tangled_string(output.uri.as_ref(), ts).ok_or_else(|| { + writer_core::AppError::new( + writer_core::ErrorCode::Parse, + "Failed to derive Tangled string record key from updated URI", + ) + }) + } + + pub async fn string_delete(&self, tid: &str) -> Result<(), writer_core::AppError> { + let trimmed_tid = tid.trim(); + if trimmed_tid.is_empty() { + return Err(writer_core::AppError::new( + writer_core::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 request = DeleteRecord::new() + .repo(AtIdentifier::Did(did)) + .collection(TangledString::nsid()) + .rkey(rkey) + .build(); + + session + .send(request) + .await + .map_err(|error| writer_core::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)))?; + + Ok(()) + } +} + +fn validate_filename(filename: &str) -> Result<(), writer_core::AppError> { + let graphemes = filename.graphemes(true).count(); + if graphemes < 1 { + return Err(writer_core::AppError::new( + writer_core::ErrorCode::Parse, + "Filename is required", + )); + } + if graphemes > 140 { + return Err(writer_core::AppError::new( + writer_core::ErrorCode::Parse, + format!("Filename must be at most 140 graphemes (got {})", graphemes), + )); + } + Ok(()) +} + +fn validate_description(description: &str) -> Result<(), writer_core::AppError> { + let graphemes = description.graphemes(true).count(); + if graphemes > 280 { + return Err(writer_core::AppError::new( + writer_core::ErrorCode::Parse, + format!("Description must be at most 280 graphemes (got {})", graphemes), + )); + } + Ok(()) +} + +fn validate_contents(contents: &str) -> Result<(), writer_core::AppError> { + if contents.graphemes(true).next().is_none() { + return Err(writer_core::AppError::new( + writer_core::ErrorCode::Parse, + "Contents must not be empty", + )); + } + Ok(()) +} + +/// Builds and validates record size before returning. Returns an error if the serialized +/// 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> { + let ts = TangledString::new() + .filename(filename) + .description(description) + .contents(contents) + .created_at(created_at) + .build(); + + let serialized = serde_json::to_vec(&ts) + .map_err(|error| writer_core::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, + format!( + "Record is too large ({} bytes); PDS limit is {} bytes (2 MiB)", + serialized.len(), + MAX_RECORD_BYTES + ), + )); + } + + Ok(ts) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_filename_rejects_empty() { + assert!(validate_filename("").is_err()); + } + + #[test] + fn validate_filename_rejects_over_140_graphemes() { + assert!(validate_filename(&"a".repeat(141)).is_err()); + } + + #[test] + fn validate_filename_accepts_140_graphemes() { + assert!(validate_filename(&"a".repeat(140)).is_ok()); + } + + #[test] + fn validate_filename_accepts_multibyte_unicode() { + let name = "é".repeat(140); + assert!(validate_filename(&name).is_ok()); + let too_long = "é".repeat(141); + assert!(validate_filename(&too_long).is_err()); + } + + #[test] + fn validate_description_accepts_empty() { + assert!(validate_description("").is_ok()); + } + + #[test] + fn validate_description_rejects_over_280_graphemes() { + assert!(validate_description(&"a".repeat(281)).is_err()); + } + + #[test] + fn validate_description_accepts_280_graphemes() { + assert!(validate_description(&"a".repeat(280)).is_ok()); + } + + #[test] + fn validate_contents_rejects_empty() { + assert!(validate_contents("").is_err()); + } + + #[test] + fn validate_contents_accepts_single_character() { + assert!(validate_contents("x").is_ok()); + } + + #[test] + fn build_tangled_string_rejects_oversized_contents() { + let big = "x".repeat(MAX_RECORD_BYTES + 1); + assert!(build_tangled_string("file.md", "", &big, Datetime::now()).is_err()); + } + + #[test] + fn build_tangled_string_accepts_normal_contents() { + assert!(build_tangled_string("notes.md", "My notes", "# Hello", Datetime::now()).is_ok()); + } } diff --git a/src-tauri/src/commands/strings.rs b/src-tauri/src/commands/strings.rs index f978a30..b8f97b2 100644 --- a/src-tauri/src/commands/strings.rs +++ b/src-tauri/src/commands/strings.rs @@ -1,8 +1,57 @@ +//! Tangled.org string (snippets/gists) commands + use super::{AppState, CommandResponse}; use crate::atproto::StringRecord; use tauri::State; use writer_core::CommandResult; +#[tauri::command] +pub async fn string_create( + state: State<'_, AppState>, filename: String, description: String, contents: String, +) -> CommandResponse { + log::info!("Creating Tangled string: {}", filename); + + match state.atproto.string_create(&filename, &description, &contents).await { + Ok(record) => Ok(CommandResult::ok(record)), + Err(error) => { + log::error!("Failed to create Tangled string: {}", error); + Ok(CommandResult::err(error)) + } + } +} + +#[tauri::command] +pub async fn string_update( + state: State<'_, AppState>, tid: String, filename: String, description: String, contents: String, +) -> CommandResponse { + log::info!("Updating Tangled string: {}", tid); + + match state + .atproto + .string_update(&tid, &filename, &description, &contents) + .await + { + Ok(record) => Ok(CommandResult::ok(record)), + Err(error) => { + log::error!("Failed to update Tangled string: {}", error); + Ok(CommandResult::err(error)) + } + } +} + +#[tauri::command] +pub async fn string_delete(state: State<'_, AppState>, tid: String) -> CommandResponse<()> { + log::info!("Deleting Tangled string: {}", tid); + + match state.atproto.string_delete(&tid).await { + Ok(()) => Ok(CommandResult::ok(())), + Err(error) => { + log::error!("Failed to delete Tangled string: {}", error); + Ok(CommandResult::err(error)) + } + } +} + #[tauri::command] pub async fn string_list(state: State<'_, AppState>, did_or_handle: String) -> CommandResponse> { log::info!("Listing Tangled strings"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 21a1544..4d0e824 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -92,6 +92,9 @@ pub fn run() { cmd::atproto_session_status, cmd::string_list, cmd::string_get, + cmd::string_create, + cmd::string_update, + cmd::string_delete, cmd::app_version_get, cmd::location_add_via_dialog, cmd::location_list, -- 2.51.2