mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-03-28 17:42:56 +01:00
Refactor i18n, add translation guide, and audit documentation
## i18n Refactoring - Refactor admin translations to use common composable patterns - Add common patterns: delete_entity_confirm, entity_added_success, move_entity_to_another_node, etc. - Remove 18 duplicate keys from admin_members and admin_node_tags sections - Update all admin JavaScript files to use new common patterns with dynamic entity composition - Fix label consistency: rename first_seen to first_seen_label to match naming convention ## Translation Documentation - Create comprehensive translation reference guide (languages.md) with 200+ documented keys - Add translation architecture documentation to AGENTS.md with examples and best practices - Add "Help Translate" call-to-action section in README with link to translation guide - Add i18n feature to README features list ## Documentation Audit - Add undocumented config options: API_KEY, WEB_LOCALE, WEB_DOMAIN to README and .env.example - Fix outdated CLI syntax: interface --mode receiver → interface receiver - Update database migration commands to use CLI wrapper (meshcore-hub db) instead of direct alembic - Add static/locales/ directory to project structure section - Add i18n configuration (WEB_LOCALE, WEB_THEME) to docker-compose.yml ## Testing - All 438 tests passing - All pre-commit checks passing (black, flake8, mypy) - Added tests for new common translation patterns Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
16
.env.example
16
.env.example
@@ -198,11 +198,24 @@ API_ADMIN_KEY=
|
||||
# External web port
|
||||
WEB_PORT=8080
|
||||
|
||||
# API endpoint URL for the web dashboard
|
||||
# Default: http://localhost:8000
|
||||
# API_BASE_URL=http://localhost:8000
|
||||
|
||||
# API key for web dashboard queries (optional)
|
||||
# If API_READ_KEY is set on the API, provide it here
|
||||
# API_KEY=
|
||||
|
||||
# Default theme for the web dashboard (dark or light)
|
||||
# Users can override via the theme toggle; their preference is saved in localStorage
|
||||
# Default: dark
|
||||
# WEB_THEME=dark
|
||||
|
||||
# Locale/language for the web dashboard
|
||||
# Default: en
|
||||
# Supported: en (see src/meshcore_hub/web/static/locales/ for available translations)
|
||||
# WEB_LOCALE=en
|
||||
|
||||
# Enable admin interface at /a/ (requires auth proxy in front)
|
||||
# Default: false
|
||||
# WEB_ADMIN_ENABLED=false
|
||||
@@ -221,6 +234,9 @@ TZ=UTC
|
||||
# -------------------
|
||||
# Displayed on the web dashboard homepage
|
||||
|
||||
# Network domain name (optional)
|
||||
# NETWORK_DOMAIN=
|
||||
|
||||
# Network display name
|
||||
NETWORK_NAME=MeshCore Network
|
||||
|
||||
|
||||
96
AGENTS.md
96
AGENTS.md
@@ -453,13 +453,103 @@ The web dashboard is a Single Page Application. Pages are ES modules loaded by t
|
||||
- Use `pageColors` from `components.js` for section-specific colors (reads CSS custom properties from `app.css`)
|
||||
- Return a cleanup function if the page creates resources (e.g., Leaflet maps, Chart.js instances)
|
||||
|
||||
### Internationalization (i18n)
|
||||
|
||||
The web dashboard supports internationalization via JSON translation files. The default language is English.
|
||||
|
||||
**Translation files location:** `src/meshcore_hub/web/static/locales/`
|
||||
|
||||
**Key files:**
|
||||
- `en.json` - English translations (reference implementation)
|
||||
- `languages.md` - Comprehensive translation reference guide for translators
|
||||
|
||||
**Using translations in JavaScript:**
|
||||
|
||||
Import the `t()` function from `components.js`:
|
||||
|
||||
```javascript
|
||||
import { t } from '../components.js';
|
||||
|
||||
// Simple translation
|
||||
const label = t('common.save'); // "Save"
|
||||
|
||||
// Translation with variable interpolation
|
||||
const title = t('common.add_entity', { entity: t('entities.node') }); // "Add Node"
|
||||
|
||||
// Composed patterns for consistency
|
||||
const emptyMsg = t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() }); // "No nodes found"
|
||||
```
|
||||
|
||||
**Translation architecture:**
|
||||
|
||||
1. **Entity-based composition:** Core entity names (`entities.*`) are referenced by composite patterns for consistency
|
||||
2. **Reusable patterns:** Common UI patterns (`common.*`) use `{{variable}}` interpolation for dynamic content
|
||||
3. **Separation of concerns:**
|
||||
- Keys without `_label` suffix = table headers (title case, no colon)
|
||||
- Keys with `_label` suffix = inline labels (sentence case, with colon)
|
||||
|
||||
**When adding/modifying translations:**
|
||||
|
||||
1. **Add new keys** to `en.json` following existing patterns:
|
||||
- Use composition when possible (reference `entities.*` in `common.*` patterns)
|
||||
- Group related keys by section (e.g., `admin_members.*`, `admin_node_tags.*`)
|
||||
- Use `{{variable}}` syntax for dynamic content
|
||||
|
||||
2. **Update `languages.md`** with:
|
||||
- Key name, English value, and usage context
|
||||
- Variable descriptions if using interpolation
|
||||
- Notes about HTML content or special formatting
|
||||
|
||||
3. **Add tests** in `tests/test_common/test_i18n.py`:
|
||||
- Test new interpolation patterns
|
||||
- Test required sections if adding new top-level sections
|
||||
- Test composed patterns with entity references
|
||||
|
||||
4. **Run i18n tests:**
|
||||
```bash
|
||||
pytest tests/test_common/test_i18n.py -v
|
||||
```
|
||||
|
||||
**Best practices:**
|
||||
|
||||
- **Avoid duplication:** Use `common.*` patterns instead of duplicating similar strings
|
||||
- **Compose with entities:** Reference `entities.*` keys in patterns rather than hardcoding entity names
|
||||
- **Preserve variables:** Keep `{{variable}}` placeholders unchanged when translating
|
||||
- **Test composition:** Verify patterns work with all entity types (singular/plural, lowercase/uppercase)
|
||||
- **Document context:** Always update `languages.md` so translators understand usage
|
||||
|
||||
**Example - adding a new entity and patterns:**
|
||||
|
||||
```javascript
|
||||
// 1. Add entity to en.json
|
||||
"entities": {
|
||||
"sensor": "Sensor"
|
||||
}
|
||||
|
||||
// 2. Use with existing common patterns
|
||||
t('common.add_entity', { entity: t('entities.sensor') }) // "Add Sensor"
|
||||
t('common.no_entity_found', { entity: t('entities.sensors').toLowerCase() }) // "No sensors found"
|
||||
|
||||
// 3. Update languages.md with context
|
||||
// 4. Add test to test_i18n.py
|
||||
```
|
||||
|
||||
**Translation loading:**
|
||||
|
||||
The i18n system (`src/meshcore_hub/common/i18n.py`) loads translations on startup:
|
||||
- Defaults to English (`en`)
|
||||
- Falls back to English for missing keys
|
||||
- Returns the key itself if translation not found
|
||||
|
||||
For full translation guidelines, see `src/meshcore_hub/web/static/locales/languages.md`.
|
||||
|
||||
### Adding a New Database Model
|
||||
|
||||
1. Create model in `common/models/`
|
||||
2. Export in `common/models/__init__.py`
|
||||
3. Create Alembic migration: `alembic revision --autogenerate -m "description"`
|
||||
3. Create Alembic migration: `meshcore-hub db revision --autogenerate -m "description"`
|
||||
4. Review and adjust migration file
|
||||
5. Test migration: `alembic upgrade head`
|
||||
5. Test migration: `meshcore-hub db upgrade`
|
||||
|
||||
### Running the Development Environment
|
||||
|
||||
@@ -481,7 +571,7 @@ pytest
|
||||
# Run specific component
|
||||
meshcore-hub api --reload
|
||||
meshcore-hub collector
|
||||
meshcore-hub interface --mode receiver --mock
|
||||
meshcore-hub interface receiver --mock
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
27
README.md
27
README.md
@@ -8,6 +8,23 @@ Python 3.14+ platform for managing and orchestrating MeshCore mesh networks.
|
||||
|
||||

