Skip to content

Commit 6c4086b

Browse files
committed
Fixed apple-plurals parsing and regeneration.
1 parent 719fe81 commit 6c4086b

File tree

6 files changed

+174
-87
lines changed

6 files changed

+174
-87
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
6+
<!-- ********** Strings for downloading map from search **********/ -->
7+
8+
<key>bookmarks_places</key>
9+
<dict>
10+
<key>NSStringLocalizedFormatKey</key>
11+
<string>%#@value@</string>
12+
<key>value</key>
13+
<dict>
14+
<key>NSStringFormatSpecTypeKey</key>
15+
<string>NSStringPluralRuleType</string>
16+
<key>NSStringFormatValueTypeKey</key>
17+
<string>d</string>
18+
<key>one</key>
19+
<string>%d bookmark</string>
20+
<key>other</key>
21+
<string>%d bookmarks</string>
22+
</dict>
23+
</dict>
24+
25+
<key>tracks</key>
26+
<dict>
27+
<key>NSStringLocalizedFormatKey</key>
28+
<string>%#@value@</string>
29+
<key>value</key>
30+
<dict>
31+
<key>NSStringFormatSpecTypeKey</key>
32+
<string>NSStringPluralRuleType</string>
33+
<key>NSStringFormatValueTypeKey</key>
34+
<string>d</string>
35+
<key>one</key>
36+
<string>%d track</string>
37+
<key>other</key>
38+
<string>%d tracks</string>
39+
</dict>
40+
</dict>
41+
42+
</dict>
43+
</plist>

python_twine/tests/test_formatters_plural.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,14 +165,19 @@ def test_read_plurals(self, fixtures_dir):
165165

166166
class TestApplePluralFormatter:
167167
@pytest.fixture
168-
def formatter(self):
168+
def formatter(self) -> ApplePluralFormatter:
169169
"""Create formatter with empty TwineFile."""
170170
formatter = ApplePluralFormatter()
171171
formatter.options = {"consume_all": True, "consume_comments": True}
172172
return formatter
173173

174174
@pytest.fixture
175-
def twine_file(self):
175+
def fixtures_dir(self) -> Path:
176+
"""Get fixtures directory path."""
177+
return Path(__file__).parent / "fixtures"
178+
179+
@pytest.fixture
180+
def twine_file(self) -> TwineFile:
176181
# Prepare TwineFile data
177182
twine_file = TwineFile()
178183
num_edits_def = TwineDefinition("num_edits")
@@ -198,6 +203,23 @@ def twine_file(self):
198203

199204
return twine_file
200205

206+
def test_read_stringsdict(self, formatter:ApplePluralFormatter, fixtures_dir):
207+
"""Test reading Android XML format with <plural/> tags."""
208+
fixture_path = fixtures_dir / "formatter_apple_plurals.stringsdict"
209+
with open(fixture_path, "r", encoding="utf-8") as f:
210+
formatter.read(f, "en")
211+
212+
twine_file = formatter.twine_file
213+
214+
assert "bookmarks_places" in twine_file.definitions_by_key
215+
translations1 = twine_file.definitions_by_key["bookmarks_places"].plural_translations
216+
assert translations1 == {"en": {"one": "%d bookmark", "other": "%d bookmarks"}}
217+
218+
assert "tracks" in twine_file.definitions_by_key
219+
translations2 = twine_file.definitions_by_key["tracks"].plural_translations
220+
assert translations2 == {"en": {"one": "%d track", "other": "%d tracks"}}
221+
222+
201223
def test_write_plural_format(self, formatter, twine_file):
202224
formatter.twine_file = twine_file
203225

python_twine/twine/formatters/__init__.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ def flatten(input: List[List[str]]) -> List[str]:
1818
flat += group
1919
return flat
2020

21+
def find_dict_diff(dict1: dict, dict2: dict):
22+
keys = dict1.keys() | dict2.keys()
23+
for key in sorted(keys):
24+
if key in dict1 and key not in dict2:
25+
yield key, dict1[key], None
26+
elif key not in dict1 and key in dict2:
27+
yield key, None, dict2[key]
28+
elif dict1[key] != dict2[key]:
29+
yield key, dict1[key], dict2[key]
30+
2131
class AbstractFormatter(ABC):
2232
"""Base class for all format formatters."""
2333

