add translate
This commit is contained in:
44
rust/src/auth.rs
Normal file
44
rust/src/auth.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AuthConfig {
|
||||
pub handle: String,
|
||||
pub did: String,
|
||||
pub access_jwt: String,
|
||||
pub refresh_jwt: String,
|
||||
pub pds: String,
|
||||
}
|
||||
|
||||
pub fn config_dir() -> PathBuf {
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("syui")
|
||||
.join("ai")
|
||||
.join("log")
|
||||
}
|
||||
|
||||
pub fn config_path() -> PathBuf {
|
||||
config_dir().join("config.json")
|
||||
}
|
||||
|
||||
pub fn load_config() -> Option<AuthConfig> {
|
||||
let path = config_path();
|
||||
if path.exists() {
|
||||
let content = fs::read_to_string(&path).ok()?;
|
||||
serde_json::from_str(&content).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_config(config: &AuthConfig) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let dir = config_dir();
|
||||
fs::create_dir_all(&dir)?;
|
||||
let path = config_path();
|
||||
let content = serde_json::to_string_pretty(config)?;
|
||||
fs::write(&path, content)?;
|
||||
println!("Config saved to: {}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
66
rust/src/commands/login.rs
Normal file
66
rust/src/commands/login.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use crate::auth::{save_config, AuthConfig};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
pub async fn run(handle: &str, password: &str, server: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Add https:// if no protocol specified
|
||||
let server = if server.starts_with("http://") || server.starts_with("https://") {
|
||||
server.to_string()
|
||||
} else {
|
||||
format!("https://{}", server)
|
||||
};
|
||||
|
||||
println!("Logging in as {} to {}", handle, server);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/xrpc/com.atproto.server.createSession", server);
|
||||
|
||||
let req = CreateSessionRequest {
|
||||
identifier: handle.to_string(),
|
||||
password: password.to_string(),
|
||||
};
|
||||
|
||||
let res = client
|
||||
.post(&url)
|
||||
.json(&req)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await?;
|
||||
return Err(format!("Login failed ({}): {}", status, body).into());
|
||||
}
|
||||
|
||||
let session: CreateSessionResponse = res.json().await?;
|
||||
|
||||
let config = AuthConfig {
|
||||
handle: session.handle.clone(),
|
||||
did: session.did.clone(),
|
||||
access_jwt: session.access_jwt,
|
||||
refresh_jwt: session.refresh_jwt,
|
||||
pds: server.to_string(),
|
||||
};
|
||||
|
||||
save_config(&config)?;
|
||||
|
||||
println!("Logged in successfully!");
|
||||
println!("DID: {}", session.did);
|
||||
println!("Handle: {}", session.handle);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
4
rust/src/commands/mod.rs
Normal file
4
rust/src/commands/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod login;
|
||||
pub mod refresh;
|
||||
pub mod sync;
|
||||
pub mod translate;
|
||||
75
rust/src/commands/refresh.rs
Normal file
75
rust/src/commands/refresh.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use crate::auth::{load_config, save_config, AuthConfig};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RefreshSessionResponse {
|
||||
did: String,
|
||||
handle: String,
|
||||
access_jwt: String,
|
||||
refresh_jwt: String,
|
||||
}
|
||||
|
||||
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = load_config().ok_or("Not logged in. Run 'ailog l' first.")?;
|
||||
|
||||
println!("Refreshing session for {}", config.handle);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/xrpc/com.atproto.server.refreshSession", config.pds);
|
||||
|
||||
let res = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", config.refresh_jwt))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await?;
|
||||
return Err(format!("Refresh failed ({}): {}", status, body).into());
|
||||
}
|
||||
|
||||
let session: RefreshSessionResponse = res.json().await?;
|
||||
|
||||
let new_config = AuthConfig {
|
||||
handle: session.handle.clone(),
|
||||
did: session.did.clone(),
|
||||
access_jwt: session.access_jwt,
|
||||
refresh_jwt: session.refresh_jwt,
|
||||
pds: config.pds,
|
||||
};
|
||||
|
||||
save_config(&new_config)?;
|
||||
|
||||
println!("Session refreshed successfully!");
|
||||
println!("DID: {}", session.did);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refresh token if needed and return valid access token
|
||||
pub async fn get_valid_token() -> Result<String, Box<dyn std::error::Error>> {
|
||||
let config = load_config().ok_or("Not logged in. Run 'ailog l' first.")?;
|
||||
|
||||
// Try to use current token, if it fails, refresh
|
||||
let client = reqwest::Client::new();
|
||||
let test_url = format!("{}/xrpc/com.atproto.server.getSession", config.pds);
|
||||
|
||||
let res = client
|
||||
.get(&test_url)
|
||||
.header("Authorization", format!("Bearer {}", config.access_jwt))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if res.status().is_success() {
|
||||
return Ok(config.access_jwt);
|
||||
}
|
||||
|
||||
// Token expired, refresh it
|
||||
println!("Token expired, refreshing...");
|
||||
run().await?;
|
||||
|
||||
let new_config = load_config().ok_or("Failed to load refreshed config")?;
|
||||
Ok(new_config.access_jwt)
|
||||
}
|
||||
117
rust/src/commands/sync.rs
Normal file
117
rust/src/commands/sync.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use crate::auth::load_config;
|
||||
use crate::commands::refresh::get_valid_token;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PutRecordRequest {
|
||||
repo: String,
|
||||
collection: String,
|
||||
rkey: String,
|
||||
record: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct PutRecordResponse {
|
||||
uri: String,
|
||||
cid: String,
|
||||
}
|
||||
|
||||
pub async fn run(input: &Path, collection: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = load_config().ok_or("Not logged in. Run 'ailog l' first.")?;
|
||||
|
||||
if input.is_dir() {
|
||||
sync_folder(input, collection).await
|
||||
} else {
|
||||
sync_file(input, collection, &config.did, &config.pds).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync_file(
|
||||
input: &Path,
|
||||
collection: &str,
|
||||
did: &str,
|
||||
pds: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Extract rkey from filename (e.g., "3abc123.json" -> "3abc123")
|
||||
let rkey = input
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or("Invalid filename")?;
|
||||
|
||||
println!("Syncing {} -> {}/{}", input.display(), collection, rkey);
|
||||
|
||||
// Read and parse JSON
|
||||
let content = fs::read_to_string(input)?;
|
||||
let record: serde_json::Value = serde_json::from_str(&content)?;
|
||||
|
||||
// Get valid token (auto-refresh if needed)
|
||||
let token = get_valid_token().await?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/xrpc/com.atproto.repo.putRecord", pds);
|
||||
|
||||
let req = PutRecordRequest {
|
||||
repo: did.to_string(),
|
||||
collection: collection.to_string(),
|
||||
rkey: rkey.to_string(),
|
||||
record,
|
||||
};
|
||||
|
||||
let res = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await?;
|
||||
return Err(format!("Put failed ({}): {}", status, body).into());
|
||||
}
|
||||
|
||||
let result: PutRecordResponse = res.json().await?;
|
||||
println!(" OK: {}", result.uri);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_folder(dir: &Path, collection: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = load_config().ok_or("Not logged in. Run 'ailog l' first.")?;
|
||||
|
||||
let mut files: Vec<_> = fs::read_dir(dir)?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.map(|ext| ext == "json")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
files.sort_by_key(|e| e.path());
|
||||
|
||||
println!("Syncing {} files from {}", files.len(), dir.display());
|
||||
|
||||
let mut success = 0;
|
||||
let mut failed = 0;
|
||||
|
||||
for entry in files {
|
||||
let path = entry.path();
|
||||
match sync_file(&path, collection, &config.did, &config.pds).await {
|
||||
Ok(_) => success += 1,
|
||||
Err(e) => {
|
||||
eprintln!(" ERROR {}: {}", path.display(), e);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nDone: {} success, {} failed", success, failed);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
211
rust/src/commands/translate.rs
Normal file
211
rust/src/commands/translate.rs
Normal file
@@ -0,0 +1,211 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChatMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChatRequest {
|
||||
model: String,
|
||||
messages: Vec<ChatMessage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChatChoice {
|
||||
message: ChatMessageResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChatMessageResponse {
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChatResponse {
|
||||
choices: Vec<ChatChoice>,
|
||||
}
|
||||
|
||||
pub async fn run(input: &Path, from: &str, to: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if input.is_dir() {
|
||||
translate_folder(input, from, to).await
|
||||
} else {
|
||||
translate_file(input, from, to).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn translate_text(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
model: &str,
|
||||
text: &str,
|
||||
from: &str,
|
||||
to: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let from_lang = lang_name(from);
|
||||
let to_lang = lang_name(to);
|
||||
|
||||
let system_content = "<|plamo:op|>dataset\ntranslation".to_string();
|
||||
let user_content = format!(
|
||||
"<|plamo:op|>input lang={}\n{}\n<|plamo:op|>output lang={}",
|
||||
from_lang, text, to_lang
|
||||
);
|
||||
|
||||
let req = ChatRequest {
|
||||
model: model.to_string(),
|
||||
messages: vec![
|
||||
ChatMessage { role: "system".to_string(), content: system_content },
|
||||
ChatMessage { role: "user".to_string(), content: user_content },
|
||||
],
|
||||
};
|
||||
|
||||
let res = client
|
||||
.post(url)
|
||||
.json(&req)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await?;
|
||||
return Err(format!("Translation failed ({}): {}", status, body).into());
|
||||
}
|
||||
|
||||
let chat_res: ChatResponse = res.json().await?;
|
||||
chat_res.choices
|
||||
.first()
|
||||
.map(|c| c.message.content.trim().to_string())
|
||||
.ok_or_else(|| "No translation result".into())
|
||||
}
|
||||
|
||||
async fn translate_file(input: &Path, from: &str, to: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let translate_url = env::var("TRANSLATE_URL")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:1234/v1".to_string());
|
||||
let model = env::var("TRANSLATE_MODEL")
|
||||
.unwrap_or_else(|_| "plamo-2-translate".to_string());
|
||||
|
||||
println!("Translating: {}", input.display());
|
||||
|
||||
// Read input JSON
|
||||
let content = fs::read_to_string(input)?;
|
||||
let mut record: serde_json::Value = serde_json::from_str(&content)?;
|
||||
|
||||
// Check if already translated
|
||||
if record.get("translations")
|
||||
.and_then(|t| t.get(to))
|
||||
.is_some()
|
||||
{
|
||||
println!(" Skipped (already has {} translation)", to);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/chat/completions", translate_url);
|
||||
|
||||
// Translate title if exists
|
||||
let translated_title = if let Some(title) = record.get("title").and_then(|v| v.as_str()) {
|
||||
if !title.is_empty() {
|
||||
Some(translate_text(&client, &url, &model, title, from, to).await?)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Get and translate content
|
||||
let text = record.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("No 'content' field in JSON")?;
|
||||
|
||||
let translated_content = translate_text(&client, &url, &model, text, from, to).await?;
|
||||
|
||||
// Add translation to record
|
||||
let translations = record
|
||||
.as_object_mut()
|
||||
.ok_or("Invalid JSON")?
|
||||
.entry("translations")
|
||||
.or_insert_with(|| serde_json::json!({}));
|
||||
|
||||
let mut translation_entry = serde_json::json!({
|
||||
"content": translated_content
|
||||
});
|
||||
|
||||
if let Some(title) = translated_title {
|
||||
translation_entry.as_object_mut().unwrap().insert("title".to_string(), serde_json::json!(title));
|
||||
}
|
||||
|
||||
translations
|
||||
.as_object_mut()
|
||||
.ok_or("Invalid translations field")?
|
||||
.insert(to.to_string(), translation_entry);
|
||||
|
||||
// Write back
|
||||
let output = serde_json::to_string_pretty(&record)?;
|
||||
fs::write(input, output)?;
|
||||
|
||||
println!(" OK");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn translate_folder(dir: &Path, from: &str, to: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut files: Vec<_> = fs::read_dir(dir)?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.map(|ext| ext == "json")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
files.sort_by_key(|e| e.path());
|
||||
|
||||
println!("Translating {} files ({} -> {})", files.len(), from, to);
|
||||
|
||||
let mut success = 0;
|
||||
let mut skipped = 0;
|
||||
let mut failed = 0;
|
||||
|
||||
for entry in files {
|
||||
let path = entry.path();
|
||||
match translate_file(&path, from, to).await {
|
||||
Ok(_) => {
|
||||
// Check if it was actually translated or skipped
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let record: serde_json::Value = serde_json::from_str(&content)?;
|
||||
if record.get("translations").and_then(|t| t.get(to)).is_some() {
|
||||
success += 1;
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" ERROR {}: {}", path.display(), e);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nDone: {} translated, {} skipped, {} failed", success, skipped, failed);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn lang_name(code: &str) -> &str {
|
||||
match code {
|
||||
"ja" => "Japanese",
|
||||
"en" => "English",
|
||||
"zh" => "Chinese",
|
||||
"ko" => "Korean",
|
||||
"fr" => "French",
|
||||
"de" => "German",
|
||||
"es" => "Spanish",
|
||||
_ => code,
|
||||
}
|
||||
}
|
||||
112
rust/src/main.rs
Normal file
112
rust/src/main.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
mod auth;
|
||||
mod commands;
|
||||
|
||||
fn load_env() {
|
||||
// Try AILOG_DIR env var first (may be set externally)
|
||||
if let Ok(ailog_dir) = std::env::var("AILOG_DIR") {
|
||||
let env_path = PathBuf::from(&ailog_dir).join(".env");
|
||||
if env_path.exists() {
|
||||
let _ = dotenvy::from_path(&env_path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Try current directory
|
||||
if dotenvy::dotenv().is_ok() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try executable directory
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
if let Some(exe_dir) = exe_path.parent() {
|
||||
let env_path = exe_dir.join(".env");
|
||||
if env_path.exists() {
|
||||
let _ = dotenvy::from_path(&env_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_ailog_dir() -> PathBuf {
|
||||
std::env::var("AILOG_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "ailog")]
|
||||
#[command(about = "AT Protocol blog tool")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Login to PDS (createSession)
|
||||
#[command(name = "l")]
|
||||
Login {
|
||||
/// Handle (e.g., user.bsky.social)
|
||||
handle: String,
|
||||
/// Password
|
||||
#[arg(short, long)]
|
||||
password: String,
|
||||
/// PDS server (e.g., bsky.social)
|
||||
#[arg(short, long, default_value = "bsky.social")]
|
||||
server: String,
|
||||
},
|
||||
|
||||
/// Refresh session token
|
||||
#[command(name = "r")]
|
||||
Refresh,
|
||||
|
||||
/// Translate content (file or folder)
|
||||
#[command(name = "t")]
|
||||
Translate {
|
||||
/// Input JSON file or folder containing *.json
|
||||
input: PathBuf,
|
||||
/// Source language
|
||||
#[arg(short, long, default_value = "ja")]
|
||||
from: String,
|
||||
/// Target language
|
||||
#[arg(short = 'l', long, default_value = "en")]
|
||||
to: String,
|
||||
},
|
||||
|
||||
/// Sync records to PDS (putRecord)
|
||||
#[command(name = "s")]
|
||||
Sync {
|
||||
/// Input JSON file or folder containing *.json
|
||||
input: PathBuf,
|
||||
/// Collection NSID (e.g., ai.syui.log.post)
|
||||
#[arg(short, long)]
|
||||
collection: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
load_env();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Login { handle, password, server } => {
|
||||
commands::login::run(&handle, &password, &server).await?;
|
||||
}
|
||||
Commands::Refresh => {
|
||||
commands::refresh::run().await?;
|
||||
}
|
||||
Commands::Translate { input, from, to } => {
|
||||
commands::translate::run(&input, &from, &to).await?;
|
||||
}
|
||||
Commands::Sync { input, collection } => {
|
||||
commands::sync::run(&input, &collection).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user