diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..897bc32 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +AILOG_DIR=~/ai/log +TRANSLATE_URL=http://127.0.0.1:1234/v1 +TRANSLATE_MODEL=plamo-2-translate diff --git a/.gitignore b/.gitignore index 1952264..6f65164 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ dist -.claude +repos node_modules package-lock.json -repos -claude.md +CLAUDE.md +.claude +.env +/rust/target diff --git a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json index e1c3229..f675bd5 100644 --- a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json +++ b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json @@ -1,7 +1,13 @@ { - "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s", "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```", - "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" } \ No newline at end of file diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..aa827cc --- /dev/null +++ b/rust/Cargo.toml @@ -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" diff --git a/rust/src/auth.rs b/rust/src/auth.rs new file mode 100644 index 0000000..caab7b4 --- /dev/null +++ b/rust/src/auth.rs @@ -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 { + 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> { + 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(()) +} diff --git a/rust/src/commands/login.rs b/rust/src/commands/login.rs new file mode 100644 index 0000000..6e08e28 --- /dev/null +++ b/rust/src/commands/login.rs @@ -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> { + // 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(()) +} diff --git a/rust/src/commands/mod.rs b/rust/src/commands/mod.rs new file mode 100644 index 0000000..dc00d6b --- /dev/null +++ b/rust/src/commands/mod.rs @@ -0,0 +1,4 @@ +pub mod login; +pub mod refresh; +pub mod sync; +pub mod translate; diff --git a/rust/src/commands/refresh.rs b/rust/src/commands/refresh.rs new file mode 100644 index 0000000..13c5086 --- /dev/null +++ b/rust/src/commands/refresh.rs @@ -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> { + 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> { + 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) +} diff --git a/rust/src/commands/sync.rs b/rust/src/commands/sync.rs new file mode 100644 index 0000000..5069a9c --- /dev/null +++ b/rust/src/commands/sync.rs @@ -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> { + 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> { + // 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> { + 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(()) +} diff --git a/rust/src/commands/translate.rs b/rust/src/commands/translate.rs new file mode 100644 index 0000000..1d1cb93 --- /dev/null +++ b/rust/src/commands/translate.rs @@ -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, +} + +#[derive(Debug, Deserialize)] +struct ChatChoice { + message: ChatMessageResponse, +} + +#[derive(Debug, Deserialize)] +struct ChatMessageResponse { + content: String, +} + +#[derive(Debug, Deserialize)] +struct ChatResponse { + choices: Vec, +} + +pub async fn run(input: &Path, from: &str, to: &str) -> Result<(), Box> { + 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> { + 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> { + 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> { + 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, + } +} diff --git a/rust/src/main.rs b/rust/src/main.rs new file mode 100644 index 0000000..8f1e942 --- /dev/null +++ b/rust/src/main.rs @@ -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> { + 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(()) +} diff --git a/scripts/generate.ts b/scripts/generate.ts index b51bcff..7b19fbb 100644 --- a/scripts/generate.ts +++ b/scripts/generate.ts @@ -114,6 +114,7 @@ async function listRecordsFromApi(did: string, collection: string, pdsUrl: strin title: r.value.title as string || 'Untitled', content: r.value.content as string || '', 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()) } @@ -394,22 +395,68 @@ function generateTabsHtml(activeTab: 'blog' | 'browser', handle: string): string ` } +function generateLangSelectorHtml(): string { + const langIcon = `` + + return ` +
+ +
+
+ 日本語 + +
+
+ English + +
+
+
+ ` +} + function generatePostListHtml(posts: BlogPost[]): string { if (posts.length === 0) { return '

No posts yet

' } + + // Build translations data for titles + const titleTranslations: Record = {} + 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 + ? `` + : '' + const items = posts.map(post => { const rkey = post.uri.split('/').pop() + // Default to English title if available + const displayTitle = post.translations?.en?.title || post.title return `
  • - ${escapeHtml(post.title)} + ${escapeHtml(displayTitle)}
  • ` }).join('') - return `
      ${items}
    ` + return ` +
    ${generateLangSelectorHtml()}
    + ${translationScript} +
      ${items}
    + ` } // 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 { const rkey = post.uri.split('/').pop() || '' 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 const baseSiteUrl = siteUrl || `https://${handle}` const postUrl = `${baseSiteUrl}/post/${rkey}/` @@ -439,16 +494,32 @@ function generatePostDetailHtml(post: BlogPost, handle: string, collection: stri const searchQuery = basePath + rkeyPrefix 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 + ? `` + : '' + + // Default to English title if available + const displayTitle = post.translations?.en?.title || post.title + return ` +
    ${generateLangSelectorHtml()}
    + ${translationScript}
    -

    ${escapeHtml(post.title)}

    +

    ${escapeHtml(displayTitle)}

    -
    ${content}
    +
    ${displayContent}
    @@ -645,16 +716,32 @@ async function generate() { const localPosts = localDid ? loadPostsFromFiles(localDid, config.collection) : [] console.log(`Found ${localPosts.length} posts from local`) - // Merge: API is the source of truth - // - If post exists in API: always use API (has latest edits) - // - If post exists in local only: keep if not deleted (for posts beyond API limit) + // Merge: API is the source of truth for content, but local has translations + // - If post exists in both: use API data but merge translations from local + // - If post exists in API only: use API + // - If post exists in local only: keep (for posts beyond API limit) + const localPostMap = new Map() + 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())) + // 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) - // Note: these might be deleted posts, so we keep them cautiously 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() ) diff --git a/src/lib/api.ts b/src/lib/api.ts index 3a4a4bd..62b2c40 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -243,8 +243,10 @@ const SERVICE_MAP: Record = { // Search Bluesky posts mentioning a URL export async function searchPostsForUrl(url: string): Promise { - // Search ALL endpoints and merge results (different networks have different indexes) - const endpoints = [getBsky(), ...FALLBACK_ENDPOINTS.filter(e => e !== getBsky())] + // Only use current network's endpoint - don't cross-search other networks + // 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 // 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 { const searchPromises = endpoints.flatMap(endpoint => searchQueries.map(async query => { try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 5000) // 5s timeout + 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 [] const data = await res.json() // Filter posts that actually link to the target URL @@ -282,6 +290,7 @@ export async function searchPostsForUrl(url: string): Promise { return embedUri === url || text.includes(url) || embedUri?.includes(url.replace(/\/$/, '')) }) } catch { + // Silently fail for CORS/network/timeout errors return [] } }) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 300fcb0..997a48f 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -98,7 +98,11 @@ export async function restoreSession(): Promise { } } } 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 } diff --git a/src/main.ts b/src/main.ts index c2c4e60..26cf135 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,7 @@ let authSession: AuthSession | null = null let config: AppConfig let networks: Networks = {} let browserNetwork: string = '' // Network for AT Browser +let currentLang: string = 'en' // Default language for translations // Browser state 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 `
    + ${lang.name} + +
    ` + }).join('') + + const langIcon = `` + + return ` +
    + +
    + ${options} +
    +
    + ` +} + +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 + + // 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 { let tabs = `
    Blog @@ -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) const jsonBtn = target.closest('.json-btn') as HTMLAnchorElement if (jsonBtn) { @@ -604,6 +739,32 @@ async function render(): Promise { // For blog top page, check for new posts from API and merge if (route.type === 'blog') { 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 @@ -712,6 +873,9 @@ async function init(): Promise { // Initialize browser network from localStorage or default to 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 const selectedNetworkConfig = networks[browserNetwork] if (selectedNetworkConfig) { diff --git a/src/styles/main.css b/src/styles/main.css index f888b55..31c11c8 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -961,6 +961,125 @@ body { 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 */ .server-info { padding: 16px 0; diff --git a/src/types.ts b/src/types.ts index 040d7d5..982d32b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,12 @@ export interface BlogPost { title: string content: string createdAt: string + translations?: { + [lang: string]: { + content: string + title?: string + } + } } export interface NetworkConfig {