@@ -105,8 +115,7 @@ def set_translation_for_key(self, key: str, lang: str, value: str, section_name:
105115

106116
def set_translation_for_key_plural(self, key: str, lang: str, values: Dict[str, str], section_name:Optional[str]):
107117
""" Set plular values translation for a key in a specific language.
108-
This method is similar to set_translation_for_key(). Let's keep both
109-
methods for simplicity.
118+
This method is similar to set_translation_for_key() but with dict values.
110119
"""
111120
# Normalize newlines
112121
values = {key:val.replace("\n", "\\n") for (key, val) in values.items()}
@@ -123,10 +132,14 @@ def set_translation_for_key_plural(self, key: str, lang: str, values: Dict[str,
123132
# Only set if no reference or value differs from reference
124133
if not reference or values != reference.plural_translations.get(lang):
125134
if lang in definition.plural_translations and definition.plural_translations[lang] != values:
126-
msg = (f"Translation '{values}' overrides existing translation '{definition.plural_translations[lang]}' "
127-
f"for key '{key}' and lang '{lang}' (comment '{definition.comment}').")
128-
self.add_validation_error(msg)
129-
definition.plural_translations[lang] = values
135+
for quantity, value_old, value_new in find_dict_diff(definition.plural_translations[lang], values):
136+
msg = (f"Translation '{value_new}' overrides existing translation '{value_old}' "
137+
f"for key '{key}:{quantity}' and lang '{lang}'")
138+
self.add_validation_error(msg)
139+
if lang in definition.plural_translations:
140+
definition.plural_translations[lang].update(values)
141+
else:
142+
definition.plural_translations[lang] = values
130143
if "tags" in self.options:
131144
definition.add_tags(flatten(self.options["tags"]))
132145

python_twine/twine/formatters/apple_plural.py

Lines changed: 76 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from typing import Dict, Optional, TextIO
66
from xml.etree import ElementTree as ET
7+
from xml.etree.ElementTree import Element
78

89
from twine.formatters.apple import AppleFormatter
910
from twine.placeholders import convert_placeholders_from_android_to_twine
@@ -128,94 +129,27 @@ def read(self, io: TextIO, lang: str):
128129
comment_text = None
129130
for j in range(i - 1, -1, -1):
130131
prev = children[j]
131-
if isinstance(prev, ET.Comment):
132+
# Handle comments (they have a callable tag function)
133+
if callable(prev.tag):
132134
comment_text = prev.text.strip() if prev.text else None
133135
break
134136
elif prev.tag is not None: # Hit another element
135137
break
136138

137139
# Extract plural hash
138-
plural_hash = {}
139-
140-
# Find <key>value</key><dict> inside value_container
141-
value_dict = None
142-
value_children = list(value_container)
143-
144-
for j, inner_key in enumerate(value_children):
145-
if inner_key.tag == "key" and inner_key.text == "value":
146-
if j + 1 < len(value_children):
147-
value_dict = value_children[j + 1]
148-
break
149-
150-
if value_dict is not None and value_dict.tag == "dict":
151-
# Extract plural entries
152-
plural_children = list(value_dict)
153-
j = 0
154-
155-
while j < len(plural_children):
156-
pkey_elem = plural_children[j]
157-
158-
if pkey_elem.tag == "key":
159-
pkey = pkey_elem.text
160-
161-
if pkey in TwineDefinition.PLURAL_KEYS:
162-
if j + 1 < len(plural_children):
163-
string_elem = plural_children[j + 1]
164-
165-
if string_elem.tag == "string":
166-
pvalue = string_elem.text or ""
167-
plural_hash[pkey] = pvalue
168-
169-
j += 1
140+
plural_hash = self.extract_plural_dict(value_container)
170141

171142
if not plural_hash:
172143
i += 2
173144
continue
174145

175146
# Get or create definition
176-
definition = self.twine_file.definitions_by_key.get(key_name)
177-
178-
if not definition:
179-
if self.options.get("consume_all"):
180-
print(
181-
f"Adding new plural definition '{key_name}' to twine file.",
182-
file=twine.stdout,
183-
)
184-
185-
# Find or create Uncategorized section
186-
current_section = next(
187-
(
188-
s
189-
for s in self.twine_file.sections
190-
if s.name == "Uncategorized"
191-
),
192-
None,
193-
)
194-
195-
if not current_section:
196-
current_section = TwineSection("Uncategorized")
197-
self.twine_file.sections.insert(0, current_section)
198-
199-
definition = TwineDefinition(key_name)
200-
current_section.definitions.append(definition)
201-
self.twine_file.definitions_by_key[key_name] = definition
202-
else:
203-
print(
204-
f"WARNING: '{key_name}' not found in twine file (plural).",
205-
file=twine.stdout,
206-
)
207-
i += 2
208-
continue
209-
210-
# Merge plural translations
211-
if lang not in definition.plural_translations:
212-
definition.plural_translations[lang] = {}
213-
214-
definition.plural_translations[lang].update(plural_hash)
215-
216-
# Set base translation to 'other' if present
217-
if "other" in plural_hash:
218-
self.set_translation_for_key(key_name, lang, plural_hash["other"])
147+
if not self.match_default_lang_translation(key_name, lang, plural_hash):
148+
self.set_translation_for_key_plural(key_name, lang, plural_hash, section_name=None)
149+
150+
# Set base translation to 'other' if present
151+
if "other" in plural_hash:
152+
self.set_translation_for_key(key_name, lang, plural_hash["other"], section_name=None)
219153

220154
# Set comment if requested
221155
if comment_text and self.options.get("consume_comments"):
@@ -227,9 +161,75 @@ def read(self, io: TextIO, lang: str):
227161

228162
i += 2
229163

164+
def extract_plural_dict(self, value_element: Element) -> dict:
165+
""" Parse next XML structure to extract key-value pairs:
166+
<dict>
167+
<key>NSStringLocalizedFormatKey</key>
168+
<string>%#@value@</string>
169+
<key>value</key>
170+
<dict>
171+
<key>NSStringFormatSpecTypeKey</key>
172+
<string>NSStringPluralRuleType</string>
173+
<key>NSStringFormatValueTypeKey</key>
174+
<string>d</string>
175+
<key>one</key>
176+
<string>%d bookmark</string>
177+
<key>other</key>
178+
<string>%d bookmarks</string>
179+
</dict>
180+
</dict>
181+
"""
182+
plural_dict = {}
183+
184+
# Find <key>value</key><dict> inside value_element
185+
value_dict = None
186+
value_children = list(value_element)
187+
188+
for j, inner_key in enumerate(value_children):
189+
if inner_key.tag == "key" and inner_key.text == "value":
190+
if j + 1 < len(value_children):
191+
value_dict = value_children[j + 1]
192+
break
193+
194+
if value_dict is not None and value_dict.tag == "dict":
195+
# Extract plural entries
196+
plural_children = list(value_dict)
197+
j = 0
198+
199+
while j < len(plural_children):
200+
pkey_elem = plural_children[j]
201+
202+
if pkey_elem.tag == "key":
203+
pkey = pkey_elem.text
204+
205+
if pkey in TwineDefinition.PLURAL_KEYS:
206+
if j + 1 < len(plural_children):
207+
string_elem = plural_children[j + 1]
208+
209+
if string_elem.tag == "string":
210+
pvalue = string_elem.text or ""
211+
plural_dict[pkey] = pvalue
212+
213+
j += 1
214+
return plural_dict
215+
230216
def should_include_definition(self, definition, lang: str) -> bool:
231217
"""Only include plural definitions."""
232218
return (
233219
definition.is_plural()
234220
and definition.plural_translation_for_lang(lang) is not None
235221
)
222+
223+
def match_default_lang_translation(self, key:str, lang:str, value:dict) -> bool:
224+
""" Apple strings file for non-default language (es, de, fr, etc) contains
225+
default value for not translated keys. That's why in Slovenian .strings
226+
file you can find english words.
227+
If `value` matches translation from default language, it means that
228+
this string is not translated.
229+
"""
230+
default_lang = self.twine_file.get_developer_language_code()
231+
if default_lang is None:
232+
return False
233+
if default_lang == lang:
234+
return False
235+
return self.twine_file.definitions_by_key[key].plural_translations[default_lang] == value

python_twine/twine/output_processor.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def process(self, language: str) -> TwineFile:
7878
"""
7979
result = TwineFile()
8080
result.language_codes = self.twine_file.language_codes.copy()
81+
fallbacks = self.fallback_languages(language)
8182

8283
for section in self.twine_file.sections:
8384
new_section = TwineSection(section.name)
@@ -99,7 +100,6 @@ def process(self, language: str) -> TwineFile:
99100

100101
# Try fallback languages if no translation found
101102
if value is None and include_option != "translated":
102-
fallbacks = self.fallback_languages(language)
103103
value = definition.translation_for_lang(fallbacks)
104104

105105
# Skip if still no value
@@ -112,8 +112,10 @@ def process(self, language: str) -> TwineFile:
112112

113113
# Handle plural translations
114114
if definition.is_plural():
115-
if language not in new_definition.plural_translations:
116-
new_definition.plural_translations[language] = {}
115+
if language not in new_definition.plural_translations \
116+
and include_option != "translated":
117+
lng = definition.find_plural_lang_fallback(fallbacks)
118+
new_definition.plural_translations[language] = definition.plural_translation_for_lang(lng)
117119

118120
# Ensure 'other' key exists for plurals
119121
if "other" not in new_definition.plural_translations[language]:

python_twine/twine/twine_file.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,13 @@ def translation_for_lang(self, lang: str | List[str]) -> Optional[str]:
122122

123123
return None
124124

125+
def find_plural_lang_fallback(self, fallback_langs: List[str]) -> Optional[str]:
126+
""" Find first language from `fallback_langs` which is in plural_translations. """
127+
return next(
128+
filter(lambda lng: lng in self.plural_translations,
129+
fallback_langs),
130+
None)
131+
125132
def plural_translation_for_lang(self, lang: str) -> Optional[Dict[str, str]]:
126133
"""
127134
Get plural translations for a language, sorted by PLURAL_KEYS order.

0 commit comments

Comments
 (0)