add translate
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
AILOG_DIR=~/ai/log
|
||||||
|
TRANSLATE_URL=http://127.0.0.1:1234/v1
|
||||||
|
TRANSLATE_MODEL=plamo-2-translate
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,6 +1,8 @@
|
|||||||
dist
|
dist
|
||||||
.claude
|
repos
|
||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
package-lock.json
|
||||||
repos
|
CLAUDE.md
|
||||||
claude.md
|
.claude
|
||||||
|
.env
|
||||||
|
/rust/target
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
{
|
{
|
||||||
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s",
|
|
||||||
"cid": "bafyreidymanu2xk4ftmvfdna3j7ixyijc37s6h3aytstuqgzatgjl4tp7e",
|
"cid": "bafyreidymanu2xk4ftmvfdna3j7ixyijc37s6h3aytstuqgzatgjl4tp7e",
|
||||||
"title": "ailogを作り直した",
|
|
||||||
"content": "## ailogとは\n\natprotoと連携するサイトジェネレータ。\n\n## ailogの使い方\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailogのコンセプト\n\n1. at-browserを基本にする\n2. atproto oauthでログインする\n3. ログインしたアカウントで記事をポストする\n\n## ailogの追加機能\n\n1. atproto recordからjsonをdownloadすると表示速度が上がる(ただし更新はlocalから)\n2. コメントはurlの言及を検索して表示\n\n```sh\n$ npm run fetch\n$ npm run generate\n```",
|
"content": "## ailogとは\n\natprotoと連携するサイトジェネレータ。\n\n## ailogの使い方\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailogのコンセプト\n\n1. at-browserを基本にする\n2. atproto oauthでログインする\n3. ログインしたアカウントで記事をポストする\n\n## ailogの追加機能\n\n1. atproto recordからjsonをdownloadすると表示速度が上がる(ただし更新はlocalから)\n2. コメントはurlの言及を検索して表示\n\n```sh\n$ npm run fetch\n$ npm run generate\n```",
|
||||||
"createdAt": "2026-01-15T13:59:52.367Z"
|
"createdAt": "2026-01-15T13:59:52.367Z",
|
||||||
|
"title": "ailogを作り直した",
|
||||||
|
"translations": {
|
||||||
|
"en": {
|
||||||
|
"content": "## What is ailog?\n\nA site generator that integrates with the atproto framework.\n\n## How to Use ailog\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailog's Concept\n\n1. Based on at-browser as its foundation\n2. Authentication via atproto oAuth\n3. Post articles using the logged-in account\n\n## Additional Features of ailog\n\n1. Downloading JSON from atproto record improves display speed (though updates still come from local storage)\n2. Comments are displayed by searching for URL mentions\n\n```sh\n$ npm run fetch\n$ npm run generate\n```",
|
||||||
|
"title": "recreated ailog"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s"
|
||||||
}
|
}
|
||||||
13
rust/Cargo.toml
Normal file
13
rust/Cargo.toml
Normal 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
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(())
|
||||||
|
}
|
||||||
@@ -114,6 +114,7 @@ async function listRecordsFromApi(did: string, collection: string, pdsUrl: strin
|
|||||||
title: r.value.title as string || 'Untitled',
|
title: r.value.title as string || 'Untitled',
|
||||||
content: r.value.content as string || '',
|
content: r.value.content as string || '',
|
||||||
createdAt: r.value.createdAt as string || new Date().toISOString(),
|
createdAt: r.value.createdAt as string || new Date().toISOString(),
|
||||||
|
translations: r.value.translations as BlogPost['translations'] || undefined,
|
||||||
})).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
})).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,22 +395,68 @@ function generateTabsHtml(activeTab: 'blog' | 'browser', handle: string): string
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateLangSelectorHtml(): string {
|
||||||
|
const langIcon = `<svg viewBox="0 0 640 640" width="20" height="20" fill="currentColor"><path d="M192 64C209.7 64 224 78.3 224 96L224 128L352 128C369.7 128 384 142.3 384 160C384 177.7 369.7 192 352 192L342.4 192L334 215.1C317.6 260.3 292.9 301.6 261.8 337.1C276 345.9 290.8 353.7 306.2 360.6L356.6 383L418.8 243C423.9 231.4 435.4 224 448 224C460.6 224 472.1 231.4 477.2 243L605.2 531C612.4 547.2 605.1 566.1 589 573.2C572.9 580.3 553.9 573.1 546.8 557L526.8 512L369.3 512L349.3 557C342.1 573.2 323.2 580.4 307.1 573.2C291 566 283.7 547.1 290.9 531L330.7 441.5L280.3 419.1C257.3 408.9 235.3 396.7 214.5 382.7C193.2 399.9 169.9 414.9 145 427.4L110.3 444.6C94.5 452.5 75.3 446.1 67.4 430.3C59.5 414.5 65.9 395.3 81.7 387.4L116.2 370.1C132.5 361.9 148 352.4 162.6 341.8C148.8 329.1 135.8 315.4 123.7 300.9L113.6 288.7C102.3 275.1 104.1 254.9 117.7 243.6C131.3 232.3 151.5 234.1 162.8 247.7L173 259.9C184.5 273.8 197.1 286.7 210.4 298.6C237.9 268.2 259.6 232.5 273.9 193.2L274.4 192L64.1 192C46.3 192 32 177.7 32 160C32 142.3 46.3 128 64 128L160 128L160 96C160 78.3 174.3 64 192 64zM448 334.8L397.7 448L498.3 448L448 334.8z"/></svg>`
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="lang-selector" id="lang-selector">
|
||||||
|
<button type="button" class="lang-btn" id="lang-btn" title="Language">
|
||||||
|
${langIcon}
|
||||||
|
</button>
|
||||||
|
<div class="lang-dropdown" id="lang-dropdown">
|
||||||
|
<div class="lang-option" data-lang="ja">
|
||||||
|
<span class="lang-name">日本語</span>
|
||||||
|
<span class="lang-check">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="lang-option selected" data-lang="en">
|
||||||
|
<span class="lang-name">English</span>
|
||||||
|
<span class="lang-check">✓</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
function generatePostListHtml(posts: BlogPost[]): string {
|
function generatePostListHtml(posts: BlogPost[]): string {
|
||||||
if (posts.length === 0) {
|
if (posts.length === 0) {
|
||||||
return '<p class="no-posts">No posts yet</p>'
|
return '<p class="no-posts">No posts yet</p>'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build translations data for titles
|
||||||
|
const titleTranslations: Record<string, { original: string; translated: string }> = {}
|
||||||
|
posts.forEach(post => {
|
||||||
|
const rkey = post.uri.split('/').pop()
|
||||||
|
if (rkey && post.translations?.en?.title) {
|
||||||
|
titleTranslations[rkey] = {
|
||||||
|
original: post.title,
|
||||||
|
translated: post.translations.en.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasTranslations = Object.keys(titleTranslations).length > 0
|
||||||
|
const translationScript = hasTranslations
|
||||||
|
? `<script id="title-translations" type="application/json">${JSON.stringify(titleTranslations)}</script>`
|
||||||
|
: ''
|
||||||
|
|
||||||
const items = posts.map(post => {
|
const items = posts.map(post => {
|
||||||
const rkey = post.uri.split('/').pop()
|
const rkey = post.uri.split('/').pop()
|
||||||
|
// Default to English title if available
|
||||||
|
const displayTitle = post.translations?.en?.title || post.title
|
||||||
return `
|
return `
|
||||||
<li class="post-item">
|
<li class="post-item">
|
||||||
<a href="/post/${rkey}/" class="post-link">
|
<a href="/post/${rkey}/" class="post-link">
|
||||||
<span class="post-title">${escapeHtml(post.title)}</span>
|
<span class="post-title" data-rkey="${rkey}">${escapeHtml(displayTitle)}</span>
|
||||||
<span class="post-date">${formatDate(post.createdAt)}</span>
|
<span class="post-date">${formatDate(post.createdAt)}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
`
|
`
|
||||||
}).join('')
|
}).join('')
|
||||||
return `<ul class="post-list">${items}</ul>`
|
return `
|
||||||
|
<div class="content-header">${generateLangSelectorHtml()}</div>
|
||||||
|
${translationScript}
|
||||||
|
<ul class="post-list">${items}</ul>
|
||||||
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map network to app URL for discussion links
|
// Map network to app URL for discussion links
|
||||||
@@ -423,7 +470,15 @@ function getAppUrl(network: string): string {
|
|||||||
function generatePostDetailHtml(post: BlogPost, handle: string, collection: string, network: string, siteUrl?: string): string {
|
function generatePostDetailHtml(post: BlogPost, handle: string, collection: string, network: string, siteUrl?: string): string {
|
||||||
const rkey = post.uri.split('/').pop() || ''
|
const rkey = post.uri.split('/').pop() || ''
|
||||||
const jsonUrl = `/at/${handle}/${collection}/${rkey}/`
|
const jsonUrl = `/at/${handle}/${collection}/${rkey}/`
|
||||||
const content = marked.parse(post.content) as string
|
const originalContent = marked.parse(post.content) as string
|
||||||
|
|
||||||
|
// Check for English translation
|
||||||
|
const hasTranslation = post.translations?.en?.content
|
||||||
|
const translatedContent = hasTranslation ? marked.parse(post.translations!.en.content) as string : ''
|
||||||
|
|
||||||
|
// Default to English if translation exists
|
||||||
|
const displayContent = hasTranslation ? translatedContent : originalContent
|
||||||
|
|
||||||
// Use siteUrl from config, or construct from handle
|
// Use siteUrl from config, or construct from handle
|
||||||
const baseSiteUrl = siteUrl || `https://${handle}`
|
const baseSiteUrl = siteUrl || `https://${handle}`
|
||||||
const postUrl = `${baseSiteUrl}/post/${rkey}/`
|
const postUrl = `${baseSiteUrl}/post/${rkey}/`
|
||||||
@@ -439,16 +494,32 @@ function generatePostDetailHtml(post: BlogPost, handle: string, collection: stri
|
|||||||
const searchQuery = basePath + rkeyPrefix
|
const searchQuery = basePath + rkeyPrefix
|
||||||
const searchUrl = `${appUrl}/search?q=${encodeURIComponent(searchQuery)}`
|
const searchUrl = `${appUrl}/search?q=${encodeURIComponent(searchQuery)}`
|
||||||
|
|
||||||
|
// Store translation data in script tag for JS switching
|
||||||
|
const hasTranslatedTitle = !!post.translations?.en?.title
|
||||||
|
const translationScript = hasTranslation
|
||||||
|
? `<script id="translation-data" type="application/json">${JSON.stringify({
|
||||||
|
original: originalContent,
|
||||||
|
translated: translatedContent,
|
||||||
|
originalTitle: post.title,
|
||||||
|
translatedTitle: post.translations?.en?.title || post.title
|
||||||
|
})}</script>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
// Default to English title if available
|
||||||
|
const displayTitle = post.translations?.en?.title || post.title
|
||||||
|
|
||||||
return `
|
return `
|
||||||
|
<div class="content-header">${generateLangSelectorHtml()}</div>
|
||||||
|
${translationScript}
|
||||||
<article class="post-detail">
|
<article class="post-detail">
|
||||||
<header class="post-header">
|
<header class="post-header">
|
||||||
<h1 class="post-title">${escapeHtml(post.title)}</h1>
|
<h1 class="post-title" id="post-detail-title">${escapeHtml(displayTitle)}</h1>
|
||||||
<div class="post-meta">
|
<div class="post-meta">
|
||||||
<time class="post-date">${formatDate(post.createdAt)}</time>
|
<time class="post-date">${formatDate(post.createdAt)}</time>
|
||||||
<a href="${jsonUrl}" class="json-btn">json</a>
|
<a href="${jsonUrl}" class="json-btn">json</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="post-content">${content}</div>
|
<div class="post-content">${displayContent}</div>
|
||||||
</article>
|
</article>
|
||||||
<div class="discussion-section">
|
<div class="discussion-section">
|
||||||
<a href="${searchUrl}" target="_blank" rel="noopener" class="discuss-link">
|
<a href="${searchUrl}" target="_blank" rel="noopener" class="discuss-link">
|
||||||
@@ -645,16 +716,32 @@ async function generate() {
|
|||||||
const localPosts = localDid ? loadPostsFromFiles(localDid, config.collection) : []
|
const localPosts = localDid ? loadPostsFromFiles(localDid, config.collection) : []
|
||||||
console.log(`Found ${localPosts.length} posts from local`)
|
console.log(`Found ${localPosts.length} posts from local`)
|
||||||
|
|
||||||
// Merge: API is the source of truth
|
// Merge: API is the source of truth for content, but local has translations
|
||||||
// - If post exists in API: always use API (has latest edits)
|
// - If post exists in both: use API data but merge translations from local
|
||||||
// - If post exists in local only: keep if not deleted (for posts beyond API limit)
|
// - If post exists in API only: use API
|
||||||
|
// - If post exists in local only: keep (for posts beyond API limit)
|
||||||
|
const localPostMap = new Map<string, BlogPost>()
|
||||||
|
for (const post of localPosts) {
|
||||||
|
const rkey = post.uri.split('/').pop()
|
||||||
|
if (rkey) localPostMap.set(rkey, post)
|
||||||
|
}
|
||||||
|
|
||||||
const apiRkeys = new Set(apiPosts.map(p => p.uri.split('/').pop()))
|
const apiRkeys = new Set(apiPosts.map(p => p.uri.split('/').pop()))
|
||||||
|
|
||||||
|
// Merge API posts with local translations
|
||||||
|
const mergedApiPosts = apiPosts.map(apiPost => {
|
||||||
|
const rkey = apiPost.uri.split('/').pop()
|
||||||
|
const localPost = rkey ? localPostMap.get(rkey) : undefined
|
||||||
|
if (localPost?.translations && !apiPost.translations) {
|
||||||
|
return { ...apiPost, translations: localPost.translations }
|
||||||
|
}
|
||||||
|
return apiPost
|
||||||
|
})
|
||||||
|
|
||||||
// Local posts that don't exist in API (older posts beyond 100 limit)
|
// Local posts that don't exist in API (older posts beyond 100 limit)
|
||||||
// Note: these might be deleted posts, so we keep them cautiously
|
|
||||||
const oldLocalPosts = localPosts.filter(p => !apiRkeys.has(p.uri.split('/').pop()))
|
const oldLocalPosts = localPosts.filter(p => !apiRkeys.has(p.uri.split('/').pop()))
|
||||||
|
|
||||||
posts = [...apiPosts, ...oldLocalPosts].sort((a, b) =>
|
posts = [...mergedApiPosts, ...oldLocalPosts].sort((a, b) =>
|
||||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -243,8 +243,10 @@ const SERVICE_MAP: Record<string, { domain: string; icon?: string }> = {
|
|||||||
|
|
||||||
// Search Bluesky posts mentioning a URL
|
// Search Bluesky posts mentioning a URL
|
||||||
export async function searchPostsForUrl(url: string): Promise<any[]> {
|
export async function searchPostsForUrl(url: string): Promise<any[]> {
|
||||||
// Search ALL endpoints and merge results (different networks have different indexes)
|
// Only use current network's endpoint - don't cross-search other networks
|
||||||
const endpoints = [getBsky(), ...FALLBACK_ENDPOINTS.filter(e => e !== getBsky())]
|
// This avoids CORS issues with public.api.bsky.app when using different PDS
|
||||||
|
const currentBsky = getBsky()
|
||||||
|
const endpoints = [currentBsky]
|
||||||
|
|
||||||
// Extract search-friendly patterns from URL
|
// Extract search-friendly patterns from URL
|
||||||
// e.g., "https://syui.ai/post/abc123/" -> ["syui.ai/post/abc123", "syui.ai/post"]
|
// e.g., "https://syui.ai/post/abc123/" -> ["syui.ai/post/abc123", "syui.ai/post"]
|
||||||
@@ -270,9 +272,15 @@ export async function searchPostsForUrl(url: string): Promise<any[]> {
|
|||||||
const searchPromises = endpoints.flatMap(endpoint =>
|
const searchPromises = endpoints.flatMap(endpoint =>
|
||||||
searchQueries.map(async query => {
|
searchQueries.map(async query => {
|
||||||
try {
|
try {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 5000) // 5s timeout
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${endpoint}/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}&limit=20`
|
`${endpoint}/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}&limit=20`,
|
||||||
|
{ signal: controller.signal }
|
||||||
)
|
)
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
if (!res.ok) return []
|
if (!res.ok) return []
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
// Filter posts that actually link to the target URL
|
// Filter posts that actually link to the target URL
|
||||||
@@ -282,6 +290,7 @@ export async function searchPostsForUrl(url: string): Promise<any[]> {
|
|||||||
return embedUri === url || text.includes(url) || embedUri?.includes(url.replace(/\/$/, ''))
|
return embedUri === url || text.includes(url) || embedUri?.includes(url.replace(/\/$/, ''))
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
|
// Silently fail for CORS/network/timeout errors
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -98,7 +98,11 @@ export async function restoreSession(): Promise<AuthSession | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Session restore error:', err)
|
// Silently fail for CORS/network errors - don't spam console
|
||||||
|
// Only log if it's not a network error
|
||||||
|
if (err instanceof Error && !err.message.includes('NetworkError') && !err.message.includes('CORS')) {
|
||||||
|
console.error('Session restore error:', err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
164
src/main.ts
164
src/main.ts
@@ -15,6 +15,7 @@ let authSession: AuthSession | null = null
|
|||||||
let config: AppConfig
|
let config: AppConfig
|
||||||
let networks: Networks = {}
|
let networks: Networks = {}
|
||||||
let browserNetwork: string = '' // Network for AT Browser
|
let browserNetwork: string = '' // Network for AT Browser
|
||||||
|
let currentLang: string = 'en' // Default language for translations
|
||||||
|
|
||||||
// Browser state
|
// Browser state
|
||||||
let browserMode = false
|
let browserMode = false
|
||||||
@@ -131,6 +132,101 @@ function updatePdsSelector(): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderLangSelector(): string {
|
||||||
|
const langs = [
|
||||||
|
{ code: 'ja', name: '日本語' },
|
||||||
|
{ code: 'en', name: 'English' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const options = langs.map(lang => {
|
||||||
|
const isSelected = lang.code === currentLang
|
||||||
|
return `<div class="lang-option ${isSelected ? 'selected' : ''}" data-lang="${lang.code}">
|
||||||
|
<span class="lang-name">${lang.name}</span>
|
||||||
|
<span class="lang-check">✓</span>
|
||||||
|
</div>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
const langIcon = `<svg viewBox="0 0 640 640" width="20" height="20" fill="currentColor"><path d="M192 64C209.7 64 224 78.3 224 96L224 128L352 128C369.7 128 384 142.3 384 160C384 177.7 369.7 192 352 192L342.4 192L334 215.1C317.6 260.3 292.9 301.6 261.8 337.1C276 345.9 290.8 353.7 306.2 360.6L356.6 383L418.8 243C423.9 231.4 435.4 224 448 224C460.6 224 472.1 231.4 477.2 243L605.2 531C612.4 547.2 605.1 566.1 589 573.2C572.9 580.3 553.9 573.1 546.8 557L526.8 512L369.3 512L349.3 557C342.1 573.2 323.2 580.4 307.1 573.2C291 566 283.7 547.1 290.9 531L330.7 441.5L280.3 419.1C257.3 408.9 235.3 396.7 214.5 382.7C193.2 399.9 169.9 414.9 145 427.4L110.3 444.6C94.5 452.5 75.3 446.1 67.4 430.3C59.5 414.5 65.9 395.3 81.7 387.4L116.2 370.1C132.5 361.9 148 352.4 162.6 341.8C148.8 329.1 135.8 315.4 123.7 300.9L113.6 288.7C102.3 275.1 104.1 254.9 117.7 243.6C131.3 232.3 151.5 234.1 162.8 247.7L173 259.9C184.5 273.8 197.1 286.7 210.4 298.6C237.9 268.2 259.6 232.5 273.9 193.2L274.4 192L64.1 192C46.3 192 32 177.7 32 160C32 142.3 46.3 128 64 128L160 128L160 96C160 78.3 174.3 64 192 64zM448 334.8L397.7 448L498.3 448L448 334.8z"/></svg>`
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="lang-selector" id="lang-selector">
|
||||||
|
<button type="button" class="lang-btn" id="lang-btn" title="Language">
|
||||||
|
${langIcon}
|
||||||
|
</button>
|
||||||
|
<div class="lang-dropdown" id="lang-dropdown">
|
||||||
|
${options}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLangSelector(): void {
|
||||||
|
const dropdown = document.getElementById('lang-dropdown')
|
||||||
|
if (!dropdown) return
|
||||||
|
|
||||||
|
const options = dropdown.querySelectorAll('.lang-option')
|
||||||
|
options.forEach(opt => {
|
||||||
|
const el = opt as HTMLElement
|
||||||
|
const lang = el.dataset.lang
|
||||||
|
const isSelected = lang === currentLang
|
||||||
|
el.classList.toggle('selected', isSelected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTranslation(): void {
|
||||||
|
const contentEl = document.querySelector('.post-content')
|
||||||
|
const titleEl = document.getElementById('post-detail-title')
|
||||||
|
|
||||||
|
// Get translation data from script tag
|
||||||
|
const scriptEl = document.getElementById('translation-data')
|
||||||
|
if (!scriptEl) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(scriptEl.textContent || '{}')
|
||||||
|
|
||||||
|
// Apply content translation
|
||||||
|
if (contentEl) {
|
||||||
|
if (currentLang === 'en' && data.translated) {
|
||||||
|
contentEl.innerHTML = data.translated
|
||||||
|
} else if (data.original) {
|
||||||
|
contentEl.innerHTML = data.original
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply title translation
|
||||||
|
if (titleEl) {
|
||||||
|
if (currentLang === 'en' && data.translatedTitle) {
|
||||||
|
titleEl.textContent = data.translatedTitle
|
||||||
|
} else if (data.originalTitle) {
|
||||||
|
titleEl.textContent = data.originalTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid JSON, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTitleTranslations(): void {
|
||||||
|
// Get title translations from script tag
|
||||||
|
const scriptEl = document.getElementById('title-translations')
|
||||||
|
if (!scriptEl) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const translations = JSON.parse(scriptEl.textContent || '{}') as Record<string, { original: string; translated: string }>
|
||||||
|
|
||||||
|
// Update each post title
|
||||||
|
document.querySelectorAll('.post-title[data-rkey]').forEach(el => {
|
||||||
|
const rkey = (el as HTMLElement).dataset.rkey
|
||||||
|
if (rkey && translations[rkey]) {
|
||||||
|
const { original, translated } = translations[rkey]
|
||||||
|
el.textContent = currentLang === 'en' ? translated : original
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Invalid JSON, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean): string {
|
function renderTabs(activeTab: 'blog' | 'browser' | 'new', isLoggedIn: boolean): string {
|
||||||
let tabs = `
|
let tabs = `
|
||||||
<a href="/" class="tab ${activeTab === 'blog' ? 'active' : ''}" id="blog-tab">Blog</a>
|
<a href="/" class="tab ${activeTab === 'blog' ? 'active' : ''}" id="blog-tab">Blog</a>
|
||||||
@@ -467,6 +563,45 @@ function setupEventHandlers(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lang button - toggle dropdown
|
||||||
|
if (target.id === 'lang-btn' || target.closest('#lang-btn')) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const dropdown = document.getElementById('lang-dropdown')
|
||||||
|
if (dropdown) {
|
||||||
|
dropdown.classList.toggle('show')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lang option selection
|
||||||
|
const langOption = target.closest('.lang-option') as HTMLElement
|
||||||
|
if (langOption) {
|
||||||
|
e.preventDefault()
|
||||||
|
const selectedLang = langOption.dataset.lang
|
||||||
|
if (selectedLang && selectedLang !== currentLang) {
|
||||||
|
currentLang = selectedLang
|
||||||
|
localStorage.setItem('preferredLang', selectedLang)
|
||||||
|
updateLangSelector()
|
||||||
|
applyTranslation()
|
||||||
|
applyTitleTranslations()
|
||||||
|
}
|
||||||
|
// Close dropdown
|
||||||
|
const dropdown = document.getElementById('lang-dropdown')
|
||||||
|
if (dropdown) {
|
||||||
|
dropdown.classList.remove('show')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close lang dropdown when clicking outside
|
||||||
|
if (!target.closest('#lang-selector')) {
|
||||||
|
const dropdown = document.getElementById('lang-dropdown')
|
||||||
|
if (dropdown) {
|
||||||
|
dropdown.classList.remove('show')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// JSON button click (on post detail page)
|
// JSON button click (on post detail page)
|
||||||
const jsonBtn = target.closest('.json-btn') as HTMLAnchorElement
|
const jsonBtn = target.closest('.json-btn') as HTMLAnchorElement
|
||||||
if (jsonBtn) {
|
if (jsonBtn) {
|
||||||
@@ -604,6 +739,32 @@ async function render(): Promise<void> {
|
|||||||
// For blog top page, check for new posts from API and merge
|
// For blog top page, check for new posts from API and merge
|
||||||
if (route.type === 'blog') {
|
if (route.type === 'blog') {
|
||||||
refreshPostListFromAPI()
|
refreshPostListFromAPI()
|
||||||
|
// Add lang selector above post list
|
||||||
|
const postList = contentEl?.querySelector('.post-list')
|
||||||
|
if (postList && !document.getElementById('lang-selector')) {
|
||||||
|
const contentHeader = document.createElement('div')
|
||||||
|
contentHeader.className = 'content-header'
|
||||||
|
contentHeader.innerHTML = renderLangSelector()
|
||||||
|
postList.parentNode?.insertBefore(contentHeader, postList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For post detail page, sync lang selector state and apply translation
|
||||||
|
if (route.type === 'post') {
|
||||||
|
// Update lang selector to match current language
|
||||||
|
updateLangSelector()
|
||||||
|
|
||||||
|
// Apply translation based on current language preference
|
||||||
|
const translationScript = document.getElementById('translation-data')
|
||||||
|
if (translationScript) {
|
||||||
|
applyTranslation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For blog index page, sync lang selector state and apply title translations
|
||||||
|
if (route.type === 'blog') {
|
||||||
|
updateLangSelector()
|
||||||
|
applyTitleTranslations()
|
||||||
}
|
}
|
||||||
|
|
||||||
return // Skip content re-rendering
|
return // Skip content re-rendering
|
||||||
@@ -712,6 +873,9 @@ async function init(): Promise<void> {
|
|||||||
// Initialize browser network from localStorage or default to config.network
|
// Initialize browser network from localStorage or default to config.network
|
||||||
browserNetwork = localStorage.getItem('browserNetwork') || config.network
|
browserNetwork = localStorage.getItem('browserNetwork') || config.network
|
||||||
|
|
||||||
|
// Initialize language preference from localStorage (default: en)
|
||||||
|
currentLang = localStorage.getItem('preferredLang') || 'en'
|
||||||
|
|
||||||
// Set network config based on selected browser network
|
// Set network config based on selected browser network
|
||||||
const selectedNetworkConfig = networks[browserNetwork]
|
const selectedNetworkConfig = networks[browserNetwork]
|
||||||
if (selectedNetworkConfig) {
|
if (selectedNetworkConfig) {
|
||||||
|
|||||||
@@ -961,6 +961,125 @@ body {
|
|||||||
color: transparent;
|
color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Language Selector */
|
||||||
|
.lang-selector {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
min-width: 140px;
|
||||||
|
z-index: 100;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-dropdown.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-option {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-option:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-option.selected {
|
||||||
|
background: linear-gradient(135deg, #f0f7ff 0%, #e8f4ff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-name {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-check {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #ccc;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-option.selected .lang-check {
|
||||||
|
background: var(--btn-color);
|
||||||
|
border-color: var(--btn-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-option:not(.selected) .lang-check {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Header (above post list) */
|
||||||
|
.content-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.lang-btn {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-color: #333;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.lang-btn:hover {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
.lang-dropdown {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-color: #333;
|
||||||
|
}
|
||||||
|
.lang-option:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
.lang-option.selected {
|
||||||
|
background: linear-gradient(135deg, #1a2a3a 0%, #1a3040 100%);
|
||||||
|
}
|
||||||
|
.lang-name {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* AT Browser */
|
/* AT Browser */
|
||||||
.server-info {
|
.server-info {
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ export interface BlogPost {
|
|||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
translations?: {
|
||||||
|
[lang: string]: {
|
||||||
|
content: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetworkConfig {
|
export interface NetworkConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user