From a9645f6c47f8bf8f93084d048d9ca0be2a497056 Mon Sep 17 00:00:00 2001 From: Tony Tran Date: Thu, 18 Jun 2026 17:44:27 +0700 Subject: [PATCH] feat: complete and polish Vietnamese locale --- static/i18n.js | 335 +++++++++++++++++++++++++------- tests/test_vietnamese_locale.py | 112 +++++++++++ 2 files changed, 380 insertions(+), 67 deletions(-) create mode 100644 tests/test_vietnamese_locale.py diff --git a/static/i18n.js b/static/i18n.js index d0d1f081..990c3a68 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -19109,7 +19109,7 @@ const LOCALES = { mcp_no_servers: 'Chưa có máy chủ MCP nào được cấu hình.', mcp_add_server: '+ Thêm máy chủ', mcp_field_name: 'Tên máy chủ', - mcp_transport_label: 'Loại transport', + mcp_transport_label: 'Loại kết nối', mcp_field_command: 'Lệnh', mcp_field_args: 'Tham số (phân tách bằng dấu phẩy)', mcp_field_url: 'URL', @@ -19174,7 +19174,7 @@ const LOCALES = { html_too_large: 'HTML quá lớn để xem trực tiếp', html_error: 'Không thể hiển thị xem trước HTML', html_open_full: 'Mở toàn bộ trang', - html_sandbox_label: 'Xem trước HTML (sandbox)', + html_sandbox_label: 'Xem trước HTML (chế độ cách ly)', thinking: 'Đang suy nghĩ', expand_all: 'Mở rộng tất cả', collapse_all: 'Thu gọn tất cả', @@ -19187,7 +19187,7 @@ const LOCALES = { approval_desc_prefix: 'Phát hiện lệnh nguy hiểm', approval_btn_once: 'Cho phép một lần', approval_btn_once_title: 'Cho phép lệnh này một lần (Enter)', - approval_btn_session: 'Cho phép trong phiên', + approval_btn_session: 'Cho phép trong cuộc trò chuyện này', approval_btn_session_title: 'Cho phép trong phiên trò chuyện này', approval_btn_always: 'Luôn cho phép', approval_btn_always_title: 'Luôn cho phép mẫu lệnh này', @@ -19223,10 +19223,10 @@ const LOCALES = { model_unavailable: ' (không khả dụng)', model_unavailable_title: 'Model này không còn trong danh sách provider hiện tại của bạn', provider_mismatch_warning: (m,p)=>`"${m}" có thể không hoạt động với provider đã cấu hình (${p}). Vẫn gửi hoặc chạy \`hermes model\` trong terminal để chuyển đổi.`, - provider_mismatch_label: 'Provider không khớp', + provider_mismatch_label: 'Nhà cung cấp không khớp', gateway_auth_label: 'Xác thực gateway thất bại', model_not_found_label: 'Không tìm thấy model', - model_custom_label: 'ID model tùy chỉnh', + model_custom_label: 'ID model tự nhập', model_custom_placeholder: 'ví dụ: openai/gpt-5.4', model_search_placeholder: 'Tìm model…', model_search_no_results: 'Không tìm thấy model nào', @@ -19246,10 +19246,10 @@ const LOCALES = { cmd_compress: 'Nén thủ công ngữ cảnh cuộc trò chuyện (cách dùng: /compress [chủ đề])', ctx_compress_hint: 'Nén ngữ cảnh để giải phóng dung lượng →', ctx_compress_action: '⚠ Nén ngay để giải phóng ngữ cảnh', - cmd_compact_alias: 'Alias cũ cho /compress', + cmd_compact_alias: 'Bí danh cũ của /compress', cmd_model: 'Chuyển model (ví dụ: /model gpt-4o)', - cmd_workspace: 'Chuyển workspace theo tên', - cmd_terminal: 'Mở terminal của workspace', + cmd_workspace: 'Chuyển không gian làm việc theo tên', + cmd_terminal: 'Mở terminal của không gian làm việc', cmd_new: 'Bắt đầu phiên chat mới', cmd_usage: 'Bật/tắt hiển thị token usage', cmd_theme: 'Đổi giao diện (theme: system/dark/light, skin: default/ares/mono/graphite/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast/zeus/verdigris)', @@ -19307,12 +19307,12 @@ const LOCALES = { goal_continuing: (turns, max_turns, reason) => `↻ Đang tiếp tục mục tiêu (${turns}/${max_turns}): ${reason}`, cmd_interrupt: 'Hủy lượt hiện tại và gửi tin nhắn mới', cmd_steer: 'Chèn điều chỉnh giữa lượt mà không ngắt agent', - cmd_queue_no_msg: 'Cách dùng: /queue ', + cmd_queue_no_msg: 'Cách dùng: /queue ', cmd_queue_not_busy: 'Không có tác vụ hoạt động — cứ gửi bình thường', cmd_queue_confirm: 'Đã đưa tin nhắn vào hàng chờ', - cmd_interrupt_no_msg: 'Cách dùng: /interrupt ', + cmd_interrupt_no_msg: 'Cách dùng: /interrupt ', cmd_interrupt_confirm: 'Đã ngắt — đang gửi tin nhắn mới', - cmd_steer_no_msg: 'Cách dùng: /steer ', + cmd_steer_no_msg: 'Cách dùng: /steer ', cmd_steer_fallback: 'Steer không khả dụng — đã đưa vào hàng chờ lượt tiếp theo', cmd_steer_delivered: 'Steer đã được gửi — agent sẽ thấy ở kết quả tool tiếp theo', steer_leftover_queued: 'Steer đã được đưa vào hàng chờ lượt tiếp theo', @@ -19400,16 +19400,15 @@ const LOCALES = { usage_personality_none:'không có', // Session toolsets (#493) session_toolsets:'Bộ công cụ phiên', - session_toolsets_desc:'Giới hạn công cụ khả dụng cho phiên này (để trống = dùng cấu hình global)', - session_toolsets_global:'Global (mặc định)', + session_toolsets_desc:'Giới hạn công cụ khả dụng cho phiên này (để trống = dùng cấu hình toàn cục)', + session_toolsets_global:'Toàn cục (mặc định)', session_toolsets_custom:'Tùy chỉnh', session_toolsets_placeholder:'tool1, tool2, …', session_toolsets_apply:'Áp dụng', - session_toolsets_clear:'Xóa (dùng global)', + session_toolsets_clear:'Xóa (dùng cấu hình toàn cục)', session_toolsets_applied:'Đã cập nhật bộ công cụ', - session_toolsets_cleared:'Đã xóa bộ công cụ — dùng cấu hình global', + session_toolsets_cleared:'Đã xóa bộ công cụ — dùng cấu hình toàn cục', session_toolsets_failed:'Cập nhật bộ công cụ thất bại: ', - untitled:'Không tiêu đề', no_personalities: 'Không tìm thấy personality nào (thêm vào ~/.hermes/personalities/)', available_personalities: 'Các personality khả dụng:', personality_switch_hint: '\n\nDùng `/personality ` để chuyển hoặc `/personality none` để xóa.', @@ -19418,12 +19417,12 @@ const LOCALES = { personality_set: 'Personality: ', failed_colon: 'Thất bại: ', // ui.js - no_workspace: 'Không có workspace', - terminal_open_title: 'Mở terminal workspace', + no_workspace: 'Không có không gian làm việc', + terminal_open_title: 'Mở terminal của không gian làm việc', terminal_no_workspace_title: 'Chọn workspace để mở terminal', terminal_title: 'Terminal', terminal_clear: 'Xóa', - terminal_copy_output: 'Sao chép output', + terminal_copy_output: 'Sao chép đầu ra', terminal_restart: 'Khởi động lại', terminal_collapse: 'Thu gọn', terminal_expand: 'Mở rộng', @@ -19435,12 +19434,12 @@ const LOCALES = { terminal_error: 'Lỗi terminal', terminal_remote_backend_unsupported: 'Terminal nhúng chỉ hỗ trợ backend terminal local.', workspace_empty_no_path: 'Chưa chọn workspace. Đặt workspace trong Settings → Workspace để duyệt file.', - workspace_empty_dir: 'Workspace này trống.', + workspace_empty_dir: 'Không gian làm việc này đang trống.', workspace_show_hidden_files: 'Hiện file ẩn', workspace_show_hidden_files_desc: 'Bao gồm .DS_Store, .git, node_modules và các file hệ thống / ẩn khác trong cây thư mục.', workspace_hidden_files_visible: 'đang hiện file ẩn', workspace_hidden_files_visible_title: 'File ẩn đang hiển thị — bấm để xem tùy chọn', - workspace_options: 'Tùy chọn workspace', + workspace_options: 'Tùy chọn không gian làm việc', // workspace.js unsaved_confirm: 'Bạn có thay đổi chưa lưu trong phần xem trước. Bỏ thay đổi và chuyển trang?', save: 'Lưu', @@ -19465,7 +19464,7 @@ const LOCALES = { rename_prompt: 'Tên mới:', deleted: 'Đã xóa ', delete_failed: 'Xóa thất bại: ', - reveal_in_finder: 'Hiện trong File Manager', + reveal_in_finder: 'Hiện trong trình quản lý tệp', reveal_failed: 'Hiển thị thất bại: ', copy_file_path: 'Sao chép đường dẫn file', open_in_vscode: 'Mở bằng VS Code', @@ -19488,7 +19487,7 @@ const LOCALES = { new_file: 'File mới', new_folder: 'Thư mục mới', workspace_root: 'thư mục gốc workspace', - new_file_prompt_title: 'Tên file mới trong {0}:', + new_file_prompt_title: 'Tên tệp mới trong {0}:', new_folder_prompt_title: 'Tên thư mục mới trong {0}:', project_name_prompt: 'Tên project:', created: 'Đã tạo ', @@ -19513,7 +19512,7 @@ const LOCALES = { session_pin_desc: 'Giữ cuộc trò chuyện này ở đầu danh sách', session_unpin_desc: 'Bỏ khỏi danh sách ghim', session_pin_failed: 'Ghim thất bại: ', - session_move_project: 'Chuyển sang project', + session_move_project: 'Chuyển sang dự án', session_move_project_desc_has: 'Đổi project cho cuộc trò chuyện này', session_move_project_desc_none: 'Gán project cho cuộc trò chuyện này', session_archive: 'Lưu trữ cuộc trò chuyện', @@ -19553,8 +19552,8 @@ const LOCALES = { settings_desc_chat_activity_display_mode: 'Compact Worklog gom nhóm hoạt động thành bản tóm tắt gọn. Transparent Stream giữ mỗi sự kiện thinking/tool thành một dòng theo thứ tự thời gian có thể mở rộng.', first_token_time: 'Thời gian tới token đầu tiên', done: 'Hoàn tất', - settings_option_compact_worklog: 'Compact Worklog', - settings_option_transparent_stream: 'Transparent Stream', + settings_option_compact_worklog: 'Nhật ký công việc rút gọn', + settings_option_transparent_stream: 'Luồng trong suốt', settings_label_tab_visibility: 'Tab sidebar', settings_desc_tab_visibility: 'Chọn tab nào xuất hiện trong sidebar và thanh rail. Kéo các chip để đổi thứ tự. Chat và Settings luôn hiển thị.', @@ -19589,7 +19588,7 @@ const LOCALES = { settings_help_docs_link: 'Mở tài liệu', settings_help_issue_label: 'Có vấn đề?', settings_help_issue_desc: 'Tìm báo cáo hiện có hoặc mở issue mới trên GitHub.', - settings_help_issue_link: 'Mở GitHub Issues', + settings_help_issue_link: 'Mở trang GitHub Issues', settings_title: 'Cài đặt', settings_save_btn: 'Lưu cài đặt', settings_label_model: 'Model mặc định', @@ -19623,13 +19622,13 @@ const LOCALES = { settings_autosave_failed: 'Lưu thất bại', settings_autosave_retry: 'Thử lại', settings_label_language: 'Ngôn ngữ', - settings_label_quota_chip: 'Hiện quota provider trong composer', + settings_label_quota_chip: 'Hiện hạn mức nhà cung cấp trong khung soạn thảo', settings_desc_quota_chip: 'Hiển thị chỉ báo quota còn lại (ví dụ: số dư credit OpenRouter) ở footer của composer. Mặc định tắt. Chỉ hiển thị trên màn hình rộng (≥1400px) khi bật để giữ composer gọn gàng trên laptop và desktop thông thường.', settings_label_conversation_outline: 'Hiện mục lục cuộc trò chuyện', settings_desc_conversation_outline: 'Hiển thị danh sách nhảy nhanh cho các câu hỏi của người dùng trong cuộc trò chuyện hiện tại trên desktop. Mặc định tắt.', settings_label_hide_suggestions: 'Ẩn gợi ý chat mới', settings_desc_hide_suggestions: 'Ẩn ba nút gợi ý mặc định trên màn hình chat mới để tránh bấm nhầm.', - settings_label_token_usage: 'Hiện token usage', + settings_label_token_usage: 'Hiện mức sử dụng token', settings_label_token_speed: 'Hiện tốc độ token (TPS)', settings_desc_token_speed: 'Hiển thị token mỗi giây trong header tin nhắn assistant khi đang stream và sau khi hoàn tất phản hồi. Mặc định tắt.', settings_label_sidebar_density: 'Mật độ sidebar', @@ -19645,18 +19644,18 @@ const LOCALES = { settings_label_password: 'Mật khẩu truy cập', settings_label_pinned_limit: 'Giới hạn ghim cuộc trò chuyện', settings_desc_pinned_limit: 'Số lượng tối đa cuộc trò chuyện hoạt động có thể được ghim trong sidebar. Mặc định là 3.', - settings_label_dashboard_mode: 'Hermes Dashboard chính thức', + settings_label_dashboard_mode: 'Bảng điều khiển Hermes chính thức', settings_desc_dashboard_mode: 'Hiển thị liên kết nav-rail khi dashboard Hermes chính thức khả dụng. URL reverse-proxy công khai chỉ được lưu trong trình duyệt và không bao giờ bị server probe.', settings_dashboard_mode_auto: 'Tự động phát hiện', settings_dashboard_mode_always: 'Luôn hiển thị', settings_dashboard_mode_never: 'Không bao giờ hiển thị', settings_dashboard_save: 'Lưu cài đặt liên kết dashboard', - settings_label_gateway_status: 'Trạng thái Gateway', + settings_label_gateway_status: 'Trạng thái gateway', settings_desc_gateway_status: 'Trạng thái gateway Hermes (Telegram, Discord, Slack, v.v.)', gateway_not_configured: 'Gateway chưa được cấu hình', gateway_metadata_stale: 'Metadata gateway đã cũ', gateway_endpoint_unreachable: 'Không thể truy cập endpoint gateway', - gateway_not_running: 'Gateway chưa chạy', + gateway_not_running: 'Gateway hiện chưa chạy', gateway_running_label: 'Đang chạy', gateway_last_active: 'Hoạt động lần cuối: ', gateway_session_count: (n) => `${n} ${n === 1 ? 'phiên' : 'phiên'}`, @@ -19689,7 +19688,7 @@ const LOCALES = { kanban_search_tasks: 'Tìm kiếm tác vụ', kanban_all_assignees: 'Tất cả assignee', kanban_all_tenants: 'Tất cả tenant', - kanban_include_archived: 'Bao gồm archived', + kanban_include_archived: 'Bao gồm mục đã lưu trữ', kanban_no_matching_tasks: 'Không có tác vụ phù hợp', kanban_no_data: 'Không có dữ liệu Kanban', kanban_work_queue_hint: 'Đây là hàng đợi công việc của Hermes Agent. Tạo hoặc phân loại tác vụ, gán người xử lý, chuyển sang Ready rồi để dispatcher nhận.', @@ -19728,9 +19727,9 @@ const LOCALES = { kanban_new_task: 'Tác vụ mới', kanban_edit_task: 'Chỉnh sửa tác vụ', kanban_status_original_hint: 'Trạng thái thực tế: {0}. Hộp thoại này chỉ hỗ trợ chỉnh sửa Triage/Todo/Ready.', - kanban_run_dispatcher: 'Chạy dispatcher', + kanban_run_dispatcher: 'Chạy bộ điều phối', kanban_run_dispatcher_confirm: 'Thao tác này sẽ nhận các tác vụ Ready trên bảng này và tạo subprocess worker (mỗi tác vụ một worker, tối đa 8 mỗi lần bấm). Tiếp tục?', - kanban_assignee_profiles_label: 'Profile Hermes', + kanban_assignee_profiles_label: 'Hồ sơ Hermes', kanban_assignee_other_label: 'Khác (CLI lanes / profile đã xóa)', kanban_assignee_unassigned: '— Chưa gán (sẽ không tự chạy) —', kanban_ready_needs_assignee: 'Bạn đã chọn Unassigned + Ready. Dispatcher sẽ bỏ qua tác vụ này. Gửi lại để xác nhận hoặc chọn một profile.', @@ -19747,13 +19746,13 @@ const LOCALES = { kanban_add_comment: 'Thêm bình luận', kanban_only_mine: 'Chỉ của tôi', kanban_bulk_action: 'Thao tác hàng loạt', - kanban_nudge_dispatcher: 'Xem trước dispatcher', + kanban_nudge_dispatcher: 'Xem trước bộ điều phối', kanban_stats: 'Thống kê', kanban_worker_log: 'Log worker', kanban_block: 'Chặn', kanban_unblock: 'Bỏ chặn', kanban_back_to_board: 'Quay lại bảng', - kanban_lanes_by_profile: 'Lane theo profile', + kanban_lanes_by_profile: 'Cột theo hồ sơ', kanban_new_board: 'Bảng mới…', kanban_rename_board: 'Đổi tên bảng hiện tại…', kanban_archive_board: 'Lưu trữ bảng hiện tại…', @@ -19762,7 +19761,7 @@ const LOCALES = { kanban_board_name: 'Tên', kanban_board_slug: 'Slug (chữ thường, dấu gạch nối)', kanban_board_description: 'Mô tả (tùy chọn)', - kanban_board_icon: 'Icon (emoji, tùy chọn)', + kanban_board_icon: 'Biểu tượng (emoji, tùy chọn)', kanban_board_color: 'Màu sắc (tùy chọn)', kanban_board_name_required: 'Tên là bắt buộc', kanban_board_slug_required: 'Slug là bắt buộc', @@ -19772,7 +19771,7 @@ const LOCALES = { kanban_status_archived: 'Đã lưu trữ', tab_todos: 'Todos', tab_insights: 'Insights', - tab_dashboard: 'Hermes Dashboard', + tab_dashboard: 'Bảng điều khiển Hermes', dashboard_loopback_warning: 'Dashboard chỉ cho phép loopback trên server. Hãy truy cập trực tiếp từ server hoặc khởi động lại với --host 0.0.0.0 (không an toàn).', tab_logs: 'Logs', tab_settings: 'Cài đặt', @@ -19864,14 +19863,14 @@ const LOCALES = { session_child_toggle_hint: '{0} — các cuộc trò chuyện con được tạo từ phiên này. Bấm để hiện hoặc ẩn chúng.', session_readonly_title_hint: 'Phiên import chỉ đọc — {0}', session_lineage_segment_untitled: 'Segment chưa có tiêu đề', - session_lineage_segment_open: 'Mở lineage segment', + session_lineage_segment_open: 'Mở đoạn lineage', new_profile: 'Profile mới', transcript: 'Transcript', download_transcript: 'Tải dưới dạng Markdown', import: 'Import', export_session_json: 'JSON', export_session_json_tooltip: 'Xuất toàn bộ phiên dưới dạng JSON', - import_session_json_tooltip: 'Import phiên từ JSON', + import_session_json_tooltip: 'Nhập phiên từ JSON', clear_conversation_btn_tooltip: 'Xóa toàn bộ tin nhắn trong cuộc trò chuyện này', // Settings detail settings_label_rtl: 'Bố cục chat từ phải sang trái', @@ -19932,19 +19931,19 @@ const LOCALES = { password_env_var_locked_placeholder: 'Đã khóa: biến môi trường HERMES_WEBUI_PASSWORD đang được đặt', disable_auth: 'Tắt xác thực', sign_out: 'Đăng xuất', - settings_label_shutdown: 'Dừng server Hermes WebUI', + settings_label_shutdown: 'Dừng máy chủ Hermes WebUI', settings_desc_shutdown_before_cmd: 'Dừng an toàn server WebUI local. Hữu ích nếu bạn khởi chạy bằng ', settings_desc_shutdown_between_cmds: ' và muốn dừng mà không cần chuyển sang terminal. Bạn sẽ cần khởi chạy lại server (', settings_desc_shutdown_after_cmd: ' hoặc ứng dụng native) trước khi WebUI có thể truy cập lại.', settings_btn_shutdown: 'Dừng server', settings_shutdown_confirm_title: 'Dừng Hermes WebUI', - settings_shutdown_confirm_message: 'Dừng server Hermes WebUI?', + settings_shutdown_confirm_message: 'Dừng máy chủ Hermes WebUI?', settings_shutdown_confirm_btn: 'Dừng', settings_shutdown_stopped_message: 'Server đã dừng. Bạn có thể đóng tab này.', // Providers panel providers_tab_title: 'Provider', - providers_section_title: 'Provider', - providers_section_meta: 'Quản lý API key cho các AI provider. Thay đổi có hiệu lực ngay lập tức.', + providers_section_title: 'Nhà cung cấp', + providers_section_meta: 'Quản lý API key cho các nhà cung cấp AI. Thay đổi có hiệu lực ngay lập tức.', providers_status_configured: 'Đã cấu hình API key', providers_status_not_configured: 'Chưa có API key', providers_status_oauth: 'OAuth', @@ -19960,8 +19959,8 @@ const LOCALES = { providers_remove: 'Xóa', providers_saving: 'Đang lưu…', providers_removing: 'Đang xóa…', - providers_enter_key: 'Vui lòng nhập API key', - providers_empty: 'Không tìm thấy provider có thể cấu hình.', + providers_enter_key: 'Vui lòng nhập khóa API', + providers_empty: 'Không tìm thấy nhà cung cấp nào có thể cấu hình.', providers_key_updated: 'Đã lưu API key', providers_key_removed: 'Đã xóa API key', providers_key_placeholder_new: 'sk-...', @@ -19991,7 +19990,7 @@ const LOCALES = { provider_quota_status_unsupported: 'không hỗ trợ', provider_quota_used_meta: '{0} đã dùng', provider_quota_resets_meta: 'reset lúc {0}', - provider_quota_credential_pool: 'Pool credential', + provider_quota_credential_pool: 'Nhóm thông tin xác thực', provider_quota_credential_label: 'Credential {0}', provider_quota_pool_plans: 'Gói: {0}', provider_quota_pool_no_windows: 'Không có cửa sổ giới hạn tài khoản nào được báo cáo.', @@ -20023,9 +20022,9 @@ const LOCALES = { onboarding_open: 'Mở Hermes', onboarding_step_system_title: 'Kiểm tra hệ thống', onboarding_step_system_desc: 'Xác minh Hermes Agent và khả năng truy cập config.', - onboarding_step_setup_title: 'Thiết lập provider', + onboarding_step_setup_title: 'Thiết lập nhà cung cấp', onboarding_step_setup_desc: 'Lưu cấu hình provider tối thiểu cho Hermes.', - onboarding_step_workspace_title: 'Workspace + model', + onboarding_step_workspace_title: 'Không gian làm việc + model', onboarding_step_workspace_desc: 'Chọn mặc định cho phiên mới và chat.', onboarding_step_password_title: 'Mật khẩu tùy chọn', onboarding_step_password_desc: 'Bảo vệ Web UI trước khi chia sẻ.', @@ -20039,7 +20038,7 @@ const LOCALES = { onboarding_check_password: 'Mật khẩu', onboarding_check_password_enabled: 'Đã bật', onboarding_check_password_disabled: 'Chưa bật', - onboarding_check_provider: 'Cấu hình provider', + onboarding_check_provider: 'Cấu hình nhà cung cấp', onboarding_check_provider_ready: 'Sẵn sàng chat', onboarding_check_provider_partial: 'Đã lưu nhưng chưa hoàn chỉnh', onboarding_check_provider_pending: 'Cần xác minh', @@ -20047,7 +20046,7 @@ const LOCALES = { onboarding_env_file: '.env file:', onboarding_unknown: 'Không xác định', onboarding_current_provider: 'Thiết lập hiện tại:', - onboarding_missing_imports: 'Import còn thiếu:', + onboarding_missing_imports: 'Thiếu các import sau:', onboarding_notice_setup_required: 'Hãy chọn cách thiết lập provider đơn giản tại đây. Các luồng OAuth nâng cao hiện vẫn nên thực hiện trong Hermes CLI.', onboarding_notice_setup_already_ready: 'Đã phát hiện cấu hình provider Hermes đang hoạt động. Bạn có thể giữ nguyên hoặc thay thế tại đây.', onboarding_oauth_provider_ready_title: 'Provider đã xác thực', @@ -20116,7 +20115,7 @@ const LOCALES = { onboarding_error_probe_failed: 'Không thể xác minh Base URL đã cấu hình.', onboarding_error_workspace_required: 'Workspace là bắt buộc.', onboarding_error_model_required: 'Model là bắt buộc.', - onboarding_complete: 'Hoàn tất onboarding', + onboarding_complete: 'Hoàn tất thiết lập ban đầu', // panel/runtime i18n error_prefix: 'Lỗi: ', @@ -20165,12 +20164,12 @@ const LOCALES = { cron_mode_label: 'Chế độ', cron_script_job_banner: 'Chạy script theo lịch — stdout được gửi tới đích. Không có agent, prompt hoặc skill.', cron_script_card_title: 'Script', - cron_script_output: 'Output script', + cron_script_output: 'Đầu ra script', cron_script_path_label: 'Đường dẫn script', cron_script_path_hint: 'Được resolve dưới ~/.hermes/scripts/ trừ khi là đường dẫn tuyệt đối. Hãy chỉnh sửa file script trên server để thay đổi hành vi.', - cron_script_badge_title: 'Script job (không có agent)', + cron_script_badge_title: 'Job script (không dùng agent)', cron_workdir_label: 'Thư mục làm việc', - cron_view_full_output: 'Xem toàn bộ output', + cron_view_full_output: 'Xem toàn bộ đầu ra', cron_all_runs: 'Tất cả lần chạy', cron_hide_runs: 'Ẩn lần chạy', cron_no_runs_yet: '(chưa có lần chạy nào)', @@ -20218,7 +20217,7 @@ const LOCALES = { tasks_empty_sub: 'Chọn một job từ sidebar để xem chi tiết và các lần chạy hoặc tạo job mới.', workspaces_empty_title: 'Chọn một space', workspaces_empty_sub: 'Chọn một space từ sidebar để xem file và cài đặt hoặc thêm space mới.', - profiles_empty_title: 'Chọn một profile', + profiles_empty_title: 'Chọn một hồ sơ', profiles_empty_sub: 'Chọn một profile agent từ sidebar để xem và chỉnh sửa cài đặt hoặc tạo profile mới.', memory_notes_label: 'bộ nhớ (ghi chú)', memory_saved: 'Đã lưu bộ nhớ', @@ -20243,8 +20242,8 @@ const LOCALES = { no_soul_yet: 'Chưa định nghĩa soul.', workspace_choose_path: 'Chọn đường dẫn workspace', workspace_choose_path_meta: 'Thêm đường dẫn hợp lệ và chuyển cuộc trò chuyện này', - workspace_manage: 'Quản lý workspace', - workspace_manage_meta: 'Mở panel Spaces', + workspace_manage: 'Quản lý không gian làm việc', + workspace_manage_meta: 'Mở bảng Spaces', workspace_use_title: 'Dùng trong phiên hiện tại', workspace_use: 'Sử dụng', workspace_add_path_placeholder: 'Thêm đường dẫn workspace (ví dụ: /home/user/my-project)', @@ -20257,7 +20256,7 @@ const LOCALES = { workspace_remove_confirm_title: 'Xóa workspace', workspace_remove_confirm_message: (path) => `Xóa "${path}"?`, workspace_removed: 'Đã xóa workspace', - workspace_switch_prompt_title: 'Chuyển workspace', + workspace_switch_prompt_title: 'Chuyển không gian làm việc', workspace_switch_prompt_message: 'Nhập đường dẫn workspace tuyệt đối để thêm và chuyển cuộc trò chuyện này sang đó.', workspace_switch_prompt_confirm: 'Chuyển', workspace_switch_prompt_placeholder: '/Users/you/project', @@ -20269,18 +20268,18 @@ const LOCALES = { workspace_switched_to: (name) => `Đã chuyển sang ${name}`, profiles_no_profiles: 'Không tìm thấy profile nào.', profile_api_keys_configured: 'Đã cấu hình API key', - profile_gateway_running: 'Gateway đang chạy', + profile_gateway_running: 'Gateway đang hoạt động', profile_gateway_stopped: 'Gateway đã dừng', profile_active: 'ĐANG HOẠT ĐỘNG', profile_no_configuration: 'Không có cấu hình', profile_skill_count: (count) => `${count} kỹ năng`, profile_use: 'Sử dụng', - profile_switch_title: 'Chuyển sang profile này', + profile_switch_title: 'Chuyển sang hồ sơ này', profile_delete_title: 'Xóa profile này', profile_default_label: '(mặc định)', profile_name_placeholder: 'Tên profile (chữ thường, a-z 0-9 dấu gạch ngang)', profile_clone_label: 'Nhân bản cấu hình từ profile đang hoạt động', - profile_model_label: 'Model / provider', + profile_model_label: 'Model / nhà cung cấp', profile_model_hint: 'Chọn từ các provider và model đã cấu hình cho profile mới này.', profile_model_use_default: 'Dùng mặc định của profile đang hoạt động', profile_base_url_placeholder: 'Base URL (tùy chọn, ví dụ: http://localhost:11434)', @@ -20292,7 +20291,7 @@ const LOCALES = { profile_name_rule: 'Chỉ chữ thường, số, dấu gạch ngang và gạch dưới', profile_base_url_rule: 'Base URL phải bắt đầu bằng http:// hoặc https://', profile_created: (name) => `Đã tạo profile: ${name}`, - profile_delete_confirm_title: (name) => `Xóa profile "${name}"?`, + profile_delete_confirm_title: (name) => `Xóa hồ sơ "${name}"?`, profile_delete_confirm_message: 'Toàn bộ phiên, cấu hình, kỹ năng và bộ nhớ của profile này sẽ bị xóa vĩnh viễn. Hành động này không thể hoàn tác.', profile_deleted: (name) => `Đã xóa profile: ${name}`, active_conversation_none: 'Chưa chọn cuộc trò chuyện hoạt động.', @@ -20309,8 +20308,8 @@ const LOCALES = { skill_name: 'Tên', skill_category: 'Danh mục', skill_category_placeholder: 'Tùy chọn, ví dụ: devops', - skill_content: 'Nội dung SKILL.md', - skill_content_placeholder: 'YAML frontmatter + nội dung markdown', + skill_content: 'Nội dung tệp SKILL.md', + skill_content_placeholder: 'YAML frontmatter + nội dung Markdown', skill_rename_not_supported: 'Không hỗ trợ đổi tên kỹ năng. Hãy tạo kỹ năng mới và xóa kỹ năng cũ để đổi tên.', skill_metadata: 'Metadata', // cron form @@ -20321,7 +20320,7 @@ const LOCALES = { cron_schedule_once_warning: "Các dạng thời lượng như '30m' chỉ chạy một lần và sẽ bị xóa sau khi chạy. Dùng 'every 30m' để giữ job lặp lại.", cron_prompt_label: 'Prompt', cron_deliver_label: 'Gửi output tới', - cron_deliver_local: 'Local (chỉ lưu output)', + cron_deliver_local: 'Cục bộ (chỉ lưu đầu ra)', cron_deliver_custom: 'Đích gửi tùy chỉnh', cron_profile_label: 'Profile', cron_profile_server_default: 'mặc định của server', @@ -20344,6 +20343,208 @@ const LOCALES = { profile_name_label: 'Tên', profile_base_url_label: 'Base URL', profile_api_key_label: 'API key', + + approval_gateway_unsupported: 'Phê duyệt cần gateway mới hơn. Hãy nâng cấp Hermes gateway đang kết nối để bật tính năng này.', + approval_skip_all: '⚡ Bỏ qua tất cả trong phiên này', + approval_skip_all_title: 'Bỏ qua mọi yêu cầu phê duyệt cho phiên này', + checkpoint_title: 'Checkpoint', + checkpoint_empty: 'Không tìm thấy checkpoint nào cho workspace này.', + checkpoint_loading: 'Đang tải điểm khôi phục…', + checkpoint_error: 'Không thể tải checkpoint', + checkpoint_date: 'Ngày', + checkpoint_message: 'Tin nhắn', + checkpoint_files: 'Tệp', + checkpoint_view_diff: 'Xem diff', + checkpoint_restore: 'Khôi phục', + checkpoint_restore_confirm_title: 'Khôi phục điểm khôi phục?', + checkpoint_restore_confirm_message: (ckpt) => `Khôi phục workspace về checkpoint "${ckpt}"? Thao tác này sẽ ghi đè các tệp bằng bản đã lưu. Các tệp được thêm sau checkpoint này sẽ không bị xóa.`, + checkpoint_restored: 'Đã khôi phục checkpoint', + checkpoint_diff_title: 'Các thay đổi trong checkpoint', + checkpoint_diff_no_changes: 'Không tìm thấy khác biệt nào giữa checkpoint này và workspace hiện tại.', + checkpoint_diff_files_changed: (n) => `${n} tệp đã thay đổi`, + discard: 'Bỏ thay đổi', + clear: 'Xóa', + create: 'Tạo', + remove: 'Xóa bỏ', + cmd_yolo: 'Bật/tắt chế độ YOLO (bỏ qua phê duyệt)', + composer_send: 'Gửi tin nhắn', + composer_queue: 'Xếp hàng tin nhắn', + composer_interrupt: 'Ngắt và gửi', + composer_steer: 'Điều hướng phản hồi hiện tại', + composer_stop: 'Dừng tạo phản hồi', + composer_disabled_clarify: 'Hãy trả lời yêu cầu làm rõ', + composer_disabled_compression: 'Đang chờ nén hoàn tất', + composer_disabled_empty: 'Nhập tin nhắn để gửi', + composer_compression_will_queue: 'Nhập tin nhắn — hệ thống sẽ xếp hàng và gửi sau khi nén xong', + composer_mobile_workspace: 'Workspace', + composer_mobile_model: 'Model', + composer_mobile_reasoning: 'Lập luận', + composer_mobile_context: 'Ngữ cảnh', + context_block_name_default: 'Ngữ cảnh', + context_block_flush_aria: 'Các khối ngữ cảnh sẽ được gửi kèm khi bạn nhấn gửi', + csv_loading: 'Đang tải CSV', + csv_too_large: 'Tệp CSV quá lớn để hiển thị trực tiếp', + csv_no_data: 'Tệp CSV không đủ dữ liệu để hiển thị thành bảng', + csv_error: 'Không thể tải tệp CSV', + csv_header_note: 'Hàng đầu tiên được hiển thị làm tiêu đề bảng', + excalidraw_loading: 'Đang tải sơ đồ', + excalidraw_too_large: 'Tệp Excalidraw quá lớn để hiển thị trực tiếp', + excalidraw_invalid: 'Định dạng tệp Excalidraw không hợp lệ', + excalidraw_error: 'Không thể tải tệp Excalidraw', + excalidraw_label: 'Sơ đồ', + excalidraw_download: 'Tải xuống', + excalidraw_empty: 'Sơ đồ trống', + excalidraw_render_error: 'Không thể render sơ đồ', + excalidraw_simplified: 'Bản xem trước SVG đã được giản lược — không hoàn toàn giống canvas Excalidraw', + gateway_start: 'Bắt đầu', + gateway_stop: 'Dừng', + gateway_restart: 'Khởi động lại', + gateway_start_success: 'Đã khởi động gateway.', + gateway_restart_success: 'Đã khởi động lại gateway.', + gateway_stop_success: 'Đã dừng gateway.', + gateway_start_failed: 'Khởi động gateway thất bại', + gateway_stop_failed: 'Dừng gateway thất bại', + gateway_restart_failed: 'Khởi động lại gateway thất bại', + gateway_running: 'Đang chạy', + gateway_session: 'phiên', + gateway_sessions: 'phiên', + gateway_status_load_failed: 'Không thể tải trạng thái gateway', + insights_model_health_title: 'So sánh sức khỏe model', + insights_model_health_provider: 'Nhà cung cấp', + insights_model_health_replacement: 'Gợi ý thay thế', + insights_model_health_cost_per_m: 'Chi phí / 1M', + media_audio_label: 'Âm thanh', + media_svg_label: 'Sơ đồ', + media_video_label: 'Video', + media_download: 'Tải xuống', + model_show_all_models: 'Hiển thị tất cả {0} model', + outline_title: 'Dàn ý', + outline_empty: 'Chưa có câu hỏi nào.', + outline_loading: 'Đang tải…', + pdf_truncated: 'Đang hiển thị {0} trên {1} trang đầu — hãy tải xuống để xem toàn bộ tài liệu', + session_select_mode: 'Chọn', + session_select_mode_desc: 'Chọn các cuộc trò chuyện để quản lý hàng loạt', + session_select_all: 'Chọn tất cả', + session_deselect_all: 'Bỏ chọn tất cả', + session_selected_count: '{0} mục đã chọn', + session_batch_archive: 'Lưu trữ', + session_batch_delete: 'Xóa', + session_batch_move: 'Chuyển vào dự án', + session_no_selection: 'Chưa chọn cuộc trò chuyện nào', + session_batch_delete_confirm: 'Xóa {0} cuộc trò chuyện?', + session_batch_archive_confirm: 'Lưu trữ {0} cuộc trò chuyện?', + session_batch_delete_worktree_confirm: 'Xóa {0} cuộc trò chuyện? {1} cuộc trò chuyện dùng worktree sẽ giữ nguyên thư mục worktree trên đĩa.', + session_batch_archive_worktree_confirm: 'Lưu trữ {0} cuộc trò chuyện? {1} cuộc trò chuyện dùng worktree sẽ giữ nguyên thư mục worktree trên đĩa.', + session_toolsets_profile_defaults: 'Mặc định của profile đang hoạt động', + session_toolsets_use_profile_defaults: 'Dùng mặc định của profile đang hoạt động', + session_toolsets_configured_servers: 'Máy chủ MCP đã cấu hình', + session_toolsets_loading_servers: 'Đang tải các máy chủ đã cấu hình...', + session_toolsets_no_configured_servers: 'Chưa có máy chủ MCP nào được cấu hình', + session_worktree_remove: 'Gỡ worktree', + session_worktree_remove_desc: (path) => `Xóa git worktree tại ${path} khỏi đĩa`, + session_worktree_remove_not_exists: (path) => `Worktree tại ${path} không còn tồn tại trên đĩa.`, + session_worktree_remove_confirm: (path) => `Gỡ git worktree khỏi đĩa?\n\nĐường dẫn: ${path}\n\nThao tác này sẽ xóa toàn bộ thư mục worktree. Dữ liệu phiên vẫn được giữ trong WebUI.`, + session_worktree_remove_confirm_label: 'Gỡ bỏ', + session_worktree_removed: 'Đã gỡ worktree.', + session_worktree_remove_failed: 'Gỡ worktree thất bại: ', + session_worktree_remove_status_failed: 'Không thể đọc trạng thái worktree: ', + session_worktree_remove_locked_by_stream: 'Không thể gỡ — một phiên streaming đang sử dụng worktree này.', + session_worktree_remove_locked_by_terminal: 'Không thể gỡ — một terminal đang hoạt động đang sử dụng worktree này.', + session_worktree_remove_unsafe_blocked: 'Hãy xử lý các thay đổi cục bộ hoặc commit chưa push trước khi gỡ worktree này.', + session_worktree_remove_dirty_warning: 'CẢNH BÁO: Worktree này có thay đổi chưa commit và sẽ bị mất.', + session_worktree_remove_untracked_warning: (count) => `${count} tệp chưa theo dõi sẽ bị xóa vĩnh viễn.`, + session_worktree_remove_ahead_warning: (ahead) => `${ahead} commit chưa push sẽ bị mất.`, + settings_heading_title: 'Trung tâm điều khiển', + settings_heading_subtitle: 'Tùy chọn, công cụ hội thoại và điều khiển hệ thống.', + settings_section_conversation_title: 'Hội thoại', + settings_section_appearance_title: 'Giao diện', + settings_section_appearance_meta: 'Theme, màu nhấn và phong cách hiển thị.', + settings_section_preferences_title: 'Tùy chọn', + settings_section_preferences_meta: 'Thiết lập mặc định và hành vi giao diện của Hermes Web UI.', + settings_section_system_title: 'Hệ thống', + settings_section_system_meta: 'Phiên bản instance và các điều khiển truy cập.', + settings_search_placeholder: 'Tìm trong cài đặt…', + settings_search_no_results: 'Không tìm thấy cài đặt nào.', + settings_check_now: 'Kiểm tra ngay', + settings_checking: 'Đang kiểm tra…', + settings_up_to_date: 'Đã cập nhật ✓', + settings_updates_available: '{count} bản cập nhật khả dụng', + settings_updates_disabled: 'Đã tắt kiểm tra cập nhật', + settings_update_check_failed: 'Kiểm tra cập nhật thất bại', + settings_update_no_git: 'Không thể kiểm tra cập nhật (không phải bản checkout git)', + settings_label_workspace_panel_open: 'Giữ panel workspace mở theo mặc định', + settings_desc_workspace_panel_open: 'Khi bật, panel workspace / trình duyệt tệp sẽ tự mở với mỗi phiên mới. Bạn vẫn có thể đóng thủ công bất cứ lúc nào.', + settings_label_workspace_todos_tab: 'Hiển thị tab Todos trong bảng workspace', + settings_desc_workspace_todos_tab: 'Khi bật, một tab Todos sẽ xuất hiện trong panel workspace. Panel Todos ở sidebar vẫn có sẵn.', + settings_label_session_jump_buttons: 'Hiển thị nút nhảy trong phiên', + settings_desc_session_jump_buttons: 'Hiển thị các nút Đầu và Cuối nổi khi đọc lịch sử phiên dài.', + settings_label_terminal_auto_expand: 'Tự mở rộng terminal khi có output', + settings_desc_terminal_auto_expand: 'Tự động mở rộng panel terminal đang thu gọn khi một lệnh đang chạy phát sinh output mới.', + settings_label_virtualize_transcript: 'Ảo hóa transcript dài (thử nghiệm)', + settings_desc_virtualize_transcript: 'Thử nghiệm. Chỉ render phần đang hiển thị của transcript rất dài (trên 80 tin nhắn) để tăng tốc cuộn. Mặc định tắt vì có thể gây rung cuộn ở các phiên dài có nhiều khối tool-call lớn. Bật để thử; để tắt nếu bạn muốn render toàn bộ transcript một lần (và để Find/Ctrl+F của trình duyệt tìm được toàn bộ cuộc trò chuyện).', + settings_main_advanced_button_aria: 'Tùy chọn nâng cao cho model chính', + settings_main_advanced_title: 'Tùy chọn model chính', + settings_main_advanced_subtitle: 'Cấu hình nâng cao cho model chat mặc định.', + settings_main_advanced_saved: 'Đã lưu tùy chọn model chính', + settings_main_advanced_save_failed: 'Lưu tùy chọn model chính thất bại', + settings_aux_task_vision: 'Vision', + settings_aux_task_vision_desc: 'phân tích ảnh / ảnh chụp màn hình', + settings_aux_task_compression: 'Compression', + settings_aux_task_compression_desc: 'tóm tắt ngữ cảnh', + settings_aux_task_web_extract: 'Web extract', + settings_aux_task_web_extract_desc: 'trích xuất và tóm tắt trang web', + settings_aux_task_session_search: 'Tìm phiên', + settings_aux_task_session_search_desc: 'truy hồi hội thoại cũ', + settings_aux_task_approval: 'Approval', + settings_aux_task_approval_desc: 'phê duyệt lệnh thông minh', + settings_aux_task_mcp: 'MCP', + settings_aux_task_mcp_desc: 'suy luận công cụ MCP', + settings_aux_task_title_generation: 'Sinh tiêu đề', + settings_aux_task_title_generation_desc: 'tiêu đề phiên', + settings_aux_task_skills_hub: 'Kho kỹ năng', + settings_aux_task_skills_hub_desc: 'tìm / cài kỹ năng', + settings_aux_task_curator: 'Curator', + settings_aux_task_curator_desc: 'rà soát việc dùng kỹ năng', + settings_aux_task_kanban_decomposer: 'Bộ phân rã Kanban', + settings_aux_task_kanban_decomposer_desc: 'phân rã công việc', + settings_aux_task_profile_describer: 'Bộ mô tả profile', + settings_aux_task_profile_describer_desc: 'tóm tắt profile', + settings_aux_task_triage_specifier: 'Bộ đặc tả phân loại', + settings_aux_task_triage_specifier_desc: 'đặc tả phân loại issue / task', + settings_aux_advanced_button_title: 'Tùy chọn nâng cao', + settings_aux_advanced_button_aria: 'Tùy chọn nâng cao cho {task}', + settings_aux_advanced_title: 'Tùy chọn cho {task}', + settings_aux_advanced_subtitle: 'Cấu hình nâng cao cho auxiliary.', + settings_aux_advanced_save: 'Lưu tùy chọn', + settings_aux_advanced_base_url: 'Base URL', + settings_aux_advanced_base_url_desc: 'Ghi đè endpoint nhà cung cấp (tùy chọn).', + settings_aux_advanced_timeout: 'Thời gian chờ (giây)', + settings_aux_advanced_timeout_desc: 'Thời gian chờ request cho auxiliary task này. Để trống để dùng mặc định của Hermes.', + settings_aux_advanced_download_timeout: 'Thời gian chờ tải xuống (giây)', + settings_aux_advanced_download_timeout_desc: 'Chỉ áp dụng cho các tác vụ tải media / nội dung, ví dụ vision. Để trống để dùng mặc định.', + settings_aux_advanced_max_concurrency: 'Số lượng chạy đồng thời tối đa', + settings_aux_advanced_max_concurrency_desc: 'Giới hạn đồng thời cho từng tác vụ (tùy chọn). Để trống để dùng mặc định.', + settings_aux_advanced_extra_body: 'Phần JSON bổ sung cho body', + settings_aux_advanced_extra_body_desc: 'Đối tượng JSON tùy chọn sẽ được gộp vào body request model.', + settings_aux_advanced_api_key: 'Ghi đè API key', + settings_aux_advanced_api_key_set_hint: 'API key đã được thiết lập. Để trống để giữ nguyên, hoặc dùng nút xóa để bỏ.', + settings_aux_advanced_api_key_empty_hint: 'Để trống để dùng credential mặc định của provider.', + settings_aux_advanced_api_key_clear: 'Xóa ghi đè API key hiện có', + settings_aux_advanced_saved: 'Đã lưu tùy chọn auxiliary', + settings_aux_advanced_extra_body_invalid_json: 'Extra body phải là JSON hợp lệ', + settings_aux_advanced_extra_body_object_required: 'Extra body phải là một đối tượng JSON', + settings_aux_advanced_save_failed: 'Lưu tùy chọn auxiliary thất bại', + symlink_link_to: 'Liên kết mềm → {target}', + text_pasted: 'Đã đính kèm văn bản đã dán dưới dạng ', + wiki_browse: 'Duyệt wiki', + wiki_search_placeholder: 'Tìm trang…', + wiki_no_pages: 'Không tìm thấy trang wiki nào', + wiki_not_configured: 'Wiki chưa được cấu hình', + yolo_no_session: 'Không có phiên hoạt động', + yolo_enabled: '⚡ Chế độ YOLO BẬT — phê duyệt sẽ bị bỏ qua trong phiên này', + yolo_disabled: 'Chế độ YOLO TẮT', + yolo_pill_label: 'YOLO', + yolo_pill_title_active: 'Chế độ YOLO đang bật — bấm để tắt', }, }; diff --git a/tests/test_vietnamese_locale.py b/tests/test_vietnamese_locale.py new file mode 100644 index 00000000..104f5389 --- /dev/null +++ b/tests/test_vietnamese_locale.py @@ -0,0 +1,112 @@ +from collections import Counter +from pathlib import Path +import re + + +REPO = Path(__file__).resolve().parent.parent + + +def read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_vietnamese_locale_block_exists(): + src = read(REPO / "static" / "i18n.js") + assert "\n _lang: 'vi'," in src + assert "_label: 'Tiếng Việt'" in src + assert "_speech: 'vi-VN'" in src + + +def extract_locale_block(src: str, locale_key: str) -> str: + start_match = re.search(rf"\b{re.escape(locale_key)}\s*:\s*\{{", src) + assert start_match, f"{locale_key} locale block not found" + + brace_start = start_match.end() - 1 + depth = 0 + in_single = False + in_double = False + in_backtick = False + escape = False + + for i in range(brace_start, len(src)): + ch = src[i] + + if escape: + escape = False + continue + + if in_single: + if ch == "\\": + escape = True + elif ch == "'": + in_single = False + continue + + if in_double: + if ch == "\\": + escape = True + elif ch == '"': + in_double = False + continue + + if in_backtick: + if ch == "\\": + escape = True + elif ch == "`": + in_backtick = False + continue + + if ch == "'": + in_single = True + continue + if ch == '"': + in_double = True + continue + if ch == "`": + in_backtick = True + continue + + if ch == "{": + depth += 1 + continue + if ch == "}": + depth -= 1 + if depth == 0: + return src[brace_start + 1 : i] + + raise AssertionError(f"{locale_key} locale block braces are not balanced") + + +def test_vietnamese_locale_includes_representative_translations(): + src = read(REPO / "static" / "i18n.js") + vi_block = extract_locale_block(src, "vi") + expected = [ + "settings_heading_title: 'Trung tâm điều khiển'", + "settings_heading_subtitle: 'Tùy chọn, công cụ hội thoại và điều khiển hệ thống.'", + "approval_skip_all: '⚡ Bỏ qua tất cả trong phiên này'", + "checkpoint_title: 'Checkpoint'", + "composer_send: 'Gửi tin nhắn'", + "gateway_restart: 'Khởi động lại'", + "wiki_browse: 'Duyệt wiki'", + "yolo_pill_title_active: 'Chế độ YOLO đang bật — bấm để tắt'", + ] + for entry in expected: + assert entry in vi_block + + +def test_vietnamese_locale_covers_english_keys(): + src = read(REPO / "static" / "i18n.js") + key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE) + en_keys = set(key_pattern.findall(extract_locale_block(src, "en"))) + vi_keys = set(key_pattern.findall(extract_locale_block(src, "vi"))) + + missing = sorted(en_keys - vi_keys) + assert not missing, f"Vietnamese locale missing keys: {missing}" + + +def test_vietnamese_locale_has_no_duplicate_keys(): + src = read(REPO / "static" / "i18n.js") + key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE) + keys = key_pattern.findall(extract_locale_block(src, "vi")) + duplicates = sorted(k for k, count in Counter(keys).items() if count > 1) + assert not duplicates, f"Vietnamese locale has duplicate keys: {duplicates}"