Coverage for ckanext/udc/i18n.py: 58%
183 statements
« prev ^ index » next coverage.py v7.7.1, created at 2026-01-19 23:48 +0000
« prev ^ index » next coverage.py v7.7.1, created at 2026-01-19 23:48 +0000
1from __future__ import annotations
2import json
3import logging
4from typing import Dict, List, Any
5import ckan.plugins.toolkit as tk
6from ckan.lib.navl.dictization_functions import Missing, missing
8log = logging.getLogger(__name__)
11def _is_missing(v):
12 return v is missing or isinstance(v, Missing) or v is None or v == ""
15def udc_lang_object(value, context):
16 """
17 Validate a localized string object: {lang: "text", ...}
18 Accepts missing/None/'' (returns None).
19 Accepts a plain string by coercing to {default_lang: string}.
20 """
21 if _is_missing(value):
22 return None
23 if isinstance(value, str):
24 # Coerce a bare string to the default locale
25 default_lang = tk.config.get("ckan.locale_default", "en") or "en"
26 return {default_lang: value}
27 if isinstance(value, (int, float, bool)):
28 default_lang = tk.config.get("ckan.locale_default", "en") or "en"
29 return {default_lang: str(value)}
30 if not isinstance(value, dict):
31 raise tk.Invalid("Expected an object of {lang: string} for localized text.")
33 default_lang = tk.config.get("ckan.locale_default", "en") or "en"
35 # Track if default language was explicitly set to empty (should be preserved)
36 default_was_empty = default_lang in value and value[default_lang] == ""
38 # Validate entries - remove only None/missing, but keep empty strings
39 for k, v in list(value.items()):
40 if _is_missing(v):
41 value.pop(k, None)
42 elif not isinstance(v, str):
43 raise tk.Invalid("Localized text values must be strings.")
45 # Restore empty default language if it was explicitly set
46 if default_was_empty and default_lang not in value:
47 value[default_lang] = ""
49 if not value:
50 return None
52 # Don't auto-seed default language from other languages
53 # This allows setting only non-default language values without clearing default
55 return value
58def udc_json_dump(value, context):
59 """
60 If value is dict/list, dump to JSON string.
61 If already a string or missing/empty, pass through.
62 """
63 if _is_missing(value):
64 return None
65 if isinstance(value, (dict, list)):
66 try:
67 return json.dumps(value, ensure_ascii=False)
68 except Exception:
69 log.debug("udc_json_dump: could not dump (leaving as-is): %r", value)
70 return value
71 return value
74def udc_json_load(value, context):
75 """
76 If value is a JSON string, parse to Python object.
77 If already a dict/list, return as-is.
78 If missing/empty, return None (so ignore_missing upstream/downstream can skip).
79 Never raise on CKAN Missing; no spurious warnings.
80 """
81 if _is_missing(value):
82 return None
83 if isinstance(value, (dict, list)):
84 return value
85 if isinstance(value, str):
86 s = value.strip()
87 if not s:
88 return None
89 try:
90 parsed = json.loads(s)
91 if isinstance(parsed, (dict, list)):
92 return parsed
93 return value
94 except Exception:
95 # Soft-fail: leave it as-is so subsequent validators can decide.
96 log.debug("udc_json_load: not JSON (leaving as-is): %r", value)
97 return value
98 # Unknown type; pass through
99 return value
102def udc_core_translated_to_extras(core_field: str):
103 """
104 INPUT: when receiving <core_field>_translated, copy default-locale value into core_field.
105 """
107 def _copy(value, context):
108 if _is_missing(value):
109 return None
110 # value may be dict or JSON string
111 if isinstance(value, str):
112 try:
113 value = json.loads(value)
114 except Exception:
115 return value
116 if isinstance(value, dict):
117 default_lang = tk.config.get("ckan.locale_default", "en") or "en"
118 s = value.get(default_lang)
119 if isinstance(s, str) and s.strip():
120 data = context.get("data") or context.get("data_dict") or {}
121 data[core_field] = s
122 return value
124 return _copy
127def udc_set_core_from_translated(core_field: str):
128 """
129 Output (show) side: if `<core_field>` is empty, fill it from
130 `<core_field>_translated[default_lang]`.
131 """
133 def _output(value, context):
134 data = context.get("data") or context.get("data_dict") or {}
135 default_lang = tk.config.get("ckan.locale_default", "en")
136 translated = data.get(f"{core_field}_translated")
137 if (not data.get(core_field)) and isinstance(translated, dict):
138 v = translated.get(default_lang)
139 if v:
140 data[core_field] = v
141 return value
143 return _output
146def udc_lang_string_list(value, context):
147 """
148 Validate {lang: [string, ...]} (used by tags_translated).
149 Missing/empty -> None.
150 A single string is coerced to a one-item list.
151 """
152 if _is_missing(value):
153 return None
154 if not isinstance(value, dict):
155 raise tk.Invalid("Expected an object of {lang: [strings]}")
157 out = {}
158 for lang, vals in value.items():
159 if _is_missing(vals):
160 continue
161 if isinstance(vals, str):
162 vals = [vals]
163 if not isinstance(vals, list) or not all(isinstance(x, str) for x in vals):
164 raise tk.Invalid(
165 'Expected a list of strings for language "{}"'.format(lang)
166 )
167 # dedupe + strip empties
168 seen = set()
169 cleaned = []
170 for x in vals:
171 x = x.strip()
172 if x and x not in seen:
173 seen.add(x)
174 cleaned.append(x)
175 if cleaned:
176 out[lang] = cleaned
177 return out or None
180def udc_set_core_tags_from_translated(value, context):
181 """
182 If data['tags_translated'][default_lang] exists, set core 'tags' accordingly.
183 Runs in the tags_translated pipeline and (optionally) in the tags pipeline.
184 """
185 data = context.get("data") or context.get("data_dict") or {}
186 t = data.get("tags_translated")
187 # handle Missing / JSON string
188 if _is_missing(t):
189 return value
190 if isinstance(t, str):
191 try:
192 t = json.loads(t)
193 except Exception:
194 return value
195 if not isinstance(t, dict):
196 return value
197 default_lang = tk.config.get("ckan.locale_default", "en") or "en"
198 names = t.get(default_lang) or []
199 if isinstance(names, str):
200 names = [names]
201 if isinstance(names, list):
202 cleaned = []
203 seen = set()
204 for n in names:
205 if isinstance(n, str):
206 n = n.strip()
207 if n and n not in seen:
208 seen.add(n)
209 cleaned.append({"name": n})
210 if cleaned:
211 data["tags"] = cleaned
212 return value
215def udc_fill_tags_translated_from_core(value, context):
216 """
217 On show: if tags_translated is absent or missing default_lang, seed from core tags.
218 """
219 data = context.get("data") or context.get("data_dict") or {}
220 default_lang = tk.config.get("ckan.locale_default", "en") or "en"
222 tags_trans = data.get("tags_translated")
224 # If tags_translated exists and has data for default language, keep it
225 if isinstance(tags_trans, dict) and tags_trans and tags_trans.get(default_lang):
226 return value
228 # Otherwise, seed from core tags
229 tags = data.get("tags") or []
230 names = []
231 for t in tags:
232 if isinstance(t, dict) and isinstance(t.get("name"), str):
233 names.append(t["name"])
234 elif isinstance(t, str):
235 names.append(t)
237 if names:
238 # If tags_translated exists but is missing default_lang, add it
239 if isinstance(tags_trans, dict):
240 tags_trans[default_lang] = names
241 data["tags_translated"] = tags_trans
242 else:
243 # Create new tags_translated
244 data["tags_translated"] = {default_lang: names}
246 return value
249def udc_seed_translated_from_core(core_field: str):
250 """
251 INPUT (create/update): if <translated> is empty, seed {user_lang: core} so it gets stored.
252 Seeds to the user's current locale, not the system default.
253 """
255 def _seed(value, context):
256 if not _is_missing(value) and value:
257 return value
258 data = context.get("data") or context.get("data_dict") or {}
259 core_val = data.get(core_field)
260 if isinstance(core_val, str) and core_val.strip():
261 # Use the current user's language, not the system default
262 import ckan.lib.helpers as h
263 user_lang = h.lang() or tk.config.get("ckan.locale_default", "en") or "en"
264 return {user_lang: core_val}
265 return None
267 return _seed
270def udc_fill_translated_from_core_on_show(core_field: str):
271 """
272 SHOW (read): ensure translated_field appears by seeding from core if absent.
273 """
274 translated_field = core_field + "_translated"
276 def _fill(value, context):
277 data = context.get("data") or context.get("data_dict") or {}
278 tval = data.get(translated_field)
279 if isinstance(tval, dict) and tval:
280 return value
281 core_val = data.get(core_field)
282 if isinstance(core_val, str) and core_val.strip():
283 default_lang = tk.config.get("ckan.locale_default", "en") or "en"
284 data[translated_field] = {default_lang: core_val}
285 return value
287 return _fill