Skip to content

Commit a058f52

Browse files
authored
[i18n] Add /i18n endpoint to provide all custom node translations (#6558)
* [i18n] Add /i18n endpoint to provide all custom node translations * Sort glob result for deterministic ordering * Update comment
1 parent d6bbe8c commit a058f52

File tree

4 files changed

+321
-17
lines changed

4 files changed

+321
-17
lines changed

app/custom_node_manager.py

Lines changed: 110 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,93 @@
44
import folder_paths
55
import glob
66
from aiohttp import web
7+
import json
8+
import logging
9+
from functools import lru_cache
10+
11+
from utils.json_util import merge_json_recursive
12+
13+
14+
# Extra locale files to load into main.json
15+
EXTRA_LOCALE_FILES = [
16+
"nodeDefs.json",
17+
"commands.json",
18+
"settings.json",
19+
]
20+
21+
22+
def safe_load_json_file(file_path: str) -> dict:
23+
if not os.path.exists(file_path):
24+
return {}
25+
26+
try:
27+
with open(file_path, "r", encoding="utf-8") as f:
28+
return json.load(f)
29+
except json.JSONDecodeError:
30+
logging.error(f"Error loading {file_path}")
31+
return {}
32+
733

834
class CustomNodeManager:
9-
"""
10-
Placeholder to refactor the custom node management features from ComfyUI-Manager.
11-
Currently it only contains the custom workflow templates feature.
12-
"""
35+
@lru_cache(maxsize=1)
36+
def build_translations(self):
37+
"""Load all custom nodes translations during initialization. Translations are
38+
expected to be loaded from `locales/` folder.
39+
40+
The folder structure is expected to be the following:
41+
- custom_nodes/
42+
- custom_node_1/
43+
- locales/
44+
- en/
45+
- main.json
46+
- commands.json
47+
- settings.json
48+
49+
returned translations are expected to be in the following format:
50+
{
51+
"en": {
52+
"nodeDefs": {...},
53+
"commands": {...},
54+
"settings": {...},
55+
...{other main.json keys}
56+
}
57+
}
58+
"""
59+
60+
translations = {}
61+
62+
for folder in folder_paths.get_folder_paths("custom_nodes"):
63+
# Sort glob results for deterministic ordering
64+
for custom_node_dir in sorted(glob.glob(os.path.join(folder, "*/"))):
65+
locales_dir = os.path.join(custom_node_dir, "locales")
66+
if not os.path.exists(locales_dir):
67+
continue
68+
69+
for lang_dir in glob.glob(os.path.join(locales_dir, "*/")):
70+
lang_code = os.path.basename(os.path.dirname(lang_dir))
71+
72+
if lang_code not in translations:
73+
translations[lang_code] = {}
74+
75+
# Load main.json
76+
main_file = os.path.join(lang_dir, "main.json")
77+
node_translations = safe_load_json_file(main_file)
78+
79+
# Load extra locale files
80+
for extra_file in EXTRA_LOCALE_FILES:
81+
extra_file_path = os.path.join(lang_dir, extra_file)
82+
key = extra_file.split(".")[0]
83+
json_data = safe_load_json_file(extra_file_path)
84+
if json_data:
85+
node_translations[key] = json_data
86+
87+
if node_translations:
88+
translations[lang_code] = merge_json_recursive(
89+
translations[lang_code], node_translations
90+
)
91+
92+
return translations
93+
1394
def add_routes(self, routes, webapp, loadedModules):
1495

1596
@routes.get("/workflow_templates")
@@ -18,17 +99,36 @@ async def get_workflow_templates(request):
1899
files = [
19100
file
20101
for folder in folder_paths.get_folder_paths("custom_nodes")
21-
for file in glob.glob(os.path.join(folder, '*/example_workflows/*.json'))
102+
for file in glob.glob(
103+
os.path.join(folder, "*/example_workflows/*.json")
104+
)
22105
]
23-
workflow_templates_dict = {} # custom_nodes folder name -> example workflow names
106+
workflow_templates_dict = (
107+
{}
108+
) # custom_nodes folder name -> example workflow names
24109
for file in files:
25-
custom_nodes_name = os.path.basename(os.path.dirname(os.path.dirname(file)))
110+
custom_nodes_name = os.path.basename(
111+
os.path.dirname(os.path.dirname(file))
112+
)
26113
workflow_name = os.path.splitext(os.path.basename(file))[0]
27-
workflow_templates_dict.setdefault(custom_nodes_name, []).append(workflow_name)
114+
workflow_templates_dict.setdefault(custom_nodes_name, []).append(
115+
workflow_name
116+
)
28117
return web.json_response(workflow_templates_dict)
29118

30119
# Serve workflow templates from custom nodes.
31120
for module_name, module_dir in loadedModules:
32-
workflows_dir = os.path.join(module_dir, 'example_workflows')
121+
workflows_dir = os.path.join(module_dir, "example_workflows")
33122
if os.path.exists(workflows_dir):
34-
webapp.add_routes([web.static('/api/workflow_templates/' + module_name, workflows_dir)])
123+
webapp.add_routes(
124+
[
125+
web.static(
126+
"/api/workflow_templates/" + module_name, workflows_dir
127+
)
128+
]
129+
)
130+
131+
@routes.get("/i18n")
132+
async def get_i18n(request):
133+
"""Returns translations from all custom nodes' locales folders."""
134+
return web.json_response(self.build_translations())

tests-unit/app_test/custom_node_manager_test.py

Lines changed: 114 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,146 @@
22
from aiohttp import web
33
from unittest.mock import patch
44
from app.custom_node_manager import CustomNodeManager
5+
import json
56

67
pytestmark = (
78
pytest.mark.asyncio
89
) # This applies the asyncio mark to all test functions in the module
910

11+
1012
@pytest.fixture
1113
def custom_node_manager():
1214
return CustomNodeManager()
1315

16+
1417
@pytest.fixture
1518
def app(custom_node_manager):
1619
app = web.Application()
1720
routes = web.RouteTableDef()
18-
custom_node_manager.add_routes(routes, app, [("ComfyUI-TestExtension1", "ComfyUI-TestExtension1")])
21+
custom_node_manager.add_routes(
22+
routes, app, [("ComfyUI-TestExtension1", "ComfyUI-TestExtension1")]
23+
)
1924
app.add_routes(routes)
2025
return app
2126

27+
2228
async def test_get_workflow_templates(aiohttp_client, app, tmp_path):
2329
client = await aiohttp_client(app)
2430
# Setup temporary custom nodes file structure with 1 workflow file
2531
custom_nodes_dir = tmp_path / "custom_nodes"
26-
example_workflows_dir = custom_nodes_dir / "ComfyUI-TestExtension1" / "example_workflows"
32+
example_workflows_dir = (
33+
custom_nodes_dir / "ComfyUI-TestExtension1" / "example_workflows"
34+
)
2735
example_workflows_dir.mkdir(parents=True)
2836
template_file = example_workflows_dir / "workflow1.json"
29-
template_file.write_text('')
37+
template_file.write_text("")
3038

31-
with patch('folder_paths.folder_names_and_paths', {
32-
'custom_nodes': ([str(custom_nodes_dir)], None)
33-
}):
34-
response = await client.get('/workflow_templates')
39+
with patch(
40+
"folder_paths.folder_names_and_paths",
41+
{"custom_nodes": ([str(custom_nodes_dir)], None)},
42+
):
43+
response = await client.get("/workflow_templates")
3544
assert response.status == 200
3645
workflows_dict = await response.json()
3746
assert isinstance(workflows_dict, dict)
3847
assert "ComfyUI-TestExtension1" in workflows_dict
3948
assert isinstance(workflows_dict["ComfyUI-TestExtension1"], list)
4049
assert workflows_dict["ComfyUI-TestExtension1"][0] == "workflow1"
50+
51+
52+
async def test_build_translations_empty_when_no_locales(custom_node_manager, tmp_path):
53+
custom_nodes_dir = tmp_path / "custom_nodes"
54+
custom_nodes_dir.mkdir(parents=True)
55+
56+
with patch("folder_paths.get_folder_paths", return_value=[str(custom_nodes_dir)]):
57+
translations = custom_node_manager.build_translations()
58+
assert translations == {}
59+
60+
61+
async def test_build_translations_loads_all_files(custom_node_manager, tmp_path):
62+
# Setup test directory structure
63+
custom_nodes_dir = tmp_path / "custom_nodes" / "test-extension"
64+
locales_dir = custom_nodes_dir / "locales" / "en"
65+
locales_dir.mkdir(parents=True)
66+
67+
# Create test translation files
68+
main_content = {"title": "Test Extension"}
69+
(locales_dir / "main.json").write_text(json.dumps(main_content))
70+
71+
node_defs = {"node1": "Node 1"}
72+
(locales_dir / "nodeDefs.json").write_text(json.dumps(node_defs))
73+
74+
commands = {"cmd1": "Command 1"}
75+
(locales_dir / "commands.json").write_text(json.dumps(commands))
76+
77+
settings = {"setting1": "Setting 1"}
78+
(locales_dir / "settings.json").write_text(json.dumps(settings))
79+
80+
with patch(
81+
"folder_paths.get_folder_paths", return_value=[tmp_path / "custom_nodes"]
82+
):
83+
translations = custom_node_manager.build_translations()
84+
85+
assert translations == {
86+
"en": {
87+
"title": "Test Extension",
88+
"nodeDefs": {"node1": "Node 1"},
89+
"commands": {"cmd1": "Command 1"},
90+
"settings": {"setting1": "Setting 1"},
91+
}
92+
}
93+
94+
95+
async def test_build_translations_handles_invalid_json(custom_node_manager, tmp_path):
96+
# Setup test directory structure
97+
custom_nodes_dir = tmp_path / "custom_nodes" / "test-extension"
98+
locales_dir = custom_nodes_dir / "locales" / "en"
99+
locales_dir.mkdir(parents=True)
100+
101+
# Create valid main.json
102+
main_content = {"title": "Test Extension"}
103+
(locales_dir / "main.json").write_text(json.dumps(main_content))
104+
105+
# Create invalid JSON file
106+
(locales_dir / "nodeDefs.json").write_text("invalid json{")
107+
108+
with patch(
109+
"folder_paths.get_folder_paths", return_value=[tmp_path / "custom_nodes"]
110+
):
111+
translations = custom_node_manager.build_translations()
112+
113+
assert translations == {
114+
"en": {
115+
"title": "Test Extension",
116+
}
117+
}
118+
119+
120+
async def test_build_translations_merges_multiple_extensions(
121+
custom_node_manager, tmp_path
122+
):
123+
# Setup test directory structure for two extensions
124+
custom_nodes_dir = tmp_path / "custom_nodes"
125+
ext1_dir = custom_nodes_dir / "extension1" / "locales" / "en"
126+
ext2_dir = custom_nodes_dir / "extension2" / "locales" / "en"
127+
ext1_dir.mkdir(parents=True)
128+
ext2_dir.mkdir(parents=True)
129+
130+
# Create translation files for extension 1
131+
ext1_main = {"title": "Extension 1", "shared": "Original"}
132+
(ext1_dir / "main.json").write_text(json.dumps(ext1_main))
133+
134+
# Create translation files for extension 2
135+
ext2_main = {"description": "Extension 2", "shared": "Override"}
136+
(ext2_dir / "main.json").write_text(json.dumps(ext2_main))
137+
138+
with patch("folder_paths.get_folder_paths", return_value=[str(custom_nodes_dir)]):
139+
translations = custom_node_manager.build_translations()
140+
141+
assert translations == {
142+
"en": {
143+
"title": "Extension 1",
144+
"description": "Extension 2",
145+
"shared": "Override", # Second extension should override first
146+
}
147+
}

tests-unit/utils/json_util_test.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from utils.json_util import merge_json_recursive
2+
3+
4+
def test_merge_simple_dicts():
5+
base = {"a": 1, "b": 2}
6+
update = {"b": 3, "c": 4}
7+
expected = {"a": 1, "b": 3, "c": 4}
8+
assert merge_json_recursive(base, update) == expected
9+
10+
11+
def test_merge_nested_dicts():
12+
base = {"a": {"x": 1, "y": 2}, "b": 3}
13+
update = {"a": {"y": 4, "z": 5}}
14+
expected = {"a": {"x": 1, "y": 4, "z": 5}, "b": 3}
15+
assert merge_json_recursive(base, update) == expected
16+
17+
18+
def test_merge_lists():
19+
base = {"a": [1, 2], "b": 3}
20+
update = {"a": [3, 4]}
21+
expected = {"a": [1, 2, 3, 4], "b": 3}
22+
assert merge_json_recursive(base, update) == expected
23+
24+
25+
def test_merge_nested_lists():
26+
base = {"a": {"x": [1, 2]}}
27+
update = {"a": {"x": [3, 4]}}
28+
expected = {"a": {"x": [1, 2, 3, 4]}}
29+
assert merge_json_recursive(base, update) == expected
30+
31+
32+
def test_merge_mixed_types():
33+
base = {"a": [1, 2], "b": {"x": 1}}
34+
update = {"a": [3], "b": {"y": 2}}
35+
expected = {"a": [1, 2, 3], "b": {"x": 1, "y": 2}}
36+
assert merge_json_recursive(base, update) == expected
37+
38+
39+
def test_merge_overwrite_non_dict():
40+
base = {"a": 1}
41+
update = {"a": {"x": 2}}
42+
expected = {"a": {"x": 2}}
43+
assert merge_json_recursive(base, update) == expected
44+
45+
46+
def test_merge_empty_dicts():
47+
base = {}
48+
update = {"a": 1}
49+
expected = {"a": 1}
50+
assert merge_json_recursive(base, update) == expected
51+
52+
53+
def test_merge_none_values():
54+
base = {"a": None}
55+
update = {"a": {"x": 1}}
56+
expected = {"a": {"x": 1}}
57+
assert merge_json_recursive(base, update) == expected
58+
59+
60+
def test_merge_different_types():
61+
base = {"a": [1, 2]}
62+
update = {"a": "string"}
63+
expected = {"a": "string"}
64+
assert merge_json_recursive(base, update) == expected
65+
66+
67+
def test_merge_complex_nested():
68+
base = {"a": [1, 2], "b": {"x": [3, 4], "y": {"p": 1}}}
69+
update = {"a": [5], "b": {"x": [6], "y": {"q": 2}}}
70+
expected = {"a": [1, 2, 5], "b": {"x": [3, 4, 6], "y": {"p": 1, "q": 2}}}
71+
assert merge_json_recursive(base, update) == expected

utils/json_util.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
def merge_json_recursive(base, update):
2+
"""Recursively merge two JSON-like objects.
3+
- Dictionaries are merged recursively
4+
- Lists are concatenated
5+
- Other types are overwritten by the update value
6+
7+
Args:
8+
base: Base JSON-like object
9+
update: Update JSON-like object to merge into base
10+
11+
Returns:
12+
Merged JSON-like object
13+
"""
14+
if not isinstance(base, dict) or not isinstance(update, dict):
15+
if isinstance(base, list) and isinstance(update, list):
16+
return base + update
17+
return update
18+
19+
merged = base.copy()
20+
for key, value in update.items():
21+
if key in merged:
22+
merged[key] = merge_json_recursive(merged[key], value)
23+
else:
24+
merged[key] = value
25+
26+
return merged

0 commit comments

Comments
 (0)