Skip to main content

gettext_mcp/web/
mod.rs

1use axum::{
2    extract::{Path, Query, State},
3    http::StatusCode,
4    response::IntoResponse,
5    routing::{delete, get, post},
6    Json, Router,
7};
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Value};
10use std::net::SocketAddr;
11use std::sync::Arc;
12use tower_http::cors::CorsLayer;
13
14use crate::service::GettextStoreManager;
15
16/// Web server configuration
17pub struct WebConfig {
18    pub addr: SocketAddr,
19    pub manager: Arc<GettextStoreManager>,
20}
21
22/// Request/Response types for REST API
23
24#[derive(Debug, Serialize, Deserialize)]
25pub struct FileInfo {
26    pub path: String,
27    pub name: String,
28    pub language: Option<String>,
29}
30
31#[derive(Debug, Serialize, Deserialize)]
32pub struct TranslationRecord {
33    pub msgid: String,
34    pub msgctxt: Option<String>,
35    pub msgstr: String,
36    pub msgid_plural: Option<String>,
37    pub msgstr_plural: Vec<String>,
38    pub is_translated: bool,
39    pub is_fuzzy: bool,
40    pub flags: Vec<String>,
41}
42
43#[derive(Debug, Serialize, Deserialize)]
44pub struct TranslationDetail {
45    pub msgid: String,
46    pub msgctxt: Option<String>,
47    pub msgstr: String,
48    pub msgid_plural: Option<String>,
49    pub msgstr_plural: Vec<String>,
50    pub is_translated: bool,
51    pub is_fuzzy: bool,
52    pub flags: Vec<String>,
53    pub extracted_comment: Option<String>,
54    pub translator_comment: Option<String>,
55    pub source_locations: Vec<String>,
56}
57
58#[derive(Debug, Deserialize)]
59pub struct ListTranslationsQuery {
60    pub file: Option<String>,
61    pub query: Option<String>,
62    pub limit: Option<usize>,
63    pub msgctxt: Option<String>,
64}
65
66#[derive(Debug, Deserialize)]
67pub struct FileQuery {
68    pub file: Option<String>,
69}
70
71#[derive(Debug, Deserialize)]
72pub struct TranslationQuery {
73    pub file: Option<String>,
74    pub msgid: String,
75    pub msgctxt: Option<String>,
76}
77
78#[derive(Debug, Deserialize)]
79pub struct UpsertTranslationPayload {
80    pub msgid: String,
81    pub msgctxt: Option<String>,
82    pub msgstr: String,
83    pub msgid_plural: Option<String>,
84    pub msgstr_plural: Option<Vec<String>>,
85}
86
87#[derive(Debug, Deserialize)]
88pub struct UpdateMetadataPayload {
89    pub key: String,
90    pub value: String,
91}
92
93#[derive(Debug, Serialize)]
94pub struct Metadata {
95    pub encoding: Option<String>,
96    pub language: Option<String>,
97    pub plural_forms: Option<String>,
98}
99
100#[derive(Clone)]
101struct AppState {
102    manager: Arc<GettextStoreManager>,
103}
104
105/// Start the web server
106pub async fn serve(config: WebConfig) -> Result<(), Box<dyn std::error::Error>> {
107    let state = AppState {
108        manager: config.manager,
109    };
110
111    let app = Router::new()
112        .route("/", get(index_html))
113        .route("/api/files", get(list_files))
114        .route("/api/translations", get(list_translations))
115        .route("/api/translations", post(upsert_translation))
116        .route("/api/translations/detail", get(get_translation))
117        .route("/api/translations/detail", delete(delete_translation))
118        .route("/api/metadata", get(get_metadata))
119        .route("/api/metadata", post(update_metadata))
120        .route("/api/languages", get(list_languages))
121        .route("/api/languages", post(add_language))
122        .route("/api/languages/{language}", delete(remove_language))
123        .layer(
124            CorsLayer::new()
125                .allow_origin([
126                    "http://localhost"
127                        .parse::<axum::http::HeaderValue>()
128                        .unwrap(),
129                    "http://127.0.0.1"
130                        .parse::<axum::http::HeaderValue>()
131                        .unwrap(),
132                    format!("http://localhost:{}", config.addr.port())
133                        .parse::<axum::http::HeaderValue>()
134                        .unwrap(),
135                    format!("http://127.0.0.1:{}", config.addr.port())
136                        .parse::<axum::http::HeaderValue>()
137                        .unwrap(),
138                ])
139                .allow_methods([
140                    axum::http::Method::GET,
141                    axum::http::Method::POST,
142                    axum::http::Method::DELETE,
143                ])
144                .allow_headers([axum::http::header::CONTENT_TYPE]),
145        )
146        .with_state(state);
147
148    let listener = tokio::net::TcpListener::bind(config.addr).await?;
149    tracing::info!("Web server listening on {}", config.addr);
150    axum::serve(listener, app).await?;
151
152    Ok(())
153}
154
155// ==================== Route Handlers ====================
156
157/// Serve the SPA HTML
158async fn index_html() -> impl IntoResponse {
159    (
160        StatusCode::OK,
161        [("content-type", "text/html; charset=utf-8")],
162        SPA_HTML,
163    )
164}
165
166/// List discovered .po files
167async fn list_files(
168    State(state): State<AppState>,
169) -> Result<Json<Vec<FileInfo>>, (StatusCode, String)> {
170    let discovered = state.manager.discovered_paths().await;
171    let base_dir = state.manager.base_dir().map(|p| p.to_path_buf());
172
173    let files: Vec<FileInfo> = discovered
174        .iter()
175        .filter_map(|path| {
176            let name = path.file_name()?.to_str()?.to_string();
177            let language = extract_language(path, base_dir.as_deref());
178            Some(FileInfo {
179                path: path.to_string_lossy().to_string(),
180                name,
181                language,
182            })
183        })
184        .collect();
185
186    Ok(Json(files))
187}
188
189/// Extract language code from a PO file path.
190/// Looks for the standard gettext layout: `{lang}/LC_MESSAGES/*.po`
191/// Falls back to checking path components against the base dir.
192fn extract_language(path: &std::path::Path, base_dir: Option<&std::path::Path>) -> Option<String> {
193    // .pot files are templates, not language-specific
194    if path.extension().and_then(|e| e.to_str()) == Some("pot") {
195        return None;
196    }
197
198    // Standard layout: .../{lang}/LC_MESSAGES/file.po
199    let components: Vec<_> = path.components().collect();
200    for (i, comp) in components.iter().enumerate() {
201        if let std::path::Component::Normal(s) = comp {
202            if s.to_str() == Some("LC_MESSAGES") && i > 0 {
203                if let std::path::Component::Normal(lang) = &components[i - 1] {
204                    return lang.to_str().map(|s| s.to_string());
205                }
206            }
207        }
208    }
209
210    // Fallback: first path component relative to the base dir
211    if let Some(base) = base_dir {
212        if let Ok(rel) = path.strip_prefix(base) {
213            let first = rel.components().next()?;
214            if let std::path::Component::Normal(s) = first {
215                let s = s.to_str()?;
216                // Only return if it looks like a language dir (not the file itself)
217                if rel.components().count() > 1 {
218                    return Some(s.to_string());
219                }
220            }
221        }
222    }
223
224    None
225}
226
227/// List translations with optional filtering
228async fn list_translations(
229    State(state): State<AppState>,
230    Query(params): Query<ListTranslationsQuery>,
231) -> Result<Json<Vec<TranslationRecord>>, (StatusCode, String)> {
232    let store = state
233        .manager
234        .store_for(params.file.as_deref())
235        .await
236        .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
237
238    let entries = store
239        .list_all()
240        .await
241        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
242
243    let mut filtered: Vec<_> = entries
244        .into_iter()
245        .filter(|(_, ctx, _)| {
246            if let Some(ref filter_ctx) = params.msgctxt {
247                ctx.as_deref() == Some(filter_ctx.as_str())
248            } else {
249                true
250            }
251        })
252        .filter(|(msgid, _, entry)| {
253            if let Some(ref query) = params.query {
254                let q_lower = query.to_lowercase();
255                msgid.to_lowercase().contains(&q_lower)
256                    || entry.msgstr.to_lowercase().contains(&q_lower)
257                    || entry
258                        .msgid_plural
259                        .as_deref()
260                        .is_some_and(|p| p.to_lowercase().contains(&q_lower))
261                    || entry
262                        .msgstr_plural
263                        .iter()
264                        .any(|p| p.to_lowercase().contains(&q_lower))
265            } else {
266                true
267            }
268        })
269        .collect();
270
271    if let Some(limit) = params.limit {
272        filtered.truncate(limit);
273    }
274
275    let records: Vec<_> = filtered
276        .into_iter()
277        .map(|(msgid, msgctxt, entry)| {
278            let is_translated = entry.is_translated();
279            let is_fuzzy = entry.is_fuzzy();
280            TranslationRecord {
281                msgid,
282                msgctxt,
283                msgstr: entry.msgstr,
284                msgid_plural: entry.msgid_plural,
285                msgstr_plural: entry.msgstr_plural,
286                is_translated,
287                is_fuzzy,
288                flags: entry.flags,
289            }
290        })
291        .collect();
292
293    Ok(Json(records))
294}
295
296/// Get a single translation entry
297async fn get_translation(
298    State(state): State<AppState>,
299    Query(params): Query<TranslationQuery>,
300) -> Result<Json<TranslationDetail>, (StatusCode, String)> {
301    let store = state
302        .manager
303        .store_for(params.file.as_deref())
304        .await
305        .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
306
307    let entry = store
308        .get(&params.msgid, params.msgctxt.as_deref())
309        .await
310        .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
311
312    let detail = TranslationDetail {
313        msgid: params.msgid,
314        msgctxt: params.msgctxt,
315        msgstr: entry.msgstr.clone(),
316        msgid_plural: entry.msgid_plural.clone(),
317        msgstr_plural: entry.msgstr_plural.clone(),
318        is_translated: entry.is_translated(),
319        is_fuzzy: entry.is_fuzzy(),
320        flags: entry.flags.clone(),
321        extracted_comment: if entry.extracted_comment.is_empty() {
322            None
323        } else {
324            Some(entry.extracted_comment.join("\n"))
325        },
326        translator_comment: if entry.translator_comment.is_empty() {
327            None
328        } else {
329            Some(entry.translator_comment.join("\n"))
330        },
331        source_locations: entry.source_locations.clone(),
332    };
333
334    Ok(Json(detail))
335}
336
337/// Create or update a translation
338async fn upsert_translation(
339    State(state): State<AppState>,
340    Query(params): Query<FileQuery>,
341    Json(payload): Json<UpsertTranslationPayload>,
342) -> Result<Json<Value>, (StatusCode, String)> {
343    let store = state
344        .manager
345        .store_for(params.file.as_deref())
346        .await
347        .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
348
349    store
350        .upsert_full(
351            &payload.msgid,
352            payload.msgctxt.as_deref(),
353            &payload.msgstr,
354            payload.msgid_plural.as_deref(),
355            payload.msgstr_plural,
356            None,
357        )
358        .await
359        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
360
361    Ok(Json(json!({ "success": true })))
362}
363
364/// Delete a translation
365async fn delete_translation(
366    State(state): State<AppState>,
367    Query(params): Query<TranslationQuery>,
368) -> Result<Json<Value>, (StatusCode, String)> {
369    let store = state
370        .manager
371        .store_for(params.file.as_deref())
372        .await
373        .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
374
375    store
376        .delete(&params.msgid, params.msgctxt.as_deref())
377        .await
378        .map_err(|e| {
379            let status = if e.to_string().contains("not found") {
380                StatusCode::NOT_FOUND
381            } else {
382                StatusCode::INTERNAL_SERVER_ERROR
383            };
384            (status, e.to_string())
385        })?;
386
387    Ok(Json(json!({ "success": true })))
388}
389
390/// Get file metadata (encoding, language, plural forms)
391async fn get_metadata(
392    State(state): State<AppState>,
393    Query(params): Query<FileQuery>,
394) -> Result<Json<Metadata>, (StatusCode, String)> {
395    let store = state
396        .manager
397        .store_for(params.file.as_deref())
398        .await
399        .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
400
401    let metadata = store
402        .metadata()
403        .await
404        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
405
406    Ok(Json(Metadata {
407        encoding: metadata
408            .get("Content-Type")
409            .and_then(|ct| ct.split("charset=").nth(1).map(|s| s.trim().to_string())),
410        language: metadata.get("Language").cloned(),
411        plural_forms: metadata.get("Plural-Forms").cloned(),
412    }))
413}
414
415/// Update metadata
416async fn update_metadata(
417    State(state): State<AppState>,
418    Query(params): Query<FileQuery>,
419    Json(payload): Json<UpdateMetadataPayload>,
420) -> Result<Json<Value>, (StatusCode, String)> {
421    let store = state
422        .manager
423        .store_for(params.file.as_deref())
424        .await
425        .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
426
427    store
428        .set_header(&payload.key, &payload.value)
429        .await
430        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
431
432    Ok(Json(json!({ "success": true })))
433}
434
435/// List all languages
436async fn list_languages(
437    State(state): State<AppState>,
438    Query(params): Query<FileQuery>,
439) -> Result<Json<Vec<String>>, (StatusCode, String)> {
440    let store = state
441        .manager
442        .store_for(params.file.as_deref())
443        .await
444        .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
445
446    let languages = store
447        .list_languages()
448        .await
449        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
450
451    Ok(Json(languages))
452}
453
454/// Add a new language
455async fn add_language(
456    State(state): State<AppState>,
457    Query(params): Query<FileQuery>,
458    Json(payload): Json<serde_json::Map<String, Value>>,
459) -> Result<Json<Value>, (StatusCode, String)> {
460    let language = payload
461        .get("language")
462        .and_then(|v| v.as_str())
463        .ok_or((StatusCode::BAD_REQUEST, "Missing language".to_string()))?;
464
465    let store = state
466        .manager
467        .store_for(params.file.as_deref())
468        .await
469        .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
470
471    store
472        .add_language(language)
473        .await
474        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
475
476    Ok(Json(json!({ "success": true })))
477}
478
479/// Remove a language
480async fn remove_language(
481    State(state): State<AppState>,
482    Query(params): Query<FileQuery>,
483    Path(language): Path<String>,
484) -> Result<Json<Value>, (StatusCode, String)> {
485    let store = state
486        .manager
487        .store_for(params.file.as_deref())
488        .await
489        .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
490
491    store
492        .remove_language(&language)
493        .await
494        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
495
496    Ok(Json(json!({ "success": true })))
497}
498
499// ==================== Embedded SPA HTML ====================
500
501const SPA_HTML: &str = r#"<!DOCTYPE html>
502<html lang="en">
503<head>
504    <meta charset="UTF-8">
505    <meta name="viewport" content="width=device-width, initial-scale=1.0">
506    <title>Gettext Translation Manager</title>
507    <style>
508        * {
509            margin: 0;
510            padding: 0;
511            box-sizing: border-box;
512        }
513
514        body {
515            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
516            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
517            min-height: 100vh;
518            padding: 20px;
519        }
520
521        .container {
522            max-width: 1400px;
523            margin: 0 auto;
524        }
525
526        header {
527            background: white;
528            padding: 20px;
529            border-radius: 8px;
530            box-shadow: 0 2px 8px rgba(0,0,0,0.1);
531            margin-bottom: 20px;
532        }
533
534        h1 {
535            color: #667eea;
536            margin-bottom: 10px;
537        }
538
539        .controls {
540            display: flex;
541            gap: 15px;
542            flex-wrap: wrap;
543            align-items: center;
544        }
545
546        input[type="text"], select {
547            padding: 8px 12px;
548            border: 1px solid #ddd;
549            border-radius: 4px;
550            font-size: 14px;
551        }
552
553        input[type="text"] {
554            flex: 1;
555            min-width: 200px;
556        }
557
558        button {
559            padding: 8px 16px;
560            background: #667eea;
561            color: white;
562            border: none;
563            border-radius: 4px;
564            cursor: pointer;
565            font-weight: 500;
566            transition: background 0.3s;
567        }
568
569        button:hover {
570            background: #5568d3;
571        }
572
573        button.secondary {
574            background: #f0f0f0;
575            color: #333;
576        }
577
578        button.secondary:hover {
579            background: #e0e0e0;
580        }
581
582        button.danger {
583            background: #e74c3c;
584        }
585
586        button.danger:hover {
587            background: #c0392b;
588        }
589
590        .main-content {
591            display: grid;
592            grid-template-columns: 1fr 2fr;
593            gap: 20px;
594        }
595
596        .sidebar {
597            background: white;
598            padding: 20px;
599            border-radius: 8px;
600            box-shadow: 0 2px 8px rgba(0,0,0,0.1);
601            height: fit-content;
602        }
603
604        .sidebar h2 {
605            color: #667eea;
606            margin-bottom: 15px;
607            font-size: 16px;
608        }
609
610        .file-list {
611            list-style: none;
612        }
613
614        .file-group {
615            margin-bottom: 8px;
616        }
617
618        .file-group-header {
619            padding: 8px 10px;
620            background: #667eea;
621            color: white;
622            border-radius: 4px;
623            cursor: pointer;
624            font-weight: 600;
625            font-size: 13px;
626            display: flex;
627            justify-content: space-between;
628            align-items: center;
629            user-select: none;
630        }
631
632        .file-group-header:hover {
633            background: #5568d3;
634        }
635
636        .file-group-header .arrow {
637            transition: transform 0.2s;
638            font-size: 10px;
639        }
640
641        .file-group-header.collapsed .arrow {
642            transform: rotate(-90deg);
643        }
644
645        .file-group-items {
646            list-style: none;
647            padding-left: 0;
648        }
649
650        .file-group-items.hidden {
651            display: none;
652        }
653
654        .file-list li {
655            padding: 8px 10px 8px 20px;
656            margin-top: 2px;
657            background: #f5f5f5;
658            border-radius: 4px;
659            cursor: pointer;
660            transition: background 0.2s;
661            font-size: 13px;
662        }
663
664        .file-list li:hover {
665            background: #e8e8ff;
666        }
667
668        .file-list li.active {
669            background: #e8e8ff;
670            color: #667eea;
671            font-weight: 600;
672        }
673
674        .language-list {
675            list-style: none;
676            margin-top: 15px;
677        }
678
679        .language-list li {
680            padding: 8px;
681            background: #f5f5f5;
682            border-radius: 4px;
683            margin-bottom: 5px;
684            display: flex;
685            justify-content: space-between;
686            align-items: center;
687        }
688
689        .language-list li button {
690            padding: 4px 8px;
691            font-size: 12px;
692        }
693
694        .content-area {
695            background: white;
696            padding: 20px;
697            border-radius: 8px;
698            box-shadow: 0 2px 8px rgba(0,0,0,0.1);
699        }
700
701        .stats {
702            display: flex;
703            gap: 20px;
704            margin-bottom: 20px;
705            flex-wrap: wrap;
706        }
707
708        .stat-card {
709            background: #f8f9fa;
710            padding: 15px;
711            border-radius: 4px;
712            border-left: 4px solid #667eea;
713        }
714
715        .stat-card strong {
716            display: block;
717            color: #667eea;
718            margin-bottom: 5px;
719        }
720
721        .translations-table {
722            width: 100%;
723            border-collapse: collapse;
724            margin-top: 20px;
725        }
726
727        .translations-table thead {
728            background: #f5f5f5;
729        }
730
731        .translations-table th {
732            padding: 12px;
733            text-align: left;
734            font-weight: 600;
735            color: #333;
736            border-bottom: 2px solid #ddd;
737        }
738
739        .translations-table td {
740            padding: 12px;
741            border-bottom: 1px solid #eee;
742        }
743
744        .translations-table tr:hover {
745            background: #f9f9f9;
746        }
747
748        .status-badge {
749            display: inline-block;
750            padding: 4px 8px;
751            border-radius: 3px;
752            font-size: 12px;
753            font-weight: 500;
754        }
755
756        .status-translated {
757            background: #d4edda;
758            color: #155724;
759        }
760
761        .status-untranslated {
762            background: #f8d7da;
763            color: #721c24;
764        }
765
766        .status-fuzzy {
767            background: #fff3cd;
768            color: #856404;
769        }
770
771        .action-buttons {
772            display: flex;
773            gap: 8px;
774        }
775
776        .action-buttons button {
777            padding: 4px 8px;
778            font-size: 12px;
779        }
780
781        .modal {
782            display: none;
783            position: fixed;
784            top: 0;
785            left: 0;
786            width: 100%;
787            height: 100%;
788            background: rgba(0,0,0,0.5);
789            z-index: 1000;
790            align-items: center;
791            justify-content: center;
792        }
793
794        .modal.active {
795            display: flex;
796        }
797
798        .modal-content {
799            background: white;
800            padding: 30px;
801            border-radius: 8px;
802            max-width: 600px;
803            width: 90%;
804            max-height: 80vh;
805            overflow-y: auto;
806        }
807
808        .modal-content h2 {
809            color: #667eea;
810            margin-bottom: 20px;
811        }
812
813        .form-group {
814            margin-bottom: 15px;
815        }
816
817        .form-group label {
818            display: block;
819            margin-bottom: 5px;
820            color: #333;
821            font-weight: 500;
822        }
823
824        .form-group input,
825        .form-group textarea {
826            width: 100%;
827            padding: 8px;
828            border: 1px solid #ddd;
829            border-radius: 4px;
830            font-family: inherit;
831        }
832
833        .form-group textarea {
834            resize: vertical;
835            min-height: 60px;
836        }
837
838        .form-actions {
839            display: flex;
840            gap: 10px;
841            margin-top: 20px;
842            justify-content: flex-end;
843        }
844
845        .progress-bar {
846            width: 100%;
847            height: 20px;
848            background: #e0e0e0;
849            border-radius: 10px;
850            overflow: hidden;
851            margin-top: 10px;
852        }
853
854        .progress-fill {
855            height: 100%;
856            background: linear-gradient(90deg, #667eea, #764ba2);
857            display: flex;
858            align-items: center;
859            justify-content: center;
860            color: white;
861            font-size: 12px;
862            transition: width 0.3s;
863        }
864
865        .empty-state {
866            text-align: center;
867            padding: 40px;
868            color: #999;
869        }
870
871        .empty-state p {
872            margin-top: 10px;
873        }
874    </style>
875</head>
876<body>
877    <div class="container">
878        <header>
879            <h1>📚 Gettext Translation Manager</h1>
880            <div class="controls">
881                <select id="fileSelector" onchange="loadFile(this.value)">
882                    <option value="">Select a file...</option>
883                </select>
884                <input type="text" id="searchInput" placeholder="Search translations..." onkeyup="filterTranslations()">
885                <button onclick="openNewTranslationModal()">+ New Translation</button>
886            </div>
887        </header>
888
889        <div class="main-content">
890            <aside class="sidebar">
891                <h2>Files</h2>
892                <ul class="file-list" id="fileList"></ul>
893
894                <h2>Languages</h2>
895                <ul class="language-list" id="languageList"></ul>
896                <button class="secondary" onclick="openAddLanguageModal()">+ Add Language</button>
897
898                <h2>Stats</h2>
899                <div id="sidebarStats"></div>
900            </aside>
901
902            <main class="content-area">
903                <div class="stats" id="stats"></div>
904                <table class="translations-table" id="translationsTable">
905                    <thead>
906                        <tr>
907                            <th>Message ID</th>
908                            <th>Translation</th>
909                            <th>Status</th>
910                            <th>Actions</th>
911                        </tr>
912                    </thead>
913                    <tbody id="translationsBody">
914                    </tbody>
915                </table>
916                <div class="empty-state" id="emptyState" style="display: none;">
917                    <p>📭 No translations found</p>
918                    <p>Start by selecting a file or creating a new translation</p>
919                </div>
920            </main>
921        </div>
922    </div>
923
924    <!-- Edit Translation Modal -->
925    <div class="modal" id="editModal">
926        <div class="modal-content">
927            <h2>Edit Translation</h2>
928            <form onsubmit="saveTranslation(event)">
929                <div class="form-group">
930                    <label>Message ID</label>
931                    <input type="text" id="editMsgid" readonly>
932                </div>
933                <div class="form-group">
934                    <label>Translation</label>
935                    <textarea id="editMsgstr" required></textarea>
936                </div>
937                <div class="form-group">
938                    <label>Plural Form (if applicable)</label>
939                    <textarea id="editMsgidPlural"></textarea>
940                </div>
941                <div class="form-actions">
942                    <button type="button" onclick="closeModal('editModal')" class="secondary">Cancel</button>
943                    <button type="submit">Save</button>
944                </div>
945            </form>
946        </div>
947    </div>
948
949    <!-- New Translation Modal -->
950    <div class="modal" id="newTranslationModal">
951        <div class="modal-content">
952            <h2>New Translation</h2>
953            <form onsubmit="createTranslation(event)">
954                <div class="form-group">
955                    <label>Message ID</label>
956                    <input type="text" id="newMsgid" required>
957                </div>
958                <div class="form-group">
959                    <label>Translation</label>
960                    <textarea id="newMsgstr" required></textarea>
961                </div>
962                <div class="form-actions">
963                    <button type="button" onclick="closeModal('newTranslationModal')" class="secondary">Cancel</button>
964                    <button type="submit">Create</button>
965                </div>
966            </form>
967        </div>
968    </div>
969
970    <!-- Add Language Modal -->
971    <div class="modal" id="addLanguageModal">
972        <div class="modal-content">
973            <h2>Add Language</h2>
974            <form onsubmit="addLanguage(event)">
975                <div class="form-group">
976                    <label>Language Code</label>
977                    <input type="text" id="languageCode" placeholder="e.g., fr, es, de" required>
978                </div>
979                <div class="form-actions">
980                    <button type="button" onclick="closeModal('addLanguageModal')" class="secondary">Cancel</button>
981                    <button type="submit">Add</button>
982                </div>
983            </form>
984        </div>
985    </div>
986
987    <script>
988        let currentFile = null;
989        let allTranslations = [];
990
991        // Initialize
992        document.addEventListener('DOMContentLoaded', () => {
993            loadFiles();
994        });
995
996        async function loadFiles() {
997            try {
998                const response = await fetch('/api/files');
999                const files = await response.json();
1000                const fileList = document.getElementById('fileList');
1001                fileList.innerHTML = '';
1002
1003                // Group files by language
1004                const groups = {};
1005                files.forEach(file => {
1006                    const group = file.language || 'Templates';
1007                    if (!groups[group]) groups[group] = [];
1008                    groups[group].push(file);
1009                });
1010
1011                // Sort group keys: Templates first, then languages alphabetically
1012                const sortedKeys = Object.keys(groups).sort((a, b) => {
1013                    if (a === 'Templates') return -1;
1014                    if (b === 'Templates') return 1;
1015                    return a.localeCompare(b);
1016                });
1017
1018                // Also populate the file selector dropdown
1019                const selector = document.getElementById('fileSelector');
1020                selector.innerHTML = '<option value="">Select a file...</option>';
1021
1022                sortedKeys.forEach(group => {
1023                    const div = document.createElement('div');
1024                    div.className = 'file-group';
1025
1026                    const header = document.createElement('div');
1027                    header.className = 'file-group-header';
1028                    header.innerHTML = '<span>' + group + ' (' + groups[group].length + ')</span><span class="arrow">&#9660;</span>';
1029                    header.onclick = () => {
1030                        header.classList.toggle('collapsed');
1031                        items.classList.toggle('hidden');
1032                    };
1033
1034                    const items = document.createElement('ul');
1035                    items.className = 'file-group-items';
1036
1037                    // Add optgroup to selector
1038                    const optgroup = document.createElement('optgroup');
1039                    optgroup.label = group;
1040
1041                    groups[group].forEach(file => {
1042                        const li = document.createElement('li');
1043                        li.textContent = file.name;
1044                        li.dataset.path = file.path;
1045                        li.onclick = () => loadFile(file.path);
1046                        items.appendChild(li);
1047
1048                        const opt = document.createElement('option');
1049                        opt.value = file.path;
1050                        opt.textContent = file.name;
1051                        optgroup.appendChild(opt);
1052                    });
1053
1054                    div.appendChild(header);
1055                    div.appendChild(items);
1056                    fileList.appendChild(div);
1057                    selector.appendChild(optgroup);
1058                });
1059
1060                if (files.length > 0) {
1061                    loadFile(files[0].path);
1062                }
1063            } catch (err) {
1064                console.error('Error loading files:', err);
1065            }
1066        }
1067
1068        async function loadFile(path) {
1069            if (!path) return;
1070            currentFile = path;
1071            document.getElementById('fileSelector').value = path;
1072
1073            // Update active file in sidebar
1074            document.querySelectorAll('.file-group-items li').forEach(li => {
1075                li.classList.remove('active');
1076                if (li.dataset.path === path) {
1077                    li.classList.add('active');
1078                }
1079            });
1080
1081            // Load translations and languages
1082            await loadTranslations();
1083            await loadLanguages();
1084            await updateStats();
1085        }
1086
1087        async function loadTranslations() {
1088            try {
1089                const response = await fetch(`/api/translations?file=${encodeURIComponent(currentFile)}`);
1090                allTranslations = await response.json();
1091                renderTranslations(allTranslations);
1092            } catch (err) {
1093                console.error('Error loading translations:', err);
1094            }
1095        }
1096
1097        async function loadLanguages() {
1098            try {
1099                const response = await fetch(`/api/languages?file=${encodeURIComponent(currentFile)}`);
1100                const languages = await response.json();
1101                const languageList = document.getElementById('languageList');
1102                languageList.innerHTML = '';
1103
1104                languages.forEach(lang => {
1105                    const li = document.createElement('li');
1106                    li.appendChild(document.createTextNode(lang + ' '));
1107                    const btn = document.createElement('button');
1108                    btn.className = 'danger';
1109                    btn.type = 'button';
1110                    btn.textContent = 'Remove';
1111                    btn.addEventListener('click', () => removeLanguage(lang));
1112                    li.appendChild(btn);
1113                    languageList.appendChild(li);
1114                });
1115            } catch (err) {
1116                console.error('Error loading languages:', err);
1117            }
1118        }
1119
1120        async function updateStats() {
1121            const total = allTranslations.length;
1122            const translated = allTranslations.filter(t => t.is_translated).length;
1123            const fuzzy = allTranslations.filter(t => t.is_fuzzy).length;
1124            const percentage = total > 0 ? Math.round((translated / total) * 100) : 0;
1125
1126            const statsHtml = `
1127                <div class="stat-card">
1128                    <strong>Total</strong>
1129                    ${total}
1130                </div>
1131                <div class="stat-card">
1132                    <strong>Translated</strong>
1133                    ${translated}
1134                </div>
1135                <div class="stat-card">
1136                    <strong>Fuzzy</strong>
1137                    ${fuzzy}
1138                </div>
1139                <div class="stat-card">
1140                    <strong>Progress</strong>
1141                    <div class="progress-bar">
1142                        <div class="progress-fill" style="width: ${percentage}%">${percentage}%</div>
1143                    </div>
1144                </div>
1145            `;
1146            document.getElementById('stats').innerHTML = statsHtml;
1147            document.getElementById('sidebarStats').innerHTML = `
1148                <strong>Total:</strong> ${total}<br>
1149                <strong>Translated:</strong> ${translated}<br>
1150                <strong>Coverage:</strong> ${percentage}%
1151            `;
1152        }
1153
1154        function renderTranslations(translations) {
1155            const tbody = document.getElementById('translationsBody');
1156            const emptyState = document.getElementById('emptyState');
1157
1158            if (translations.length === 0) {
1159                tbody.innerHTML = '';
1160                emptyState.style.display = 'block';
1161                return;
1162            }
1163
1164            emptyState.style.display = 'none';
1165            tbody.innerHTML = '';
1166            translations.forEach((t, idx) => {
1167                const tr = document.createElement('tr');
1168
1169                const tdId = document.createElement('td');
1170                const code = document.createElement('code');
1171                code.textContent = t.msgid;
1172                tdId.appendChild(code);
1173
1174                const tdStr = document.createElement('td');
1175                tdStr.textContent = t.msgstr || '(untranslated)';
1176
1177                const tdStatus = document.createElement('td');
1178                if (t.is_translated) {
1179                    const span = document.createElement('span');
1180                    span.className = 'status-badge status-translated';
1181                    span.textContent = 'Translated';
1182                    tdStatus.appendChild(span);
1183                }
1184                if (t.is_fuzzy) {
1185                    const span = document.createElement('span');
1186                    span.className = 'status-badge status-fuzzy';
1187                    span.textContent = 'Fuzzy';
1188                    tdStatus.appendChild(span);
1189                }
1190                if (!t.is_translated && !t.is_fuzzy) {
1191                    const span = document.createElement('span');
1192                    span.className = 'status-badge status-untranslated';
1193                    span.textContent = 'Untranslated';
1194                    tdStatus.appendChild(span);
1195                }
1196
1197                const tdActions = document.createElement('td');
1198                const div = document.createElement('div');
1199                div.className = 'action-buttons';
1200                const editBtn = document.createElement('button');
1201                editBtn.textContent = 'Edit';
1202                editBtn.addEventListener('click', () => openEditModal(t.msgid, t.msgstr, t.msgctxt));
1203                const deleteBtn = document.createElement('button');
1204                deleteBtn.className = 'danger';
1205                deleteBtn.textContent = 'Delete';
1206                deleteBtn.addEventListener('click', () => deleteTranslation(t.msgid, t.msgctxt));
1207                div.appendChild(editBtn);
1208                div.appendChild(deleteBtn);
1209                tdActions.appendChild(div);
1210
1211                tr.appendChild(tdId);
1212                tr.appendChild(tdStr);
1213                tr.appendChild(tdStatus);
1214                tr.appendChild(tdActions);
1215                tbody.appendChild(tr);
1216            });
1217        }
1218
1219        function filterTranslations() {
1220            const query = document.getElementById('searchInput').value;
1221            const filtered = allTranslations.filter(t =>
1222                (t.msgid || '').toLowerCase().includes(query.toLowerCase()) ||
1223                (t.msgstr || '').toLowerCase().includes(query.toLowerCase())
1224            );
1225            renderTranslations(filtered);
1226        }
1227
1228        let editMsgctxt = null;
1229        function openEditModal(msgid, msgstr, msgctxt) {
1230            document.getElementById('editMsgid').value = msgid;
1231            document.getElementById('editMsgstr').value = msgstr;
1232            editMsgctxt = msgctxt || null;
1233            document.getElementById('editModal').classList.add('active');
1234        }
1235
1236        function openNewTranslationModal() {
1237            if (!currentFile) {
1238                alert('Please select a file first');
1239                return;
1240            }
1241            document.getElementById('newTranslationModal').classList.add('active');
1242        }
1243
1244        function openAddLanguageModal() {
1245            if (!currentFile) {
1246                alert('Please select a file first');
1247                return;
1248            }
1249            document.getElementById('addLanguageModal').classList.add('active');
1250        }
1251
1252        function closeModal(modalId) {
1253            document.getElementById(modalId).classList.remove('active');
1254        }
1255
1256        async function saveTranslation(event) {
1257            event.preventDefault();
1258            const msgid = document.getElementById('editMsgid').value;
1259            const msgstr = document.getElementById('editMsgstr').value;
1260
1261            try {
1262                const response = await fetch(`/api/translations?file=${encodeURIComponent(currentFile)}`, {
1263                    method: 'POST',
1264                    headers: { 'Content-Type': 'application/json' },
1265                    body: JSON.stringify({
1266                        msgid,
1267                        msgstr,
1268                        msgctxt: editMsgctxt
1269                    })
1270                });
1271
1272                if (response.ok) {
1273                    closeModal('editModal');
1274                    await loadTranslations();
1275                    await updateStats();
1276                } else {
1277                    alert('Error saving translation');
1278                }
1279            } catch (err) {
1280                console.error('Error:', err);
1281                alert('Error saving translation');
1282            }
1283        }
1284
1285        async function createTranslation(event) {
1286            event.preventDefault();
1287            const msgid = document.getElementById('newMsgid').value;
1288            const msgstr = document.getElementById('newMsgstr').value;
1289
1290            try {
1291                const response = await fetch(`/api/translations?file=${encodeURIComponent(currentFile)}`, {
1292                    method: 'POST',
1293                    headers: { 'Content-Type': 'application/json' },
1294                    body: JSON.stringify({
1295                        msgid,
1296                        msgstr
1297                    })
1298                });
1299
1300                if (response.ok) {
1301                    closeModal('newTranslationModal');
1302                    document.getElementById('newMsgid').value = '';
1303                    document.getElementById('newMsgstr').value = '';
1304                    await loadTranslations();
1305                    await updateStats();
1306                } else {
1307                    alert('Error creating translation');
1308                }
1309            } catch (err) {
1310                console.error('Error:', err);
1311                alert('Error creating translation');
1312            }
1313        }
1314
1315        async function deleteTranslation(msgid, msgctxt) {
1316            if (!confirm(`Delete translation for "${msgid}"?`)) return;
1317
1318            try {
1319                let url = `/api/translations/detail?file=${encodeURIComponent(currentFile)}&msgid=${encodeURIComponent(msgid)}`;
1320                if (msgctxt) url += `&msgctxt=${encodeURIComponent(msgctxt)}`;
1321                const response = await fetch(url, {
1322                    method: 'DELETE'
1323                });
1324
1325                if (response.ok) {
1326                    await loadTranslations();
1327                    await updateStats();
1328                } else {
1329                    alert('Error deleting translation');
1330                }
1331            } catch (err) {
1332                console.error('Error:', err);
1333                alert('Error deleting translation');
1334            }
1335        }
1336
1337        async function addLanguage(event) {
1338            event.preventDefault();
1339            const language = document.getElementById('languageCode').value;
1340
1341            try {
1342                const response = await fetch(`/api/languages?file=${encodeURIComponent(currentFile)}`, {
1343                    method: 'POST',
1344                    headers: { 'Content-Type': 'application/json' },
1345                    body: JSON.stringify({
1346                        language
1347                    })
1348                });
1349
1350                if (response.ok) {
1351                    closeModal('addLanguageModal');
1352                    document.getElementById('languageCode').value = '';
1353                    await loadLanguages();
1354                } else {
1355                    alert('Error adding language');
1356                }
1357            } catch (err) {
1358                console.error('Error:', err);
1359                alert('Error adding language');
1360            }
1361        }
1362
1363        async function removeLanguage(language) {
1364            if (!confirm(`Remove language "${language}"?`)) return;
1365
1366            try {
1367                const response = await fetch(`/api/languages/${encodeURIComponent(language)}?file=${encodeURIComponent(currentFile)}`, {
1368                    method: 'DELETE'
1369                });
1370
1371                if (response.ok) {
1372                    await loadLanguages();
1373                } else {
1374                    alert('Error removing language');
1375                }
1376            } catch (err) {
1377                console.error('Error:', err);
1378                alert('Error removing language');
1379            }
1380        }
1381
1382    </script>
1383</body>
1384</html>"#;