|
||||
|
||||
## 🌍 Help Translate MeshCore Hub
|
||||
|
||||
**We need volunteers to translate the web dashboard into other languages!**
|
||||
|
||||
MeshCore Hub includes full internationalization (i18n) support with a composable translation system. We're looking for open source volunteers to contribute and maintain language packs for their native languages.
|
||||
|
||||
**Current translations:**
|
||||
- 🇬🇧 English (complete)
|
||||
|
||||
**How to contribute:**
|
||||
1. Check out the [Translation Reference Guide](src/meshcore_hub/web/static/locales/languages.md) for complete documentation
|
||||
2. Copy `src/meshcore_hub/web/static/locales/en.json` to your language code (e.g., `es.json`, `fr.json`, `de.json`)
|
||||
3. Translate all values while preserving variables (`{{variable}}`) and HTML tags
|
||||
4. Submit a pull request with your translation
|
||||
|
||||
Even partial translations are welcome! The system falls back to English for missing keys, so you can translate incrementally.
|
||||
|
||||
## Overview
|
||||
|
||||
MeshCore Hub provides a complete solution for monitoring, collecting, and interacting with MeshCore mesh networks. It consists of multiple components that work together:
|
||||
@@ -70,6 +87,7 @@ flowchart LR
|
||||
- **Command Dispatch**: Send messages and advertisements via the API
|
||||
- **Node Tagging**: Add custom metadata to nodes for organization
|
||||
- **Web Dashboard**: Visualize network status, node locations, and message history
|
||||
- **Internationalization**: Full i18n support with composable translation patterns
|
||||
- **Docker Ready**: Single image with all components, easy deployment
|
||||
|
||||
## Getting Started
|
||||
@@ -248,7 +266,7 @@ pip install -e ".[dev]"
|
||||
meshcore-hub db upgrade
|
||||
|
||||
# Start components (in separate terminals)
|
||||
meshcore-hub interface --mode receiver --port /dev/ttyUSB0
|
||||
meshcore-hub interface receiver --port /dev/ttyUSB0
|
||||
meshcore-hub collector
|
||||
meshcore-hub api
|
||||
meshcore-hub web
|
||||
@@ -339,9 +357,12 @@ The collector automatically cleans up old event data and inactive nodes:
|
||||
| `WEB_HOST` | `0.0.0.0` | Web server bind address |
|
||||
| `WEB_PORT` | `8080` | Web server port |
|
||||
| `API_BASE_URL` | `http://localhost:8000` | API endpoint URL |
|
||||
| `API_KEY` | *(none)* | API key for web dashboard queries (optional) |
|
||||
| `WEB_THEME` | `dark` | Default theme (`dark` or `light`). Users can override via theme toggle in navbar. |
|
||||
| `WEB_LOCALE` | `en` | Locale/language for the web dashboard (e.g., `en`, `es`, `fr`) |
|
||||
| `WEB_ADMIN_ENABLED` | `false` | Enable admin interface at /a/ (requires auth proxy) |
|
||||
| `TZ` | `UTC` | Timezone for displaying dates/times (e.g., `America/New_York`, `Europe/London`) |
|
||||
| `NETWORK_DOMAIN` | *(none)* | Network domain name (optional) |
|
||||
| `NETWORK_NAME` | `MeshCore Network` | Display name for the network |
|
||||
| `NETWORK_CITY` | *(none)* | City where network is located |
|
||||
| `NETWORK_COUNTRY` | *(none)* | Country code (ISO 3166-1 alpha-2) |
|
||||
@@ -632,7 +653,9 @@ meshcore-hub/
|
||||
│ ├── api/ # REST API
|
||||
│ └── web/ # Web dashboard
|
||||
│ ├── templates/ # Jinja2 templates (SPA shell)
|
||||
│ └── static/js/spa/ # SPA frontend (ES modules, lit-html)
|
||||
│ └── static/
|
||||
│ ├── js/spa/ # SPA frontend (ES modules, lit-html)
|
||||
│ └── locales/ # Translation files (en.json, languages.md)
|
||||
├── tests/ # Test suite
|
||||
├── alembic/ # Database migrations
|
||||
├── etc/ # Configuration files (mosquitto.conf)
|
||||
|
||||
@@ -251,6 +251,8 @@ services:
|
||||
- API_KEY=${API_ADMIN_KEY:-${API_READ_KEY:-}}
|
||||
- WEB_HOST=0.0.0.0
|
||||
- WEB_PORT=8080
|
||||
- WEB_THEME=${WEB_THEME:-dark}
|
||||
- WEB_LOCALE=${WEB_LOCALE:-en}
|
||||
- WEB_ADMIN_ENABLED=${WEB_ADMIN_ENABLED:-false}
|
||||
- NETWORK_NAME=${NETWORK_NAME:-MeshCore Network}
|
||||
- NETWORK_CITY=${NETWORK_CITY:-}
|
||||
|
||||
@@ -78,8 +78,8 @@ export async function render(container, params, router) {
|
||||
</div>`
|
||||
: html`
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<p>${t('admin_members.no_members_yet')}</p>
|
||||
<p class="text-sm mt-2">${t('admin_members.no_members_hint')}</p>
|
||||
<p>${t('common.no_entity_yet', { entity: t('entities.members').toLowerCase() })}</p>
|
||||
<p class="text-sm mt-2">${t('admin_members.empty_state_hint')}</p>
|
||||
</div>`;
|
||||
|
||||
litRender(html`
|
||||
@@ -208,10 +208,10 @@ ${flashHtml}
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">${t('common.delete_entity', { entity: t('entities.member') })}</h3>
|
||||
<div class="py-4">
|
||||
<p class="py-4">Are you sure you want to delete member <strong id="delete_member_name"></strong>?</p>
|
||||
<p class="py-4" id="delete_confirm_message"></p>
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span>${t('admin_members.cannot_be_undone')}</span>
|
||||
<span>${t('common.cannot_be_undone')}</span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="deleteCancel">${t('common.cancel')}</button>
|
||||
@@ -249,7 +249,7 @@ ${flashHtml}
|
||||
try {
|
||||
await apiPost('/api/v1/members', body);
|
||||
container.querySelector('#addModal').close();
|
||||
router.navigate('/a/members?message=' + encodeURIComponent(t('admin_members.entity_added')));
|
||||
router.navigate('/a/members?message=' + encodeURIComponent(t('common.entity_added_success', { entity: t('entities.member') })));
|
||||
} catch (err) {
|
||||
container.querySelector('#addModal').close();
|
||||
router.navigate('/a/members?error=' + encodeURIComponent(err.message));
|
||||
@@ -289,7 +289,7 @@ ${flashHtml}
|
||||
try {
|
||||
await apiPut('/api/v1/members/' + encodeURIComponent(id), body);
|
||||
container.querySelector('#editModal').close();
|
||||
router.navigate('/a/members?message=' + encodeURIComponent(t('admin_members.entity_updated')));
|
||||
router.navigate('/a/members?message=' + encodeURIComponent(t('common.entity_updated_success', { entity: t('entities.member') })));
|
||||
} catch (err) {
|
||||
container.querySelector('#editModal').close();
|
||||
router.navigate('/a/members?error=' + encodeURIComponent(err.message));
|
||||
@@ -301,7 +301,12 @@ ${flashHtml}
|
||||
btn.addEventListener('click', () => {
|
||||
const row = btn.closest('tr');
|
||||
activeDeleteId = row.dataset.memberId;
|
||||
container.querySelector('#delete_member_name').textContent = row.dataset.memberName;
|
||||
const memberName = row.dataset.memberName;
|
||||
const confirmMsg = t('common.delete_entity_confirm', {
|
||||
entity: t('entities.member').toLowerCase(),
|
||||
name: memberName
|
||||
});
|
||||
container.querySelector('#delete_confirm_message').innerHTML = confirmMsg;
|
||||
container.querySelector('#deleteModal').showModal();
|
||||
});
|
||||
});
|
||||
@@ -314,7 +319,7 @@ ${flashHtml}
|
||||
try {
|
||||
await apiDelete('/api/v1/members/' + encodeURIComponent(activeDeleteId));
|
||||
container.querySelector('#deleteModal').close();
|
||||
router.navigate('/a/members?message=' + encodeURIComponent(t('admin_members.entity_deleted')));
|
||||
router.navigate('/a/members?message=' + encodeURIComponent(t('common.entity_deleted_success', { entity: t('entities.member') })));
|
||||
} catch (err) {
|
||||
container.querySelector('#deleteModal').close();
|
||||
router.navigate('/a/members?error=' + encodeURIComponent(err.message));
|
||||
|
||||
@@ -93,8 +93,8 @@ export async function render(container, params, router) {
|
||||
</div>`
|
||||
: html`
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<p>${t('admin_node_tags.no_tags_found')}</p>
|
||||
<p class="text-sm mt-2">${t('admin_node_tags.no_tags_hint')}</p>
|
||||
<p>${t('common.no_entity_found', { entity: t('entities.tags').toLowerCase() }) + ' ' + t('admin_node_tags.for_this_node')}</p>
|
||||
<p class="text-sm mt-2">${t('admin_node_tags.empty_state_hint')}</p>
|
||||
</div>`;
|
||||
|
||||
const bulkButtons = tags.length > 0
|
||||
@@ -116,7 +116,7 @@ export async function render(container, params, router) {
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
${bulkButtons}
|
||||
<a href="/nodes/${encodeURIComponent(selectedPublicKey)}" class="btn btn-ghost btn-sm">${t('admin_node_tags.view_node')}</a>
|
||||
<a href="/nodes/${encodeURIComponent(selectedPublicKey)}" class="btn btn-ghost btn-sm">${t('common.view_entity', { entity: t('entities.node') })}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,7 +124,7 @@ export async function render(container, params, router) {
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${t('admin_node_tags.tags_count', { count: tags.length })}</h2>
|
||||
<h2 class="card-title">${t('entities.tags')} (${tags.length})</h2>
|
||||
${tagsTableHtml}
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,7 +188,7 @@ export async function render(container, params, router) {
|
||||
|
||||
<dialog id="moveModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">${t('admin_node_tags.move_to_another_node')}</h3>
|
||||
<h3 class="font-bold text-lg">${t('common.move_entity_to_another_node', { entity: t('entities.tag') })}</h3>
|
||||
<form id="move-tag-form" class="py-4">
|
||||
<div class="form-control mb-4">
|
||||
<label class="label"><span class="label-text">${t('admin_node_tags.tag_key')}</span></label>
|
||||
@@ -222,10 +222,10 @@ export async function render(container, params, router) {
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">${t('common.delete_entity', { entity: t('entities.tag') })}</h3>
|
||||
<div class="py-4">
|
||||
<p class="py-4">Are you sure you want to delete the tag "<span id="deleteKeyDisplay" class="font-mono font-semibold"></span>"?</p>
|
||||
<p class="py-4" id="delete_tag_confirm_message"></p>
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span>${t('admin_node_tags.cannot_be_undone')}</span>
|
||||
<span>${t('common.cannot_be_undone')}</span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="deleteCancel">${t('common.cancel')}</button>
|
||||
@@ -238,9 +238,9 @@ export async function render(container, params, router) {
|
||||
|
||||
<dialog id="copyAllModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">${t('admin_node_tags.copy_all_title')}</h3>
|
||||
<h3 class="font-bold text-lg">${t('common.copy_all_entity_to_another_node', { entity: t('entities.tags') })}</h3>
|
||||
<form id="copy-all-form" class="py-4">
|
||||
<p class="mb-4">${unsafeHTML(t('admin_node_tags.copy_all_description', { count: tags.length, name: nodeName }))}</p>
|
||||
<p class="mb-4">${unsafeHTML(t('common.copy_all_entity_description', { count: tags.length, entity: t('entities.tags').toLowerCase(), name: nodeName }))}</p>
|
||||
<div class="form-control mb-4">
|
||||
<label class="label"><span class="label-text">${t('admin_node_tags.destination_node')}</span></label>
|
||||
<select id="copyAllDestination" class="select select-bordered w-full" required>
|
||||
@@ -258,7 +258,7 @@ export async function render(container, params, router) {
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" id="copyAllCancel">${t('common.cancel')}</button>
|
||||
<button type="submit" class="btn btn-primary">${t('admin_node_tags.copy_tags')}</button>
|
||||
<button type="submit" class="btn btn-primary">${t('common.copy_entity', { entity: t('entities.tags') })}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -269,7 +269,7 @@ export async function render(container, params, router) {
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">${t('common.delete_all_entity', { entity: t('entities.tags') })}</h3>
|
||||
<div class="py-4">
|
||||
<p class="mb-4">${unsafeHTML(t('admin_node_tags.delete_all_confirm', { count: tags.length, name: nodeName }))}</p>
|
||||
<p class="mb-4">${unsafeHTML(t('common.delete_all_entity_confirm', { count: tags.length, entity: t('entities.tags').toLowerCase(), name: nodeName }))}</p>
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span>${t('admin_node_tags.delete_all_warning')}</span>
|
||||
@@ -371,7 +371,7 @@ ${contentHtml}`, container);
|
||||
await apiPost('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags', {
|
||||
key, value, value_type,
|
||||
});
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('admin_node_tags.entity_added')));
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_added_success', { entity: t('entities.tag') })));
|
||||
} catch (err) {
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
}
|
||||
@@ -403,7 +403,7 @@ ${contentHtml}`, container);
|
||||
value, value_type,
|
||||
});
|
||||
container.querySelector('#editModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('admin_node_tags.entity_updated')));
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_updated_success', { entity: t('entities.tag') })));
|
||||
} catch (err) {
|
||||
container.querySelector('#editModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
@@ -435,7 +435,7 @@ ${contentHtml}`, container);
|
||||
new_public_key: newPublicKey,
|
||||
});
|
||||
container.querySelector('#moveModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('admin_node_tags.entity_moved')));
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_moved_success', { entity: t('entities.tag') })));
|
||||
} catch (err) {
|
||||
container.querySelector('#moveModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
@@ -447,7 +447,11 @@ ${contentHtml}`, container);
|
||||
btn.addEventListener('click', () => {
|
||||
const row = btn.closest('tr');
|
||||
activeTagKey = row.dataset.tagKey;
|
||||
container.querySelector('#deleteKeyDisplay').textContent = activeTagKey;
|
||||
const confirmMsg = t('common.delete_entity_confirm', {
|
||||
entity: t('entities.tag').toLowerCase(),
|
||||
name: `"<span class="font-mono font-semibold">${activeTagKey}</span>"`
|
||||
});
|
||||
container.querySelector('#delete_tag_confirm_message').innerHTML = confirmMsg;
|
||||
container.querySelector('#deleteModal').showModal();
|
||||
});
|
||||
});
|
||||
@@ -460,7 +464,7 @@ ${contentHtml}`, container);
|
||||
try {
|
||||
await apiDelete('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags/' + encodeURIComponent(activeTagKey));
|
||||
container.querySelector('#deleteModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('admin_node_tags.entity_deleted')));
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_deleted_success', { entity: t('entities.tag') })));
|
||||
} catch (err) {
|
||||
container.querySelector('#deleteModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
@@ -511,7 +515,7 @@ ${contentHtml}`, container);
|
||||
try {
|
||||
await apiDelete('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags');
|
||||
container.querySelector('#deleteAllModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('admin_node_tags.all_entities_deleted')));
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.all_entity_deleted_success', { entity: t('entities.tags').toLowerCase() })));
|
||||
} catch (err) {
|
||||
container.querySelector('#deleteAllModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
|
||||
@@ -80,7 +80,7 @@ ${content}`, container);
|
||||
: nothing;
|
||||
|
||||
const mobileCards = advertisements.length === 0
|
||||
? html`<div class="text-center py-8 opacity-70">${t('advertisements.no_advertisements_found')}</div>`
|
||||
? html`<div class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}</div>`
|
||||
: advertisements.map(ad => {
|
||||
const emoji = typeEmoji(ad.adv_type);
|
||||
const adName = ad.node_tag_name || ad.node_name || ad.name;
|
||||
@@ -119,7 +119,7 @@ ${content}`, container);
|
||||
});
|
||||
|
||||
const tableRows = advertisements.length === 0
|
||||
? html`<tr><td colspan="3" class="text-center py-8 opacity-70">${t('advertisements.no_advertisements_found')}</td></tr>`
|
||||
? html`<tr><td colspan="3" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}</td></tr>`
|
||||
: advertisements.map(ad => {
|
||||
const emoji = typeEmoji(ad.adv_type);
|
||||
const adName = ad.node_tag_name || ad.node_name || ad.name;
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function render(container, params, router) {
|
||||
|
||||
} catch (e) {
|
||||
if (e.message && e.message.includes('404')) {
|
||||
litRender(errorAlert(t('custom_page.page_not_found')), container);
|
||||
litRender(errorAlert(t('common.page_not_found')), container);
|
||||
} else {
|
||||
litRender(errorAlert(e.message || t('custom_page.failed_to_load')), container);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ function formatTimeShort(isoString) {
|
||||
|
||||
function renderRecentAds(ads) {
|
||||
if (!ads || ads.length === 0) {
|
||||
return html`<p class="text-sm opacity-70">${t('dashboard.no_advertisements_yet')}</p>`;
|
||||
return html`<p class="text-sm opacity-70">${t('common.no_entity_yet', { entity: t('entities.advertisements').toLowerCase() })}</p>`;
|
||||
}
|
||||
const rows = ads.slice(0, 5).map(ad => {
|
||||
const friendlyName = ad.tag_name || ad.name;
|
||||
|
||||
@@ -309,7 +309,7 @@ export async function render(container, params, router) {
|
||||
}
|
||||
|
||||
if (debug.total_nodes === 0) {
|
||||
container.querySelector('#node-count').textContent = t('map.no_nodes_in_database');
|
||||
container.querySelector('#node-count').textContent = t('common.no_entity_in_database', { entity: t('entities.nodes').toLowerCase() });
|
||||
return () => map.remove();
|
||||
}
|
||||
|
||||
|
||||
@@ -92,8 +92,8 @@ export async function render(container, params, router) {
|
||||
<div class="alert alert-info">
|
||||
${iconInfo('stroke-current shrink-0 h-6 w-6')}
|
||||
<div>
|
||||
<h3 class="font-bold">${t('members.no_members_configured')}</h3>
|
||||
<p class="text-sm">${t('members.no_members_description')}</p>
|
||||
<h3 class="font-bold">${t('common.no_entity_configured', { entity: t('entities.members').toLowerCase() })}</h3>
|
||||
<p class="text-sm">${t('members.empty_state_description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ ${content}`, container);
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const mobileCards = messages.length === 0
|
||||
? html`<div class="text-center py-8 opacity-70">${t('messages.no_messages_found')}</div>`
|
||||
? html`<div class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}</div>`
|
||||
: messages.map(msg => {
|
||||
const isChannel = msg.message_type === 'channel';
|
||||
const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}';
|
||||
@@ -95,7 +95,7 @@ ${content}`, container);
|
||||
});
|
||||
|
||||
const tableRows = messages.length === 0
|
||||
? html`<tr><td colspan="5" class="text-center py-8 opacity-70">${t('messages.no_messages_found')}</td></tr>`
|
||||
? html`<tr><td colspan="5" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}</td></tr>`
|
||||
: messages.map(msg => {
|
||||
const isChannel = msg.message_type === 'channel';
|
||||
const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}';
|
||||
|
||||
@@ -101,7 +101,7 @@ export async function render(container, params, router) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`
|
||||
: html`<p class="opacity-70">${t('nodes.no_advertisements')}</p>`;
|
||||
: html`<p class="opacity-70">${t('common.no_entity_recorded', { entity: t('entities.advertisements').toLowerCase() })}</p>`;
|
||||
|
||||
const tags = node.tags || [];
|
||||
const tagsTableHtml = tags.length > 0
|
||||
@@ -123,7 +123,7 @@ export async function render(container, params, router) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`
|
||||
: html`<p class="opacity-70">${t('nodes.no_tags')}</p>`;
|
||||
: html`<p class="opacity-70">${t('common.no_entity_defined', { entity: t('entities.tags').toLowerCase() })}</p>`;
|
||||
|
||||
const adminTagsHtml = (config.admin_enabled && config.is_authenticated)
|
||||
? html`<div class="mt-3">
|
||||
@@ -154,7 +154,7 @@ ${heroHtml}
|
||||
<code class="text-sm bg-base-200 p-2 rounded block break-all">${node.public_key}</code>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-8 gap-y-2 mt-4 text-sm">
|
||||
<div><span class="opacity-70">${t('common.first_seen')}</span> ${formatDateTime(node.first_seen)}</div>
|
||||
<div><span class="opacity-70">${t('common.first_seen_label')}</span> ${formatDateTime(node.first_seen)}</div>
|
||||
<div><span class="opacity-70">${t('common.last_seen_label')}</span> ${formatDateTime(node.last_seen)}</div>
|
||||
${coordsHtml}
|
||||
</div>
|
||||
@@ -239,12 +239,12 @@ function renderNotFound(publicKey) {
|
||||
<ul>
|
||||
<li><a href="/">${t('entities.home')}</a></li>
|
||||
<li><a href="/nodes">${t('entities.nodes')}</a></li>
|
||||
<li>${t('not_found.title')}</li>
|
||||
<li>${t('common.page_not_found')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="alert alert-error">
|
||||
${iconError('stroke-current shrink-0 h-6 w-6')}
|
||||
<span>${t('nodes.node_not_found', { public_key: publicKey })}</span>
|
||||
<span>${t('common.entity_not_found_details', { entity: t('entities.node'), details: publicKey })}</span>
|
||||
</div>
|
||||
<a href="/nodes" class="btn btn-primary mt-4">${t('common.view_entity', { entity: t('entities.nodes') })}</a>`;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ ${content}`, container);
|
||||
: nothing;
|
||||
|
||||
const mobileCards = nodes.length === 0
|
||||
? html`<div class="text-center py-8 opacity-70">${t('nodes.no_nodes_found')}</div>`
|
||||
? html`<div class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}</div>`
|
||||
: nodes.map(node => {
|
||||
const tagName = node.tags?.find(tag => tag.key === 'name')?.value;
|
||||
const displayName = tagName || node.name;
|
||||
@@ -97,7 +97,7 @@ ${content}`, container);
|
||||
});
|
||||
|
||||
const tableRows = nodes.length === 0
|
||||
? html`<tr><td colspan="3" class="text-center py-8 opacity-70">${t('nodes.no_nodes_found')}</td></tr>`
|
||||
? html`<tr><td colspan="3" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}</td></tr>`
|
||||
: nodes.map(node => {
|
||||
const tagName = node.tags?.find(tag => tag.key === 'name')?.value;
|
||||
const displayName = tagName || node.name;
|
||||
|
||||
@@ -7,7 +7,7 @@ export async function render(container, params, router) {
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<div class="text-9xl font-bold text-primary opacity-20">404</div>
|
||||
<h1 class="text-4xl font-bold -mt-8">${t('not_found.title')}</h1>
|
||||
<h1 class="text-4xl font-bold -mt-8">${t('common.page_not_found')}</h1>
|
||||
<p class="py-6 text-base-content/70">
|
||||
${t('not_found.description')}
|
||||
</p>
|
||||
|
||||
@@ -34,11 +34,30 @@
|
||||
"delete_entity": "Delete {{entity}}",
|
||||
"delete_all_entity": "Delete All {{entity}}",
|
||||
"move_entity": "Move {{entity}}",
|
||||
"move_entity_to_another_node": "Move {{entity}} to Another Node",
|
||||
"copy_entity": "Copy {{entity}}",
|
||||
"copy_all_entity_to_another_node": "Copy All {{entity}} to Another Node",
|
||||
"view_entity": "View {{entity}}",
|
||||
"recent_entity": "Recent {{entity}}",
|
||||
"total_entity": "Total {{entity}}",
|
||||
"all_entity": "All {{entity}}",
|
||||
"no_entity_found": "No {{entity}} found",
|
||||
"no_entity_recorded": "No {{entity}} recorded",
|
||||
"no_entity_defined": "No {{entity}} defined",
|
||||
"no_entity_in_database": "No {{entity}} in database",
|
||||
"no_entity_configured": "No {{entity}} configured",
|
||||
"no_entity_yet": "No {{entity}} yet",
|
||||
"entity_not_found_details": "{{entity}} not found: {{details}}",
|
||||
"page_not_found": "Page not found",
|
||||
"delete_entity_confirm": "Are you sure you want to delete {{entity}} <strong>{{name}}</strong>?",
|
||||
"delete_all_entity_confirm": "Are you sure you want to delete all {{count}} {{entity}} from <strong>{{name}}</strong>?",
|
||||
"cannot_be_undone": "This action cannot be undone.",
|
||||
"entity_added_success": "{{entity}} added successfully",
|
||||
"entity_updated_success": "{{entity}} updated successfully",
|
||||
"entity_deleted_success": "{{entity}} deleted successfully",
|
||||
"entity_moved_success": "{{entity}} moved successfully",
|
||||
"all_entity_deleted_success": "All {{entity}} deleted successfully",
|
||||
"copy_all_entity_description": "Copy all {{count}} {{entity}} from <strong>{{name}}</strong> to another node.",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"go_home": "Go Home",
|
||||
@@ -67,7 +86,7 @@
|
||||
"callsign": "Callsign",
|
||||
"tags": "Tags",
|
||||
"last_seen": "Last Seen",
|
||||
"first_seen": "First seen:",
|
||||
"first_seen_label": "First seen:",
|
||||
"last_seen_label": "Last seen:",
|
||||
"location": "Location",
|
||||
"public_key": "Public Key",
|
||||
@@ -117,21 +136,13 @@
|
||||
"dashboard": {
|
||||
"all_discovered_nodes": "All discovered nodes",
|
||||
"recent_channel_messages": "Recent Channel Messages",
|
||||
"channel": "Channel {{number}}",
|
||||
"no_advertisements_yet": "No advertisements recorded yet."
|
||||
"channel": "Channel {{number}}"
|
||||
},
|
||||
"nodes": {
|
||||
"no_nodes_found": "No nodes found.",
|
||||
"node_not_found": "Node not found: {{public_key}}",
|
||||
"scan_to_add": "Scan to add as contact",
|
||||
"no_advertisements": "No advertisements recorded.",
|
||||
"no_tags": "No tags defined."
|
||||
},
|
||||
"advertisements": {
|
||||
"no_advertisements_found": "No advertisements found."
|
||||
"scan_to_add": "Scan to add as contact"
|
||||
},
|
||||
"advertisements": {},
|
||||
"messages": {
|
||||
"no_messages_found": "No messages found.",
|
||||
"type_direct": "Direct",
|
||||
"type_channel": "Channel",
|
||||
"type_contact": "Contact",
|
||||
@@ -144,7 +155,6 @@
|
||||
"infrastructure": "Infrastructure",
|
||||
"public": "Public",
|
||||
"nodes_on_map": "{{count}} nodes on map",
|
||||
"no_nodes_in_database": "No nodes in database",
|
||||
"nodes_none_have_coordinates": "{{count}} nodes (none have coordinates)",
|
||||
"gps_description": "Nodes are placed on the map based on GPS coordinates from node reports or manual tags.",
|
||||
"owner": "Owner:",
|
||||
@@ -152,18 +162,15 @@
|
||||
"select_destination_node": "-- Select destination node --"
|
||||
},
|
||||
"members": {
|
||||
"no_members_configured": "No members configured",
|
||||
"no_members_description": "To display network members, create a members.yaml file in your seed directory.",
|
||||
"empty_state_description": "To display network members, create a members.yaml file in your seed directory.",
|
||||
"members_file_format": "Members File Format",
|
||||
"members_file_description": "Create a YAML file at <code>$SEED_HOME/members.yaml</code> with the following structure:",
|
||||
"members_import_instructions": "Run <code>meshcore-hub collector seed</code> to import members.<br/>To associate nodes with members, add a <code>member_id</code> tag to nodes in <code>node_tags.yaml</code>."
|
||||
},
|
||||
"not_found": {
|
||||
"title": "Page Not Found",
|
||||
"description": "The page you're looking for doesn't exist or has been moved."
|
||||
},
|
||||
"custom_page": {
|
||||
"page_not_found": "Page not found",
|
||||
"failed_to_load": "Failed to load page"
|
||||
},
|
||||
"admin": {
|
||||
@@ -180,40 +187,23 @@
|
||||
"network_members": "Network Members ({{count}})",
|
||||
"member_id": "Member ID",
|
||||
"member_id_hint": "Unique identifier (letters, numbers, underscore)",
|
||||
"no_members_yet": "No members configured yet.",
|
||||
"no_members_hint": "Click \"Add Member\" to create the first member.",
|
||||
"delete_confirm": "Are you sure you want to delete member <strong>{{name}}</strong>?",
|
||||
"cannot_be_undone": "This action cannot be undone.",
|
||||
"entity_added": "Member added successfully",
|
||||
"entity_updated": "Member updated successfully",
|
||||
"entity_deleted": "Member deleted successfully"
|
||||
"empty_state_hint": "Click \"Add Member\" to create the first member."
|
||||
},
|
||||
"admin_node_tags": {
|
||||
"select_node": "Select Node",
|
||||
"select_node_placeholder": "-- Select a node --",
|
||||
"load_tags": "Load Tags",
|
||||
"move_to_another_node": "Move Tag to Another Node",
|
||||
"move_warning": "This will move the tag from the current node to the destination node.",
|
||||
"delete_tag_confirm": "Are you sure you want to delete the tag \"{{key}}\"?",
|
||||
"cannot_be_undone": "This action cannot be undone.",
|
||||
"copy_all": "Copy All",
|
||||
"copy_all_title": "Copy All Tags to Another Node",
|
||||
"copy_all_description": "Copy all {{count}} tag(s) from <strong>{{name}}</strong> to another node.",
|
||||
"copy_all_info": "Tags that already exist on the destination node will be skipped. Original tags remain on this node.",
|
||||
"delete_all": "Delete All",
|
||||
"delete_all_confirm": "Are you sure you want to delete all {{count}} tag(s) from <strong>{{name}}</strong>?",
|
||||
"delete_all_warning": "This action cannot be undone. All tags will be permanently deleted.",
|
||||
"delete_all_warning": "All tags will be permanently deleted.",
|
||||
"destination_node": "Destination Node",
|
||||
"tag_key": "Tag Key",
|
||||
"no_tags_found": "No tags found for this node.",
|
||||
"no_tags_hint": "Add a new tag below.",
|
||||
"for_this_node": "for this node",
|
||||
"empty_state_hint": "Add a new tag below.",
|
||||
"select_a_node": "Select a Node",
|
||||
"select_a_node_description": "Choose a node from the dropdown above to view and manage its tags.",
|
||||
"entity_added": "Tag added successfully",
|
||||
"entity_updated": "Tag updated successfully",
|
||||
"entity_moved": "Tag moved successfully",
|
||||
"entity_deleted": "Tag deleted successfully",
|
||||
"all_entities_deleted": "All tags deleted successfully",
|
||||
"copied_entities": "Copied {{copied}} tag(s), skipped {{skipped}}"
|
||||
},
|
||||
"footer": {
|
||||
|
||||
417
src/meshcore_hub/web/static/locales/languages.md
Normal file
417
src/meshcore_hub/web/static/locales/languages.md
Normal file
@@ -0,0 +1,417 @@
|
||||
# Translation Reference Guide
|
||||
|
||||
This document provides a comprehensive reference for translating the MeshCore Hub web dashboard.
|
||||
|
||||
## File Structure
|
||||
|
||||
Translation files are JSON files named by language code (e.g., `en.json`, `es.json`, `fr.json`) and located in `/src/meshcore_hub/web/static/locales/`.
|
||||
|
||||
## Variable Interpolation
|
||||
|
||||
Many translations use `{{variable}}` syntax for dynamic content. These must be preserved exactly:
|
||||
|
||||
```json
|
||||
"total": "{{count}} total"
|
||||
```
|
||||
|
||||
When translating, keep the variable names unchanged:
|
||||
```json
|
||||
"total": "{{count}} au total" // French example
|
||||
```
|
||||
|
||||
## Translation Sections
|
||||
|
||||
### 1. `entities`
|
||||
|
||||
Core entity names used throughout the application. These are referenced by other translations for composition.
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `home` | Home | Homepage/breadcrumb navigation |
|
||||
| `dashboard` | Dashboard | Main dashboard page |
|
||||
| `nodes` | Nodes | Mesh network nodes (plural) |
|
||||
| `node` | Node | Single mesh network node |
|
||||
| `node_detail` | Node Detail | Node details page |
|
||||
| `advertisements` | Advertisements | Network advertisements (plural) |
|
||||
| `advertisement` | Advertisement | Single advertisement |
|
||||
| `messages` | Messages | Network messages (plural) |
|
||||
| `message` | Message | Single message |
|
||||
| `map` | Map | Network map page |
|
||||
| `members` | Members | Network members (plural) |
|
||||
| `member` | Member | Single network member |
|
||||
| `admin` | Admin | Admin panel |
|
||||
| `tags` | Tags | Node metadata tags (plural) |
|
||||
| `tag` | Tag | Single tag |
|
||||
|
||||
**Usage:** These are used with composite patterns. For example, `t('common.add_entity', { entity: t('entities.node') })` produces "Add Node".
|
||||
|
||||
### 2. `common`
|
||||
|
||||
Reusable patterns and UI elements used across multiple pages.
|
||||
|
||||
#### Actions
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `filter` | Filter | Filter button/action |
|
||||
| `clear` | Clear | Clear action |
|
||||
| `clear_filters` | Clear Filters | Reset all filters |
|
||||
| `search` | Search | Search button/action |
|
||||
| `cancel` | Cancel | Cancel button in dialogs |
|
||||
| `delete` | Delete | Delete button |
|
||||
| `edit` | Edit | Edit button |
|
||||
| `move` | Move | Move button |
|
||||
| `save` | Save | Save button |
|
||||
| `save_changes` | Save Changes | Save changes button |
|
||||
| `add` | Add | Add button |
|
||||
| `close` | close | Close button (lowercase for accessibility) |
|
||||
| `sign_in` | Sign In | Authentication sign in |
|
||||
| `sign_out` | Sign Out | Authentication sign out |
|
||||
| `go_home` | Go Home | Return to homepage button |
|
||||
|
||||
#### Composite Patterns with Entity
|
||||
|
||||
These patterns use `{{entity}}` variable - the entity name is provided dynamically:
|
||||
|
||||
| Key | English | Example Output |
|
||||
|-----|---------|----------------|
|
||||
| `add_entity` | Add {{entity}} | "Add Node", "Add Tag" |
|
||||
| `add_new_entity` | Add New {{entity}} | "Add New Member" |
|
||||
| `edit_entity` | Edit {{entity}} | "Edit Tag" |
|
||||
| `delete_entity` | Delete {{entity}} | "Delete Member" |
|
||||
| `delete_all_entity` | Delete All {{entity}} | "Delete All Tags" |
|
||||
| `move_entity` | Move {{entity}} | "Move Tag" |
|
||||
| `move_entity_to_another_node` | Move {{entity}} to Another Node | "Move Tag to Another Node" |
|
||||
| `copy_entity` | Copy {{entity}} | "Copy Tags" |
|
||||
| `copy_all_entity_to_another_node` | Copy All {{entity}} to Another Node | "Copy All Tags to Another Node" |
|
||||
| `view_entity` | View {{entity}} | "View Node" |
|
||||
| `recent_entity` | Recent {{entity}} | "Recent Advertisements" |
|
||||
| `total_entity` | Total {{entity}} | "Total Nodes" |
|
||||
| `all_entity` | All {{entity}} | "All Messages" |
|
||||
|
||||
#### Empty State Patterns
|
||||
|
||||
These patterns indicate when data is absent. Use `{{entity}}` in lowercase (e.g., "nodes", not "Nodes"):
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `no_entity_found` | No {{entity}} found | Search/filter returned no results |
|
||||
| `no_entity_recorded` | No {{entity}} recorded | No historical records exist |
|
||||
| `no_entity_defined` | No {{entity}} defined | No configuration/definitions exist |
|
||||
| `no_entity_in_database` | No {{entity}} in database | Database is empty |
|
||||
| `no_entity_configured` | No {{entity}} configured | System not configured |
|
||||
| `no_entity_yet` | No {{entity}} yet | Empty state, expecting data later |
|
||||
| `entity_not_found_details` | {{entity}} not found: {{details}} | Specific item not found with details |
|
||||
| `page_not_found` | Page not found | 404 error message |
|
||||
|
||||
#### Confirmation Patterns
|
||||
|
||||
Used in delete/move dialogs. Variables: `{{entity}}`, `{{name}}`, `{{count}}`:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `delete_entity_confirm` | Are you sure you want to delete {{entity}} <strong>{{name}}</strong>? | Single item delete confirmation |
|
||||
| `delete_all_entity_confirm` | Are you sure you want to delete all {{count}} {{entity}} from <strong>{{name}}</strong>? | Bulk delete confirmation |
|
||||
| `cannot_be_undone` | This action cannot be undone. | Warning in delete dialogs |
|
||||
|
||||
#### Success Messages
|
||||
|
||||
Toast/flash messages after successful operations:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `entity_added_success` | {{entity}} added successfully | After creating new item |
|
||||
| `entity_updated_success` | {{entity}} updated successfully | After updating item |
|
||||
| `entity_deleted_success` | {{entity}} deleted successfully | After deleting item |
|
||||
| `entity_moved_success` | {{entity}} moved successfully | After moving tag to another node |
|
||||
| `all_entity_deleted_success` | All {{entity}} deleted successfully | After bulk delete |
|
||||
| `copy_all_entity_description` | Copy all {{count}} {{entity}} from <strong>{{name}}</strong> to another node. | Copy operation description |
|
||||
|
||||
#### Navigation & Status
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `previous` | Previous | Pagination previous |
|
||||
| `next` | Next | Pagination next |
|
||||
| `loading` | Loading... | Loading indicator |
|
||||
| `error` | Error | Error state |
|
||||
| `failed_to_load_page` | Failed to load page | Page load error |
|
||||
|
||||
#### Counts & Metrics
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `total` | {{count}} total | Total count display |
|
||||
| `shown` | {{count}} shown | Filtered count display |
|
||||
| `count_entity` | {{count}} {{entity}} | Generic count with entity |
|
||||
|
||||
#### Form Fields & Labels
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `type` | Type | Type field/column header |
|
||||
| `name` | Name | Name field/column header |
|
||||
| `key` | Key | Key field (for tags) |
|
||||
| `value` | Value | Value field (for tags) |
|
||||
| `time` | Time | Time column header |
|
||||
| `actions` | Actions | Actions column header |
|
||||
| `updated` | Updated | Last updated timestamp |
|
||||
| `view_details` | View Details | View details link |
|
||||
| `all_types` | All Types | "All types" filter option |
|
||||
| `node_type` | Node Type | Node type field |
|
||||
| `show` | Show | Show/display action |
|
||||
| `search_placeholder` | Search by name, ID, or public key... | Search input placeholder |
|
||||
| `contact` | Contact | Contact information field |
|
||||
| `description` | Description | Description field |
|
||||
| `callsign` | Callsign | Amateur radio callsign field |
|
||||
| `tags` | Tags | Tags label/header |
|
||||
| `last_seen` | Last Seen | Last seen timestamp (table header) |
|
||||
| `first_seen_label` | First seen: | First seen label (inline with colon) |
|
||||
| `last_seen_label` | Last seen: | Last seen label (inline with colon) |
|
||||
| `location` | Location | Geographic location |
|
||||
| `public_key` | Public Key | Node public key |
|
||||
| `received` | Received | Received timestamp |
|
||||
| `received_by` | Received By | Received by field |
|
||||
| `receivers` | Receivers | Multiple receivers |
|
||||
| `from` | From | Message sender |
|
||||
| `unnamed` | Unnamed | Fallback for unnamed items |
|
||||
| `unnamed_node` | Unnamed Node | Fallback for unnamed nodes |
|
||||
|
||||
**Note:** Keys ending in `_label` have colons and are used inline. Keys without `_label` are for table headers.
|
||||
|
||||
### 3. `links`
|
||||
|
||||
Platform and external link labels:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `website` | Website | Website link label |
|
||||
| `github` | GitHub | GitHub link label (preserve capitalization) |
|
||||
| `discord` | Discord | Discord link label |
|
||||
| `youtube` | YouTube | YouTube link label (preserve capitalization) |
|
||||
| `profile` | Profile | Radio profile label |
|
||||
|
||||
### 4. `time`
|
||||
|
||||
Time-related labels and formats:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `days_ago` | {{count}}d ago | Days ago (abbreviated) |
|
||||
| `hours_ago` | {{count}}h ago | Hours ago (abbreviated) |
|
||||
| `minutes_ago` | {{count}}m ago | Minutes ago (abbreviated) |
|
||||
| `less_than_minute` | <1m ago | Less than one minute ago |
|
||||
| `last_7_days` | Last 7 days | Last 7 days label |
|
||||
| `per_day_last_7_days` | Per day (last 7 days) | Per day over last 7 days |
|
||||
| `over_time_last_7_days` | Over time (last 7 days) | Over time last 7 days |
|
||||
| `activity_per_day_last_7_days` | Activity per day (last 7 days) | Activity chart label |
|
||||
|
||||
### 5. `node_types`
|
||||
|
||||
Mesh network node type labels:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `chat` | Chat | Chat node type |
|
||||
| `repeater` | Repeater | Repeater/relay node type |
|
||||
| `room` | Room | Room/group node type |
|
||||
| `unknown` | Unknown | Unknown node type fallback |
|
||||
|
||||
### 6. `home`
|
||||
|
||||
Homepage-specific content:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `welcome_default` | Welcome to the {{network_name}} mesh network dashboard. Monitor network activity, view connected nodes, and explore message history. | Default welcome message |
|
||||
| `all_discovered_nodes` | All discovered nodes | Stat description |
|
||||
| `network_info` | Network Info | Network info card title |
|
||||
| `network_activity` | Network Activity | Activity chart title |
|
||||
| `meshcore_attribution` | Our local off-grid mesh network is made possible by | Attribution text before MeshCore logo |
|
||||
| `frequency` | Frequency | Radio frequency label |
|
||||
| `bandwidth` | Bandwidth | Radio bandwidth label |
|
||||
| `spreading_factor` | Spreading Factor | LoRa spreading factor label |
|
||||
| `coding_rate` | Coding Rate | LoRa coding rate label |
|
||||
| `tx_power` | TX Power | Transmit power label |
|
||||
| `advertisements` | Advertisements | Homepage stat label |
|
||||
| `messages` | Messages | Homepage stat label |
|
||||
|
||||
**Note:** MeshCore tagline "Connecting people and things, without using the internet" is hardcoded in English and should not be translated (trademark).
|
||||
|
||||
### 7. `dashboard`
|
||||
|
||||
Dashboard page content:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `all_discovered_nodes` | All discovered nodes | Stat label |
|
||||
| `recent_channel_messages` | Recent Channel Messages | Recent messages card title |
|
||||
| `channel` | Channel {{number}} | Channel label with number |
|
||||
|
||||
### 8. `nodes`
|
||||
|
||||
Node-specific labels:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `scan_to_add` | Scan to add as contact | QR code instruction |
|
||||
|
||||
### 9. `advertisements`
|
||||
|
||||
Currently empty - advertisements page uses common patterns.
|
||||
|
||||
### 10. `messages`
|
||||
|
||||
Message type labels:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `type_direct` | Direct | Direct message type |
|
||||
| `type_channel` | Channel | Channel message type |
|
||||
| `type_contact` | Contact | Contact message type |
|
||||
| `type_public` | Public | Public message type |
|
||||
|
||||
### 11. `map`
|
||||
|
||||
Map page content:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `show_labels` | Show Labels | Toggle to show node labels |
|
||||
| `infrastructure_only` | Infrastructure Only | Toggle to show only infrastructure nodes |
|
||||
| `legend` | Legend: | Map legend header |
|
||||
| `infrastructure` | Infrastructure | Infrastructure node category |
|
||||
| `public` | Public | Public node category |
|
||||
| `nodes_on_map` | {{count}} nodes on map | Status text with coordinates |
|
||||
| `nodes_none_have_coordinates` | {{count}} nodes (none have coordinates) | Status text without coordinates |
|
||||
| `gps_description` | Nodes are placed on the map based on GPS coordinates from node reports or manual tags. | Map data source explanation |
|
||||
| `owner` | Owner: | Node owner label |
|
||||
| `role` | Role: | Member role label |
|
||||
| `select_destination_node` | -- Select destination node -- | Dropdown placeholder |
|
||||
|
||||
### 12. `members`
|
||||
|
||||
Members page content:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `empty_state_description` | To display network members, create a members.yaml file in your seed directory. | Empty state instructions |
|
||||
| `members_file_format` | Members File Format | Documentation section title |
|
||||
| `members_file_description` | Create a YAML file at <code>$SEED_HOME/members.yaml</code> with the following structure: | File creation instructions |
|
||||
| `members_import_instructions` | Run <code>meshcore-hub collector seed</code> to import members.<br/>To associate nodes with members, add a <code>member_id</code> tag to nodes in <code>node_tags.yaml</code>. | Import instructions (HTML allowed) |
|
||||
|
||||
### 13. `not_found`
|
||||
|
||||
404 page content:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `description` | The page you're looking for doesn't exist or has been moved. | 404 description |
|
||||
|
||||
### 14. `custom_page`
|
||||
|
||||
Custom markdown page errors:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `failed_to_load` | Failed to load page | Page load error |
|
||||
|
||||
### 15. `admin`
|
||||
|
||||
Admin panel content:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `access_denied` | Access Denied | Access denied heading |
|
||||
| `admin_not_enabled` | The admin interface is not enabled. | Admin disabled message |
|
||||
| `admin_enable_hint` | Set <code>WEB_ADMIN_ENABLED=true</code> to enable admin features. | Configuration hint (HTML allowed) |
|
||||
| `auth_required` | Authentication Required | Auth required heading |
|
||||
| `auth_required_description` | You must sign in to access the admin interface. | Auth required description |
|
||||
| `welcome` | Welcome to the admin panel. | Admin welcome message |
|
||||
| `members_description` | Manage network members and operators. | Members card description |
|
||||
| `tags_description` | Manage custom tags and metadata for network nodes. | Tags card description |
|
||||
|
||||
### 16. `admin_members`
|
||||
|
||||
Admin members page:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `network_members` | Network Members ({{count}}) | Page heading with count |
|
||||
| `member_id` | Member ID | Member ID field label |
|
||||
| `member_id_hint` | Unique identifier (letters, numbers, underscore) | Member ID input hint |
|
||||
| `empty_state_hint` | Click "Add Member" to create the first member. | Empty state hint |
|
||||
|
||||
**Note:** Confirmation and success messages use `common.*` patterns.
|
||||
|
||||
### 17. `admin_node_tags`
|
||||
|
||||
Admin node tags page:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `select_node` | Select Node | Section heading |
|
||||
| `select_node_placeholder` | -- Select a node -- | Dropdown placeholder |
|
||||
| `load_tags` | Load Tags | Load button |
|
||||
| `move_warning` | This will move the tag from the current node to the destination node. | Move operation warning |
|
||||
| `copy_all` | Copy All | Copy all button |
|
||||
| `copy_all_info` | Tags that already exist on the destination node will be skipped. Original tags remain on this node. | Copy operation info |
|
||||
| `delete_all` | Delete All | Delete all button |
|
||||
| `delete_all_warning` | All tags will be permanently deleted. | Delete all warning |
|
||||
| `destination_node` | Destination Node | Destination node field |
|
||||
| `tag_key` | Tag Key | Tag key field |
|
||||
| `for_this_node` | for this node | Suffix for "No tags found for this node" |
|
||||
| `empty_state_hint` | Add a new tag below. | Empty state hint |
|
||||
| `select_a_node` | Select a Node | Empty state heading |
|
||||
| `select_a_node_description` | Choose a node from the dropdown above to view and manage its tags. | Empty state description |
|
||||
| `copied_entities` | Copied {{copied}} tag(s), skipped {{skipped}} | Copy operation result message |
|
||||
|
||||
**Note:** Titles, confirmations, and success messages use `common.*` patterns.
|
||||
|
||||
### 18. `footer`
|
||||
|
||||
Footer content:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `powered_by` | Powered by | "Powered by" attribution |
|
||||
|
||||
## Translation Tips
|
||||
|
||||
1. **Preserve HTML tags:** Some strings contain `<code>`, `<strong>`, or `<br/>` tags - keep these intact.
|
||||
|
||||
2. **Preserve variables:** Keep `{{variable}}` placeholders exactly as-is, only translate surrounding text.
|
||||
|
||||
3. **Entity composition:** Many translations reference `entities.*` keys. When translating entities, consider how they'll work in composite patterns (e.g., "Add {{entity}}" should make sense with "Node", "Tag", etc.).
|
||||
|
||||
4. **Capitalization:**
|
||||
- Entity names should follow your language's capitalization rules for UI elements
|
||||
- Inline labels (with colons) typically use sentence case
|
||||
- Table headers typically use title case
|
||||
- Action buttons can vary by language convention
|
||||
|
||||
5. **Colons:** Keys ending in `_label` include colons in English. Adjust punctuation to match your language's conventions for inline labels.
|
||||
|
||||
6. **Plurals:** Some languages have complex plural rules. You may need to add plural variants for `{{count}}` patterns. Consult the i18n library documentation for plural support.
|
||||
|
||||
7. **Length:** UI space is limited. Try to keep translations concise, especially for button labels and table headers.
|
||||
|
||||
8. **Brand names:** Preserve "MeshCore", "GitHub", "YouTube" capitalization.
|
||||
|
||||
## Testing Your Translation
|
||||
|
||||
1. Create your translation file: `locales/xx.json` (where `xx` is your language code)
|
||||
2. Copy the structure from `en.json`
|
||||
3. Translate all values, preserving all variables and HTML
|
||||
4. Test in the application by setting the language
|
||||
5. Check all pages for:
|
||||
- Text overflow/truncation
|
||||
- Proper variable interpolation
|
||||
- Natural phrasing in context
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you're unsure about the context of a translation key, check:
|
||||
1. The "Context" column in this reference
|
||||
2. The JavaScript files in `/src/meshcore_hub/web/static/js/spa/pages/`
|
||||
3. Grep for the key: `grep -r "t('section.key')" src/`
|
||||
@@ -118,6 +118,15 @@ class TestEnJsonCompleteness:
|
||||
for section in required:
|
||||
assert section in data, f"Missing section: {section}"
|
||||
|
||||
def test_common_no_entity_patterns(self):
|
||||
"""Test that common 'no entity' patterns exist."""
|
||||
assert t("common.no_entity_found", entity="test") == "No test found"
|
||||
assert t("common.no_entity_recorded", entity="test") == "No test recorded"
|
||||
assert t("common.no_entity_defined", entity="test") == "No test defined"
|
||||
assert t("common.no_entity_configured", entity="test") == "No test configured"
|
||||
assert t("common.no_entity_yet", entity="test") == "No test yet"
|
||||
assert t("common.page_not_found") == "Page not found"
|
||||
|
||||
def test_entity_keys(self):
|
||||
"""Entity keys are all present."""
|
||||
assert t("entities.home") != "entities.home"
|
||||
|
||||
Reference in New Issue
Block a user