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:
Louis King
2026-02-13 22:19:37 +00:00
parent b2f8e18f13
commit 5272a72647
18 changed files with 641 additions and 85 deletions

View File

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

View File

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

View File

@@ -8,6 +8,23 @@ Python 3.14+ platform for managing and orchestrating MeshCore mesh networks.
![MeshCore Hub Web Dashboard](docs/images/web.png)
## 🌍 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": {

View 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/`

View File

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