Skip to content

feat: enhance recursive translations with shortcuts and dot notation #110

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,16 @@ your extensions:

### Translation File Structure

Each extension can have its own `translations.yml` file located at
`src/extensions/EXT_NAME/translations.yml`. This file follows a specific structure:
Each extension can have translations in one of two formats:

1. **Single File**: A `translations.yml` file located at `src/extensions/EXT_NAME/translations.yml`
2. **Folder Structure**: A `translations/` folder located at `src/extensions/EXT_NAME/translations/`

If both exist, the single file takes precedence for backward compatibility.

#### Single File Format

The traditional `translations.yml` file follows this structure:

```yaml
commands:
Expand Down Expand Up @@ -279,6 +287,61 @@ strings:
> `config["translations"]`. This section is for general strings not directly tied to
> specific commands.

#### Folder Structure Format

The new folder structure allows for better organization of translations, especially for extensions with many commands and strings. Here's how it works:

```
src/extensions/my_extension/
├── __init__.py
└── translations/
├── commands/
│ ├── hello.yaml
│ └── goodbye.yaml
├── strings/
│ ├── general.yaml
│ └── errors.yaml
└── c.help/ # Shortcut for commands.help/
└── subcommand.yml
```

**Folder Name Features:**
- **Dot Notation**: Dots in folder names create nested structures (e.g., `strings.general` becomes a nested key)
- **Shortcuts**: Use `c.`, `t.`, and `s.` as shortcuts for `commands.`, `translations.`, and `strings.` respectively (only in folder names, not in YAML files)
- **File Extensions**: Both `.yml` and `.yaml` files are supported

**Example file contents:**

**commands/hello.yaml:**
```yaml
name:
en-US: hello
fr: bonjour
description:
en-US: Say hello
fr: Dire bonjour
strings:
response:
en-US: Hello, {user}!
fr: Bonjour, {user}!
```

**strings/general.yaml:**
```yaml
welcome:
en-US: Welcome to the bot!
fr: Bienvenue dans le bot!
help:
en-US: Need help? Use /help
fr: Besoin d'aide? Utilisez /help
```

**How it maps:**
- Files in `commands/` become command definitions
- Files in `strings/` become general strings with dot-notation keys (e.g., `strings.general.welcome`)
- Folder shortcuts expand: `c.help/` becomes `commands.help/`
- Nested folders create nested keys: `strings.errors.not_found`

### Nested Commands and Sub-commands

For command groups and sub-commands, you can nest the structure using the `commands`
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
# SPDX-License-Identifier: MIT

from .classes import apply_locale
from .utils import apply, load_translation
from .utils import apply, load_translation, load_translation_folder

__all__ = ["apply", "apply_locale", "load_translation"]
__all__ = ["apply", "apply_locale", "load_translation", "load_translation_folder"]
138 changes: 138 additions & 0 deletions src/i18n/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

from typing import TYPE_CHECKING, TypeVar

import os
from pathlib import Path

import discord
import yaml
from discord.ext import commands as prefixed
Expand Down Expand Up @@ -182,6 +185,141 @@ def load_translation(path: str) -> ExtensionTranslation:
return ExtensionTranslation(**data)


def _expand_folder_name(folder_name: str) -> str:
"""Expand folder name shortcuts and handle dots as path separators.

Args:
----
folder_name (str): The folder name to expand.

Returns:
-------
str: The expanded folder name with shortcuts replaced and dots converted to path separators.
"""
# Handle shortcuts (only in folder names)
shortcuts = {
'c.': 'commands.',
't.': 'translations.',
's.': 'strings.'
}

for shortcut, expansion in shortcuts.items():
if folder_name.startswith(shortcut):
folder_name = expansion + folder_name[len(shortcut):]
break

# Convert dots to path separators for nested structure
return folder_name.replace('.', '.')
Copy link
Preview

Copilot AI May 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _expand_folder_name function's replacement operation currently has no effect, as it replaces '.' with '.'. If the intent is to convert dots to a different separator for nested keys, please update the replacement accordingly (e.g., using a different character or string).

Suggested change
return folder_name.replace('.', '.')
return folder_name.replace('.', '/')

Copilot uses AI. Check for mistakes.



def _load_recursive_translations(folder_path: Path, prefix: str = "") -> dict:
"""Recursively load translations from a folder structure.

Args:
----
folder_path (Path): The path to the translations folder.
prefix (str): The current prefix for nested keys.

Returns:
-------
dict: A flattened dictionary with dot-notation keys for nested structure.

"""
result = {}

if not folder_path.exists() or not folder_path.is_dir():
return result

for item in folder_path.iterdir():
if item.is_file() and item.suffix in ('.yml', '.yaml'):
# Load YAML file content
try:
with open(item, encoding="utf-8") as f:
file_data = yaml.safe_load(f)
if file_data is not None:
# Use filename without extension as key
key = item.stem
full_key = f"{prefix}.{key}" if prefix else key

# For command files, keep the entire structure intact
# For string files, flatten with dot notation
if prefix.startswith('commands.') or (prefix == '' and key == 'commands'):
# This is a command file - keep structure intact
result[full_key] = file_data
elif isinstance(file_data, dict):
# This is a strings file - flatten with dot notation
for sub_key, sub_value in file_data.items():
nested_key = f"{full_key}.{sub_key}"
result[nested_key] = sub_value
else:
result[full_key] = file_data
except yaml.YAMLError as e:
logger.warning(f"Error loading translation file {item}: {e}")
elif item.is_dir() and not item.name.startswith('.'):
# Expand folder name with shortcuts and dot notation
expanded_name = _expand_folder_name(item.name)
new_prefix = f"{prefix}.{expanded_name}" if prefix else expanded_name
subdir_data = _load_recursive_translations(item, new_prefix)
result.update(subdir_data)

return result


def load_translation_folder(folder_path: str) -> ExtensionTranslation:
"""Load translations from a folder structure.

Args:
----
folder_path (str): The path to the translations folder.

Returns:
-------
ExtensionTranslation: The loaded translation with nested structure.

Raises:
------
yaml.YAMLError: If any YAML file is not valid.

"""
path = Path(folder_path)
flattened_data = _load_recursive_translations(path)

# Separate commands and strings based on the structure
result_data = {}
commands_data = {}
strings_data = {}

for key, value in flattened_data.items():
if key.startswith('commands.'):
# Extract command structure
parts = key.split('.', 2) # ['commands', 'command_name', 'rest']
if len(parts) >= 2:
command_name = parts[1]
if command_name not in commands_data:
commands_data[command_name] = {}

if len(parts) == 2:
# Direct command data (file named after command group)
commands_data[command_name].update(value if isinstance(value, dict) else {})
else:
# This is a file inside a command folder (e.g., commands.admin.ban)
# The file name becomes a sub-command
subcommand_name = parts[2]
if 'commands' not in commands_data[command_name]:
commands_data[command_name]['commands'] = {}
commands_data[command_name]['commands'][subcommand_name] = value
else:
# Everything else goes to strings with dot notation
strings_data[key] = value

if commands_data:
result_data['commands'] = commands_data
if strings_data:
result_data['strings'] = strings_data

return ExtensionTranslation(**result_data)


def apply(
bot: "custom.Bot",
translations: list[ExtensionTranslation],
Expand Down
19 changes: 16 additions & 3 deletions src/start.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,25 @@ def load_extensions() -> tuple[

logger.info(f"Loading extension {name}")
translation: ExtensionTranslation | None = None
if (translation_path := (extension / "translations.yml")).exists():

# Check for translations.yml file first (backward compatibility)
translation_file = extension / "translations.yml"
translation_folder = extension / "translations"

if translation_file.exists():
try:
translation = i18n.load_translation(str(translation_path))
translation = i18n.load_translation(str(translation_file))
translations.append(translation)
logger.debug(f"Loaded translation file for extension {name}")
except yaml.YAMLError as e:
logger.error(f"Error loading translation {translation_path}: {e}")
logger.error(f"Error loading translation file {translation_file}: {e}")
elif translation_folder.exists() and translation_folder.is_dir():
try:
translation = i18n.load_translation_folder(str(translation_folder))
translations.append(translation)
logger.debug(f"Loaded translation folder for extension {name}")
except yaml.YAMLError as e:
logger.error(f"Error loading translation folder {translation_folder}: {e}")
else:
logger.warning(f"No translation found for extension {name}")

Expand Down
Loading