init
This commit is contained in:
97
src/commands/auth.rs
Normal file
97
src/commands/auth.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::token::{self, Session};
|
||||
use crate::lexicons::{self, com_atproto_server};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CreateSessionRequest {
|
||||
identifier: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CreateSessionResponse {
|
||||
did: String,
|
||||
handle: String,
|
||||
access_jwt: String,
|
||||
refresh_jwt: String,
|
||||
}
|
||||
|
||||
/// Login to ATProto PDS
|
||||
pub async fn login(handle: &str, password: &str, pds: &str) -> Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = lexicons::url(pds, &com_atproto_server::CREATE_SESSION);
|
||||
|
||||
let req = CreateSessionRequest {
|
||||
identifier: handle.to_string(),
|
||||
password: password.to_string(),
|
||||
};
|
||||
|
||||
println!("Logging in to {} as {}...", pds, handle);
|
||||
|
||||
let res = client
|
||||
.post(&url)
|
||||
.json(&req)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send login request")?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Login failed: {} - {}", status, body);
|
||||
}
|
||||
|
||||
let session_res: CreateSessionResponse = res.json().await?;
|
||||
|
||||
let session = Session {
|
||||
did: session_res.did,
|
||||
handle: session_res.handle,
|
||||
access_jwt: session_res.access_jwt,
|
||||
refresh_jwt: session_res.refresh_jwt,
|
||||
pds: Some(pds.to_string()),
|
||||
};
|
||||
|
||||
token::save_session(&session)?;
|
||||
println!("Logged in as {} ({})", session.handle, session.did);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refresh access token
|
||||
pub async fn refresh_session() -> Result<Session> {
|
||||
let session = token::load_session()?;
|
||||
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = lexicons::url(pds, &com_atproto_server::REFRESH_SESSION);
|
||||
|
||||
let res = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", session.refresh_jwt))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to refresh session")?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Refresh failed: {} - {}. Try logging in again.", status, body);
|
||||
}
|
||||
|
||||
let new_session: CreateSessionResponse = res.json().await?;
|
||||
|
||||
let session = Session {
|
||||
did: new_session.did,
|
||||
handle: new_session.handle,
|
||||
access_jwt: new_session.access_jwt,
|
||||
refresh_jwt: new_session.refresh_jwt,
|
||||
pds: Some(pds.to_string()),
|
||||
};
|
||||
|
||||
token::save_session(&session)?;
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
34
src/commands/did.rs
Normal file
34
src/commands/did.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::lexicons::{self, com_atproto_identity};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResolveHandleResponse {
|
||||
did: String,
|
||||
}
|
||||
|
||||
/// Resolve handle to DID
|
||||
pub async fn resolve(handle: &str, server: &str) -> Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"{}?handle={}",
|
||||
lexicons::url(server, &com_atproto_identity::RESOLVE_HANDLE),
|
||||
handle
|
||||
);
|
||||
|
||||
let res = client.get(&url).send().await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Failed to resolve handle: {} - {}", status, body);
|
||||
}
|
||||
|
||||
let result: ResolveHandleResponse = res.json().await
|
||||
.context("Failed to parse response")?;
|
||||
|
||||
println!("{}", result.did);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
265
src/commands/gen.rs
Normal file
265
src/commands/gen.rs
Normal file
@@ -0,0 +1,265 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Lexicon {
|
||||
id: String,
|
||||
defs: BTreeMap<String, LexiconDef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LexiconDef {
|
||||
#[serde(rename = "type")]
|
||||
def_type: Option<String>,
|
||||
}
|
||||
|
||||
struct EndpointInfo {
|
||||
nsid: String,
|
||||
method: String, // GET or POST
|
||||
}
|
||||
|
||||
/// Generate lexicon code from ATProto lexicon JSON files
|
||||
pub fn generate(input: &str, output: &str) -> Result<()> {
|
||||
let input_path = Path::new(input);
|
||||
|
||||
if !input_path.exists() {
|
||||
anyhow::bail!("Input directory does not exist: {}", input);
|
||||
}
|
||||
|
||||
println!("Scanning lexicons from: {}", input);
|
||||
|
||||
// Collect all endpoints grouped by namespace
|
||||
let mut namespaces: BTreeMap<String, Vec<EndpointInfo>> = BTreeMap::new();
|
||||
|
||||
// Scan com/atproto directory
|
||||
let atproto_path = input_path.join("com/atproto");
|
||||
if atproto_path.exists() {
|
||||
scan_namespace(&atproto_path, "com.atproto", &mut namespaces)?;
|
||||
}
|
||||
|
||||
// Scan app/bsky directory
|
||||
let bsky_path = input_path.join("app/bsky");
|
||||
if bsky_path.exists() {
|
||||
scan_namespace(&bsky_path, "app.bsky", &mut namespaces)?;
|
||||
}
|
||||
|
||||
// Generate Rust code
|
||||
let rust_code = generate_rust_code(&namespaces);
|
||||
let rust_output_path = Path::new(output).join("mod.rs");
|
||||
fs::create_dir_all(output)?;
|
||||
fs::write(&rust_output_path, &rust_code)?;
|
||||
println!("Generated Rust: {}", rust_output_path.display());
|
||||
|
||||
// Generate TypeScript code
|
||||
let ts_output = output.replace("src/lexicons", "src/web/lexicons");
|
||||
let ts_code = generate_typescript_code(&namespaces);
|
||||
let ts_output_path = Path::new(&ts_output).join("index.ts");
|
||||
fs::create_dir_all(&ts_output)?;
|
||||
fs::write(&ts_output_path, &ts_code)?;
|
||||
println!("Generated TypeScript: {}", ts_output_path.display());
|
||||
|
||||
println!("Total namespaces: {}", namespaces.len());
|
||||
let total_endpoints: usize = namespaces.values().map(|v| v.len()).sum();
|
||||
println!("Total endpoints: {}", total_endpoints);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scan_namespace(
|
||||
base_path: &Path,
|
||||
prefix: &str,
|
||||
namespaces: &mut BTreeMap<String, Vec<EndpointInfo>>,
|
||||
) -> Result<()> {
|
||||
for entry in fs::read_dir(base_path)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
let ns_name = path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.context("Invalid directory name")?;
|
||||
|
||||
let full_ns = format!("{}.{}", prefix, ns_name);
|
||||
let mut endpoints = Vec::new();
|
||||
|
||||
// Scan JSON files in this namespace
|
||||
for file_entry in fs::read_dir(&path)? {
|
||||
let file_entry = file_entry?;
|
||||
let file_path = file_entry.path();
|
||||
|
||||
if file_path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||
if let Some(endpoint) = parse_lexicon_file(&file_path)? {
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !endpoints.is_empty() {
|
||||
endpoints.sort_by(|a, b| a.nsid.cmp(&b.nsid));
|
||||
namespaces.insert(full_ns, endpoints);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_lexicon_file(path: &Path) -> Result<Option<EndpointInfo>> {
|
||||
let content = fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read: {}", path.display()))?;
|
||||
|
||||
let lexicon: Lexicon = serde_json::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse: {}", path.display()))?;
|
||||
|
||||
// Get the main definition type
|
||||
let main_def = match lexicon.defs.get("main") {
|
||||
Some(def) => def,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let method = match main_def.def_type.as_deref() {
|
||||
Some("query") => "GET",
|
||||
Some("procedure") => "POST",
|
||||
Some("subscription") => return Ok(None), // Skip websocket subscriptions
|
||||
_ => return Ok(None), // Skip records, tokens, etc.
|
||||
};
|
||||
|
||||
Ok(Some(EndpointInfo {
|
||||
nsid: lexicon.id,
|
||||
method: method.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn generate_rust_code(namespaces: &BTreeMap<String, Vec<EndpointInfo>>) -> String {
|
||||
let mut code = String::new();
|
||||
|
||||
// Header
|
||||
code.push_str("//! Auto-generated from ATProto lexicons\n");
|
||||
code.push_str("//! Run `ailog gen` to regenerate\n");
|
||||
code.push_str("//! Do not edit manually\n\n");
|
||||
code.push_str("#![allow(dead_code)]\n\n");
|
||||
|
||||
// Endpoint struct
|
||||
code.push_str("#[derive(Debug, Clone, Copy)]\n");
|
||||
code.push_str("pub struct Endpoint {\n");
|
||||
code.push_str(" pub nsid: &'static str,\n");
|
||||
code.push_str(" pub method: &'static str,\n");
|
||||
code.push_str("}\n\n");
|
||||
|
||||
// URL helper function
|
||||
code.push_str("/// Build XRPC URL for an endpoint\n");
|
||||
code.push_str("pub fn url(pds: &str, endpoint: &Endpoint) -> String {\n");
|
||||
code.push_str(" format!(\"https://{}/xrpc/{}\", pds, endpoint.nsid)\n");
|
||||
code.push_str("}\n\n");
|
||||
|
||||
// Generate modules for each namespace
|
||||
for (ns, endpoints) in namespaces {
|
||||
// Convert namespace to module name: com.atproto.repo -> com_atproto_repo
|
||||
let mod_name = ns.replace('.', "_");
|
||||
|
||||
code.push_str(&format!("pub mod {} {{\n", mod_name));
|
||||
code.push_str(" use super::Endpoint;\n\n");
|
||||
|
||||
for endpoint in endpoints {
|
||||
// Extract the method name from NSID: com.atproto.repo.listRecords -> LIST_RECORDS
|
||||
let method_name = endpoint.nsid
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.unwrap_or(&endpoint.nsid);
|
||||
|
||||
// Convert camelCase to SCREAMING_SNAKE_CASE
|
||||
let const_name = to_screaming_snake_case(method_name);
|
||||
|
||||
code.push_str(&format!(
|
||||
" pub const {}: Endpoint = Endpoint {{ nsid: \"{}\", method: \"{}\" }};\n",
|
||||
const_name, endpoint.nsid, endpoint.method
|
||||
));
|
||||
}
|
||||
|
||||
code.push_str("}\n\n");
|
||||
}
|
||||
|
||||
code
|
||||
}
|
||||
|
||||
fn generate_typescript_code(namespaces: &BTreeMap<String, Vec<EndpointInfo>>) -> String {
|
||||
let mut code = String::new();
|
||||
|
||||
// Header
|
||||
code.push_str("// Auto-generated from ATProto lexicons\n");
|
||||
code.push_str("// Run `ailog gen` to regenerate\n");
|
||||
code.push_str("// Do not edit manually\n\n");
|
||||
|
||||
// Endpoint type
|
||||
code.push_str("export interface Endpoint {\n");
|
||||
code.push_str(" nsid: string\n");
|
||||
code.push_str(" method: 'GET' | 'POST'\n");
|
||||
code.push_str("}\n\n");
|
||||
|
||||
// URL helper function
|
||||
code.push_str("/** Build XRPC URL for an endpoint */\n");
|
||||
code.push_str("export function xrpcUrl(pds: string, endpoint: Endpoint): string {\n");
|
||||
code.push_str(" return `https://${pds}/xrpc/${endpoint.nsid}`\n");
|
||||
code.push_str("}\n\n");
|
||||
|
||||
// Generate namespaces
|
||||
for (ns, endpoints) in namespaces {
|
||||
// Convert namespace to object name: com.atproto.repo -> comAtprotoRepo
|
||||
let obj_name = to_camel_case(&ns.replace('.', "_"));
|
||||
|
||||
code.push_str(&format!("export const {} = {{\n", obj_name));
|
||||
|
||||
for endpoint in endpoints {
|
||||
// Extract the method name from NSID: com.atproto.repo.listRecords -> listRecords
|
||||
let method_name = endpoint.nsid
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.unwrap_or(&endpoint.nsid);
|
||||
|
||||
code.push_str(&format!(
|
||||
" {}: {{ nsid: '{}', method: '{}' }} as Endpoint,\n",
|
||||
method_name, endpoint.nsid, endpoint.method
|
||||
));
|
||||
}
|
||||
|
||||
code.push_str("} as const\n\n");
|
||||
}
|
||||
|
||||
code
|
||||
}
|
||||
|
||||
fn to_screaming_snake_case(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
|
||||
for (i, c) in s.chars().enumerate() {
|
||||
if c.is_uppercase() && i > 0 {
|
||||
result.push('_');
|
||||
}
|
||||
result.push(c.to_ascii_uppercase());
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn to_camel_case(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut capitalize_next = false;
|
||||
|
||||
for (i, c) in s.chars().enumerate() {
|
||||
if c == '_' {
|
||||
capitalize_next = true;
|
||||
} else if capitalize_next {
|
||||
result.push(c.to_ascii_uppercase());
|
||||
capitalize_next = false;
|
||||
} else if i == 0 {
|
||||
result.push(c.to_ascii_lowercase());
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
10
src/commands/lang.rs
Normal file
10
src/commands/lang.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use anyhow::Result;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::lms;
|
||||
|
||||
/// Translate content files from one language to another
|
||||
pub async fn translate(input: &str, from: &str, to: &str) -> Result<()> {
|
||||
let path = Path::new(input);
|
||||
lms::translate::run(path, from, to).await
|
||||
}
|
||||
6
src/commands/mod.rs
Normal file
6
src/commands/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod auth;
|
||||
pub mod token;
|
||||
pub mod post;
|
||||
pub mod gen;
|
||||
pub mod lang;
|
||||
pub mod did;
|
||||
351
src/commands/post.rs
Normal file
351
src/commands/post.rs
Normal file
@@ -0,0 +1,351 @@
|
||||
use anyhow::{Context, Result};
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
|
||||
use super::auth;
|
||||
use crate::lexicons::{self, com_atproto_repo, com_atproto_identity};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PutRecordRequest {
|
||||
repo: String,
|
||||
collection: String,
|
||||
rkey: String,
|
||||
record: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DeleteRecordRequest {
|
||||
repo: String,
|
||||
collection: String,
|
||||
rkey: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PutRecordResponse {
|
||||
uri: String,
|
||||
cid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListRecordsResponse {
|
||||
records: Vec<Record>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Record {
|
||||
uri: String,
|
||||
cid: String,
|
||||
value: Value,
|
||||
}
|
||||
|
||||
/// Generate TID (timestamp-based ID)
|
||||
fn generate_tid() -> String {
|
||||
const CHARSET: &[u8] = b"234567abcdefghijklmnopqrstuvwxyz";
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..13)
|
||||
.map(|_| {
|
||||
let idx = rng.gen_range(0..CHARSET.len());
|
||||
CHARSET[idx] as char
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Put a record to ATProto
|
||||
pub async fn put_record(file: &str, collection: &str, rkey: Option<&str>) -> Result<()> {
|
||||
let session = auth::refresh_session().await?;
|
||||
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||
|
||||
let content = fs::read_to_string(file)
|
||||
.with_context(|| format!("Failed to read file: {}", file))?;
|
||||
let record: Value = serde_json::from_str(&content)?;
|
||||
|
||||
let rkey = rkey.map(|s| s.to_string()).unwrap_or_else(generate_tid);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = lexicons::url(pds, &com_atproto_repo::PUT_RECORD);
|
||||
|
||||
let req = PutRecordRequest {
|
||||
repo: session.did.clone(),
|
||||
collection: collection.to_string(),
|
||||
rkey: rkey.clone(),
|
||||
record,
|
||||
};
|
||||
|
||||
println!("Posting to {} with rkey: {}", collection, rkey);
|
||||
println!("{}", serde_json::to_string_pretty(&req)?);
|
||||
|
||||
let res = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", session.access_jwt))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Put record failed: {} - {}", status, body);
|
||||
}
|
||||
|
||||
let result: PutRecordResponse = res.json().await?;
|
||||
println!("Success!");
|
||||
println!(" URI: {}", result.uri);
|
||||
println!(" CID: {}", result.cid);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put a lexicon schema
|
||||
pub async fn put_lexicon(file: &str) -> Result<()> {
|
||||
let session = auth::refresh_session().await?;
|
||||
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||
|
||||
let content = fs::read_to_string(file)
|
||||
.with_context(|| format!("Failed to read file: {}", file))?;
|
||||
let lexicon: Value = serde_json::from_str(&content)?;
|
||||
|
||||
let lexicon_id = lexicon["id"]
|
||||
.as_str()
|
||||
.context("Lexicon file must have 'id' field")?
|
||||
.to_string();
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = lexicons::url(pds, &com_atproto_repo::PUT_RECORD);
|
||||
|
||||
let req = PutRecordRequest {
|
||||
repo: session.did.clone(),
|
||||
collection: "com.atproto.lexicon.schema".to_string(),
|
||||
rkey: lexicon_id.clone(),
|
||||
record: lexicon,
|
||||
};
|
||||
|
||||
println!("Putting lexicon: {}", lexicon_id);
|
||||
println!("{}", serde_json::to_string_pretty(&req)?);
|
||||
|
||||
let res = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", session.access_jwt))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Put lexicon failed: {} - {}", status, body);
|
||||
}
|
||||
|
||||
let result: PutRecordResponse = res.json().await?;
|
||||
println!("Success!");
|
||||
println!(" URI: {}", result.uri);
|
||||
println!(" CID: {}", result.cid);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get records from a collection
|
||||
pub async fn get_records(collection: &str, limit: u32) -> Result<()> {
|
||||
let session = auth::refresh_session().await?;
|
||||
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let base_url = lexicons::url(pds, &com_atproto_repo::LIST_RECORDS);
|
||||
let url = format!(
|
||||
"{}?repo={}&collection={}&limit={}",
|
||||
base_url, session.did, collection, limit
|
||||
);
|
||||
|
||||
let res = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", session.access_jwt))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Get records failed: {} - {}", status, body);
|
||||
}
|
||||
|
||||
let result: ListRecordsResponse = res.json().await?;
|
||||
|
||||
println!("Found {} records in {}", result.records.len(), collection);
|
||||
for record in &result.records {
|
||||
println!("---");
|
||||
println!("URI: {}", record.uri);
|
||||
println!("CID: {}", record.cid);
|
||||
println!("{}", serde_json::to_string_pretty(&record.value)?);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a record
|
||||
pub async fn delete_record(collection: &str, rkey: &str) -> Result<()> {
|
||||
let session = auth::refresh_session().await?;
|
||||
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = lexicons::url(pds, &com_atproto_repo::DELETE_RECORD);
|
||||
|
||||
let req = DeleteRecordRequest {
|
||||
repo: session.did.clone(),
|
||||
collection: collection.to_string(),
|
||||
rkey: rkey.to_string(),
|
||||
};
|
||||
|
||||
println!("Deleting {} from {}", rkey, collection);
|
||||
|
||||
let res = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", session.access_jwt))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Delete failed: {} - {}", status, body);
|
||||
}
|
||||
|
||||
println!("Deleted successfully");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Config {
|
||||
handle: String,
|
||||
#[serde(default)]
|
||||
collection: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DescribeRepoResponse {
|
||||
did: String,
|
||||
handle: String,
|
||||
collections: Vec<String>,
|
||||
}
|
||||
|
||||
/// Sync PDS data to local content directory
|
||||
pub async fn sync_to_local(output: &str) -> Result<()> {
|
||||
let config_content = fs::read_to_string("public/config.json")
|
||||
.context("config.json not found")?;
|
||||
let config: Config = serde_json::from_str(&config_content)?;
|
||||
|
||||
println!("Syncing data for {}", config.handle);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Resolve handle to DID
|
||||
let resolve_url = format!(
|
||||
"{}?handle={}",
|
||||
lexicons::url("public.api.bsky.app", &com_atproto_identity::RESOLVE_HANDLE),
|
||||
config.handle
|
||||
);
|
||||
let res = client.get(&resolve_url).send().await?;
|
||||
let resolve: serde_json::Value = res.json().await?;
|
||||
let did = resolve["did"].as_str().context("Could not resolve handle")?;
|
||||
|
||||
println!("DID: {}", did);
|
||||
|
||||
// Get PDS from DID document
|
||||
let plc_url = format!("https://plc.directory/{}", did);
|
||||
let res = client.get(&plc_url).send().await?;
|
||||
let did_doc: serde_json::Value = res.json().await?;
|
||||
let pds = did_doc["service"]
|
||||
.as_array()
|
||||
.and_then(|services| {
|
||||
services.iter().find(|s| s["type"] == "AtprotoPersonalDataServer")
|
||||
})
|
||||
.and_then(|s| s["serviceEndpoint"].as_str())
|
||||
.context("Could not find PDS")?;
|
||||
|
||||
println!("PDS: {}", pds);
|
||||
|
||||
// Remove https:// prefix for lexicons::url
|
||||
let pds_host = pds.trim_start_matches("https://");
|
||||
|
||||
// Create output directory
|
||||
let did_dir = format!("{}/{}", output, did);
|
||||
fs::create_dir_all(&did_dir)?;
|
||||
|
||||
// 1. Sync describeRepo
|
||||
let describe_url = format!(
|
||||
"{}?repo={}",
|
||||
lexicons::url(pds_host, &com_atproto_repo::DESCRIBE_REPO),
|
||||
did
|
||||
);
|
||||
let res = client.get(&describe_url).send().await?;
|
||||
let describe: DescribeRepoResponse = res.json().await?;
|
||||
|
||||
let describe_path = format!("{}/describe.json", did_dir);
|
||||
let describe_json = serde_json::to_string_pretty(&serde_json::json!({
|
||||
"did": describe.did,
|
||||
"handle": describe.handle,
|
||||
"collections": describe.collections,
|
||||
}))?;
|
||||
fs::write(&describe_path, &describe_json)?;
|
||||
println!("Saved: {}", describe_path);
|
||||
|
||||
// 2. Sync profile
|
||||
let profile_url = format!(
|
||||
"{}?repo={}&collection=app.bsky.actor.profile&rkey=self",
|
||||
lexicons::url(pds_host, &com_atproto_repo::GET_RECORD),
|
||||
did
|
||||
);
|
||||
let res = client.get(&profile_url).send().await?;
|
||||
if res.status().is_success() {
|
||||
let profile: serde_json::Value = res.json().await?;
|
||||
let profile_dir = format!("{}/app.bsky.actor.profile", did_dir);
|
||||
fs::create_dir_all(&profile_dir)?;
|
||||
let profile_path = format!("{}/self.json", profile_dir);
|
||||
fs::write(&profile_path, serde_json::to_string_pretty(&profile)?)?;
|
||||
println!("Saved: {}", profile_path);
|
||||
}
|
||||
|
||||
// 3. Sync collection records
|
||||
let collection = config.collection.as_deref().unwrap_or("ai.syui.log.post");
|
||||
let records_url = format!(
|
||||
"{}?repo={}&collection={}&limit=100",
|
||||
lexicons::url(pds_host, &com_atproto_repo::LIST_RECORDS),
|
||||
did, collection
|
||||
);
|
||||
let res = client.get(&records_url).send().await?;
|
||||
if res.status().is_success() {
|
||||
let list: ListRecordsResponse = res.json().await?;
|
||||
let collection_dir = format!("{}/{}", did_dir, collection);
|
||||
fs::create_dir_all(&collection_dir)?;
|
||||
|
||||
let mut rkeys: Vec<String> = Vec::new();
|
||||
for record in &list.records {
|
||||
let rkey = record.uri.split('/').last().unwrap_or("unknown");
|
||||
rkeys.push(rkey.to_string());
|
||||
let record_path = format!("{}/{}.json", collection_dir, rkey);
|
||||
let record_json = serde_json::json!({
|
||||
"uri": record.uri,
|
||||
"cid": record.cid,
|
||||
"value": record.value,
|
||||
});
|
||||
fs::write(&record_path, serde_json::to_string_pretty(&record_json)?)?;
|
||||
println!("Saved: {}", record_path);
|
||||
}
|
||||
|
||||
// Create index.json with list of rkeys
|
||||
let index_path = format!("{}/index.json", collection_dir);
|
||||
fs::write(&index_path, serde_json::to_string_pretty(&rkeys)?)?;
|
||||
println!("Saved: {}", index_path);
|
||||
|
||||
println!("Synced {} records from {}", list.records.len(), collection);
|
||||
}
|
||||
|
||||
println!("Sync complete!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
46
src/commands/token.rs
Normal file
46
src/commands/token.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Bundle ID for the application
|
||||
pub const BUNDLE_ID: &str = "ai.syui.log";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Session {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub access_jwt: String,
|
||||
pub refresh_jwt: String,
|
||||
#[serde(default)]
|
||||
pub pds: Option<String>,
|
||||
}
|
||||
|
||||
/// Get token file path: ~/Library/Application Support/ai.syui.log/token.json
|
||||
pub fn token_path() -> Result<PathBuf> {
|
||||
let config_dir = dirs::config_dir()
|
||||
.context("Could not find config directory")?
|
||||
.join(BUNDLE_ID);
|
||||
|
||||
fs::create_dir_all(&config_dir)?;
|
||||
Ok(config_dir.join("token.json"))
|
||||
}
|
||||
|
||||
/// Load session from token file
|
||||
pub fn load_session() -> Result<Session> {
|
||||
let path = token_path()?;
|
||||
let content = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Token file not found: {:?}. Run 'ailog login' first.", path))?;
|
||||
let session: Session = serde_json::from_str(&content)?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Save session to token file
|
||||
pub fn save_session(session: &Session) -> Result<()> {
|
||||
let path = token_path()?;
|
||||
let content = serde_json::to_string_pretty(session)?;
|
||||
fs::write(&path, content)?;
|
||||
println!("Token saved to {:?}", path);
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user