use super::types::{
default_mode, error, ApiError, AppState, FuriganaParams, FuriganaResponse, MAX_TEXT_LEN,
};
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::Json;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use furigana::{Furigana, RomajiStyle, TtsOptions};
use serde_json::{json, Value};
use std::time::Instant;
pub(super) async fn healthz(State(state): State<AppState>) -> Json<Value> {
let f = state.furigana.read().await;
Json(json!({
"status": "ok",
"dict_size": f.dict_size(),
}))
}
pub(super) async fn furigana_get(
State(state): State<AppState>,
Query(params): Query<FuriganaParams>,
) -> Result<Json<FuriganaResponse>, ApiError> {
let f = state.furigana.read().await.clone();
process(f.as_ref(), ¶ms)
}
pub(super) async fn furigana_post(
State(state): State<AppState>,
Json(params): Json<FuriganaParams>,
) -> Result<Json<FuriganaResponse>, ApiError> {
let f = state.furigana.read().await.clone();
process(f.as_ref(), ¶ms)
}
pub(super) async fn admin_reload(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
let dict_size = do_reload(&state).await.map_err(|e| {
error(
StatusCode::INTERNAL_SERVER_ERROR,
format!("reload failed: {e}"),
)
})?;
Ok(Json(json!({
"status": "reloaded",
"dict_size": dict_size,
})))
}
pub(super) async fn do_reload(state: &AppState) -> Result<usize, String> {
let paths = state.paths.clone();
let new = tokio::task::spawn_blocking(move || crate::commands::build_furigana(&paths))
.await
.map_err(|e| format!("reload task join error: {e}"))?
.map_err(|e| format!("build_furigana failed: {e}"))?;
let new_arc = std::sync::Arc::new(new);
let dict_size = new_arc.dict_size();
*state.furigana.write().await = new_arc;
tracing::info!("辞書を reload しました (dict_size={dict_size})");
Ok(dict_size)
}
fn process(f: &Furigana, params: &FuriganaParams) -> Result<Json<FuriganaResponse>, ApiError> {
let text = decode_text(params)?;
validate_length(&text)?;
let mode = normalize_mode(¶ms.mode);
let t_start = Instant::now();
if mode == "analyze" {
let analyze_start = Instant::now();
let analyze_result = f.analyze(&text);
let t_convert_ms = analyze_start.elapsed().as_secs_f64() * 1000.0;
let t_total_ms = t_start.elapsed().as_secs_f64() * 1000.0;
let result: String = analyze_result
.tokens
.iter()
.map(|t| t.reading.as_str())
.collect();
let timings_ms = if params.debug {
Some(json!({
"total": round1(t_total_ms),
"tokenize": 0.0, "convert": round1(t_convert_ms),
}))
} else {
None
};
return Ok(Json(FuriganaResponse {
result,
mode,
segments: None,
timings_ms,
analyze: Some(analyze_result),
}));
}
let tokens_start = Instant::now();
let tokens = f.tokenize(&text);
let t_tokenize_ms = tokens_start.elapsed().as_secs_f64() * 1000.0;
let convert_start = Instant::now();
let result = match mode.as_str() {
"kanji" => text.clone(),
"ruby" => furigana::tokens_to_ruby(&tokens),
"hiragana" => furigana::tokens_to_hiragana(&tokens),
"romaji" => {
let hira = furigana::tokens_to_hiragana(&tokens);
furigana::hiragana_to_romaji(&hira, RomajiStyle::Hepburn)
}
"romaji-kunrei" => {
let hira = furigana::tokens_to_hiragana(&tokens);
furigana::hiragana_to_romaji(&hira, RomajiStyle::Kunrei)
}
_ => {
let opts = TtsOptions {
short_pause: params.short_pause.clone(),
long_pause: params.long_pause.clone(),
keep_period: params.keep_period,
};
let hira = furigana::tokens_to_hiragana(&tokens);
furigana::tts::normalize_for_tts(&hira, &opts)
}
};
let t_convert_ms = convert_start.elapsed().as_secs_f64() * 1000.0;
let t_total_ms = t_start.elapsed().as_secs_f64() * 1000.0;
let segments = if params.segmented && (mode == "tts" || mode == "hiragana") {
Some(furigana::tts::segment_for_tts(
&result,
params.max_segment_len,
))
} else {
None
};
let timings_ms = if params.debug {
Some(json!({
"total": round1(t_total_ms),
"tokenize": round1(t_tokenize_ms),
"convert": round1(t_convert_ms),
}))
} else {
None
};
Ok(Json(FuriganaResponse {
result,
mode,
segments,
timings_ms,
analyze: None,
}))
}
fn decode_text(params: &FuriganaParams) -> Result<String, ApiError> {
if let Some(b64) = params.text_b64.as_ref() {
let decoded = URL_SAFE_NO_PAD
.decode(b64.trim_end_matches('='))
.map_err(|_| error(StatusCode::BAD_REQUEST, "invalid base64 in text_b64"))?;
String::from_utf8(decoded).map_err(|_| {
error(
StatusCode::BAD_REQUEST,
"text_b64 decoded bytes are not valid UTF-8",
)
})
} else if let Some(t) = params.text.as_ref() {
Ok(t.clone())
} else {
Err(error(StatusCode::BAD_REQUEST, "no text provided"))
}
}
fn validate_length(text: &str) -> Result<(), ApiError> {
if text.is_empty() {
return Err(error(StatusCode::BAD_REQUEST, "no text provided"));
}
let nchars = text.chars().count();
if nchars > MAX_TEXT_LEN {
return Err(error(
StatusCode::BAD_REQUEST,
format!("text too long: {nchars} chars (max {MAX_TEXT_LEN})"),
));
}
Ok(())
}
fn normalize_mode(mode: &str) -> String {
match mode {
"tts" | "hiragana" | "ruby" | "kanji" | "romaji" | "romaji-kunrei" | "analyze" => {
mode.to_string()
}
_ => default_mode(),
}
}
fn round1(ms: f64) -> f64 {
(ms * 10.0).round() / 10.0
}