add translate

This commit is contained in:
2026-01-16 11:59:21 +09:00
parent 2533720014
commit b8922f38be
17 changed files with 1062 additions and 20 deletions

3
.env.example Normal file
View 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
View File

@@ -1,6 +1,8 @@
dist
.claude
repos
node_modules
package-lock.json
repos
claude.md
CLAUDE.md
.claude
.env
/rust/target

View File

@@ -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"
}

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

View File

@@ -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 = `<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 {
if (posts.length === 0) {
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 rkey = post.uri.split('/').pop()
// Default to English title if available
const displayTitle = post.translations?.en?.title || post.title
return `
<li class="post-item">
<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>
</a>
</li>
`
}).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
@@ -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
? `<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 `
<div class="content-header">${generateLangSelectorHtml()}</div>
${translationScript}
<article class="post-detail">
<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">
<time class="post-date">${formatDate(post.createdAt)}</time>
<a href="${jsonUrl}" class="json-btn">json</a>
</div>
</header>
<div class="post-content">${content}</div>
<div class="post-content">${displayContent}</div>
</article>
<div class="discussion-section">
<a href="${searchUrl}" target="_blank" rel="noopener" class="discuss-link">
@@ -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<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()))
// 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()
)

View File

@@ -243,8 +243,10 @@ const SERVICE_MAP: Record<string, { domain: string; icon?: string }> = {
// Search Bluesky posts mentioning a URL
export async function searchPostsForUrl(url: string): Promise<any[]> {
// 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<any[]> {
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<any[]> {
return embedUri === url || text.includes(url) || embedUri?.includes(url.replace(/\/$/, ''))
})
} catch {
// Silently fail for CORS/network/timeout errors
return []
}
})

View File

@@ -98,7 +98,11 @@ export async function restoreSession(): Promise<AuthSession | null> {
}
}
} 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
}

View File

@@ -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 `<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 {
let tabs = `
<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)
const jsonBtn = target.closest('.json-btn') as HTMLAnchorElement
if (jsonBtn) {
@@ -604,6 +739,32 @@ async function render(): Promise<void> {
// 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<void> {
// 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) {

View File

@@ -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;

View File

@@ -13,6 +13,12 @@ export interface BlogPost {
title: string
content: string
createdAt: string
translations?: {
[lang: string]: {
content: string
title?: string
}
}
}
export interface NetworkConfig {