add translate

This commit is contained in:
2026-01-16 12:40:01 +09:00
parent 7d7130b23c
commit e2fdf135ea
17 changed files with 947 additions and 11 deletions

13
rust/Cargo.toml Normal file
View File

@@ -0,0 +1,13 @@
[package]
name = "ailog"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dotenvy = "0.15"
dirs = "5"

44
rust/src/auth.rs Normal file
View 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(())
}

View 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
View File

@@ -0,0 +1,4 @@
pub mod login;
pub mod refresh;
pub mod sync;
pub mod translate;

View 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
View 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(())
}

View File

@@ -0,0 +1,184 @@
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_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(());
}
// Get text to translate
let text = record.get("content")
.and_then(|v| v.as_str())
.ok_or("No 'content' field in JSON")?;
// Build plamo-2-translate prompt
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,
messages: vec![
ChatMessage { role: "system".to_string(), content: system_content },
ChatMessage { role: "user".to_string(), content: user_content },
],
};
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", translate_url);
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?;
let translated = chat_res.choices
.first()
.map(|c| c.message.content.trim().to_string())
.ok_or("No translation result")?;
// Add translation to record
let translations = record
.as_object_mut()
.ok_or("Invalid JSON")?
.entry("translations")
.or_insert_with(|| serde_json::json!({}));
translations
.as_object_mut()
.ok_or("Invalid translations field")?
.insert(to.to_string(), serde_json::json!({
"content": translated
}));
// 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
View 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(())
}