├── tests ├── __init__.py ├── test_link.py ├── test_parse_directory.py ├── test_parse_resources.py ├── test_compare.py ├── test_parse_spreadsheet.py ├── test_create_spreadsheet_values.py └── test_parse_file.py ├── stringsheet ├── constants.py ├── __init__.py ├── cli.py ├── comparator.py ├── writer.py ├── api.py ├── main.py ├── model.py └── parser.py ├── tox.ini ├── test-resources ├── strings_empty.xml ├── strings │ ├── one.xml │ ├── two.xml │ └── non_xml.txt ├── res │ ├── xml │ │ └── strings.xml │ ├── values-night │ │ └── strings.xml │ ├── values-v21 │ │ └── strings.xml │ ├── values-pl │ │ └── strings.xml │ ├── values-w820dp │ │ └── strings.xml │ ├── values-zh-rCN │ │ └── strings.xml │ ├── values-zh-rTW │ │ └── strings.xml │ ├── values-de │ │ └── strings.xml │ └── values │ │ └── strings.xml ├── strings_invalid_root_tag.xml ├── strings_non_translatable_file │ └── donottranslate.xml ├── strings_not_translatable_root.xml ├── strings_basic.xml ├── strings_not_translatable_output.xml ├── strings_invalid.xml ├── strings_not_translatable.xml ├── strings_arrays_output.xml ├── strings_arrays.xml ├── output │ ├── valid.json │ ├── empty_row.json │ ├── incorrectly_named_id.json │ └── missing_id_row.json ├── strings_plurals.xml ├── strings_plurals_output.xml ├── strings_order.xml ├── strings_order_output.xml └── strings_comments.xml ├── requirements.txt ├── .gitignore ├── .idea ├── vcs.xml ├── modules.xml ├── misc.xml ├── inspectionProfiles │ └── Project_Default.xml ├── runConfigurations │ ├── py_test.xml │ └── Unittests.xml └── codeStyleSettings.xml ├── .travis.yml ├── setup.py ├── LICENSE ├── stringsheet.iml └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /stringsheet/constants.py: -------------------------------------------------------------------------------- 1 | QUANTITIES = ['zero', 'one', 'two', 'few', 'many', 'other'] 2 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py36 3 | [testenv] 4 | deps=pytest 5 | commands=pytest 6 | -------------------------------------------------------------------------------- /test-resources/strings_empty.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | httplib2==0.19.0 2 | setuptools==36.3.0 3 | lxml==4.6.3 4 | google_api_python_client==1.6.3 5 | -------------------------------------------------------------------------------- /test-resources/strings/one.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | One 4 | 5 | -------------------------------------------------------------------------------- /test-resources/strings/two.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Two 4 | 5 | -------------------------------------------------------------------------------- /test-resources/res/xml/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | String 4 | 5 | -------------------------------------------------------------------------------- /test-resources/strings/non_xml.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | Non xml 4 | 5 | -------------------------------------------------------------------------------- /test-resources/res/values-night/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | String 4 | 5 | -------------------------------------------------------------------------------- /test-resources/res/values-v21/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | String 4 | 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | res/ 2 | client_secret.json 3 | .idea/workspace.xml 4 | build/ 5 | dist/ 6 | __pycache__/ 7 | .tox/ 8 | .cache/ 9 | 10 | *.egg-info 11 | *.pyc 12 | -------------------------------------------------------------------------------- /test-resources/res/values-pl/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | String (pl) 4 | 5 | -------------------------------------------------------------------------------- /test-resources/res/values-w820dp/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | String 4 | 5 | -------------------------------------------------------------------------------- /test-resources/res/values-zh-rCN/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | String (zh-rCN) 4 | 5 | -------------------------------------------------------------------------------- /test-resources/res/values-zh-rTW/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | String (zh-rTW) 4 | 5 | -------------------------------------------------------------------------------- /test-resources/strings_invalid_root_tag.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | This string shouldn't be found 4 | 5 | -------------------------------------------------------------------------------- /test-resources/strings_non_translatable_file/donottranslate.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Non translatable 4 | 5 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /test-resources/strings_not_translatable_root.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | This string shouldn't be found 4 | 5 | -------------------------------------------------------------------------------- /test-resources/strings_basic.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Second string 4 | Test string 5 | 6 | -------------------------------------------------------------------------------- /test-resources/strings_not_translatable_output.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Translatable 4 | Translatable 2 5 | 6 | -------------------------------------------------------------------------------- /test-resources/res/values-de/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | String (de) 4 | 5 | Partly added (de) 6 | 7 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test-resources/strings_invalid.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12dp 5 | #FFFFFF 6 | @string/other 7 | ?something 8 | 9 | -------------------------------------------------------------------------------- /stringsheet/__init__.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | __title__ = 'stringsheet' 3 | __description__ = 'Manage Android translations using Google Spreadsheets' 4 | __url__ = 'https://github.com/Tunous/StringsSheet' 5 | __version__ = '0.2.3' 6 | __author__ = 'Łukasz Rutkowski' 7 | __author_email__ = 'lukus178@gmail.com' 8 | __license__ = 'MIT' 9 | -------------------------------------------------------------------------------- /test-resources/strings_not_translatable.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Not translatable 4 | Translatable 5 | Translatable 2 6 | 7 | -------------------------------------------------------------------------------- /test-resources/strings_arrays_output.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | First 5 | Second 6 | Third 7 | 8 | 9 | First 10 | Second 11 | Third 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/test_link.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from stringsheet.main import create_link 4 | 5 | 6 | class LinkTestCase(unittest.TestCase): 7 | def test_creates_valid_link(self): 8 | self.assertEqual( 9 | 'https://docs.google.com/spreadsheets/d/theSpreadsheetId/edit', 10 | create_link('theSpreadsheetId')) 11 | 12 | 13 | if __name__ == '__main__': 14 | unittest.main() 15 | -------------------------------------------------------------------------------- /test-resources/strings_arrays.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | First 5 | Second 6 | Third 7 | 8 | 9 | First 10 | Second 11 | Third 12 | @string/reference 13 | 14 | 15 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /test-resources/output/valid.json: -------------------------------------------------------------------------------- 1 | { 2 | "range": "Translations!A1:E3", 3 | "majorDimension": "ROWS", 4 | "values": [ 5 | [ 6 | "id", 7 | "comment", 8 | "default", 9 | "de", 10 | "pl" 11 | ], 12 | [ 13 | "partial_string", 14 | null, 15 | "Partial string", 16 | "Partial string (de", 17 | null 18 | ], 19 | [ 20 | "string", 21 | null, 22 | "String", 23 | "String (de)", 24 | "String (pl)" 25 | ] 26 | ] 27 | } -------------------------------------------------------------------------------- /test-resources/strings_plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Few 5 | Many 6 | One 7 | Other 8 | Two 9 | Zero 10 | 11 | 12 | One 13 | @string/reference 14 | Other 15 | Zero 16 | 17 | 18 | -------------------------------------------------------------------------------- /test-resources/strings_plurals_output.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Zero 5 | One 6 | Two 7 | Few 8 | Many 9 | Other 10 | 11 | 12 | Zero 13 | One 14 | Other 15 | Other 16 | Other 17 | Other 18 | 19 | 20 | -------------------------------------------------------------------------------- /test-resources/output/empty_row.json: -------------------------------------------------------------------------------- 1 | { 2 | "range": "Translations!A1:E4", 3 | "majorDimension": "ROWS", 4 | "values": [ 5 | [ 6 | "id", 7 | "comment", 8 | "default", 9 | "de", 10 | "pl" 11 | ], 12 | [ 13 | "string", 14 | null, 15 | "String", 16 | "String (de)", 17 | "String (pl)" 18 | ], 19 | [ 20 | "string_2", 21 | null, 22 | "String 2", 23 | "String 2 (de)", 24 | "String 2 (pl)" 25 | ], 26 | [], 27 | [ 28 | "Some non-translation text", 29 | "Using more columns", 30 | "Just like valid cells", 31 | "But it's located under blank row" 32 | ] 33 | ] 34 | } -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /test-resources/output/incorrectly_named_id.json: -------------------------------------------------------------------------------- 1 | { 2 | "range": "Translations!A1:E4", 3 | "majorDimension": "ROWS", 4 | "values": [ 5 | [ 6 | "id", 7 | "comment", 8 | "default", 9 | "de", 10 | "pl" 11 | ], 12 | [ 13 | "string", 14 | null, 15 | "String", 16 | "String (de)", 17 | "String (pl)" 18 | ], 19 | [ 20 | "string_2", 21 | null, 22 | "String 2", 23 | "String 2 (de)", 24 | "String 2 (pl)" 25 | ], 26 | [ 27 | "This id is incorrectly formatted", 28 | "Ids can't contain whitespace", 29 | "Which makes it invalid" 30 | ], 31 | [ 32 | "Some non-translation text", 33 | "Using more columns", 34 | "Just like valid cells", 35 | "But it's located under invalid row" 36 | ] 37 | ] 38 | } -------------------------------------------------------------------------------- /.idea/runConfigurations/py_test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 18 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Unittests.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 18 | -------------------------------------------------------------------------------- /test-resources/output/missing_id_row.json: -------------------------------------------------------------------------------- 1 | { 2 | "range": "Translations!A1:E4", 3 | "majorDimension": "ROWS", 4 | "values": [ 5 | [ 6 | "id", 7 | "comment", 8 | "default", 9 | "de", 10 | "pl" 11 | ], 12 | [ 13 | "string", 14 | null, 15 | "String", 16 | "String (de)", 17 | "String (pl)" 18 | ], 19 | [ 20 | "string_2", 21 | null, 22 | "String 2", 23 | "String 2 (de)", 24 | "String 2 (pl)" 25 | ], 26 | [ 27 | null, 28 | "This row has no id", 29 | "Which makes it invalid", 30 | "Even though there are enough columns", 31 | "That could be translations" 32 | ], 33 | [ 34 | "Some non-translation text", 35 | "Using more columns", 36 | "Just like valid cells", 37 | "But it's located under invalid row" 38 | ] 39 | ] 40 | } -------------------------------------------------------------------------------- /test-resources/strings_order.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Many B 5 | Few B 6 | Zero B 7 | Two B 8 | Other B 9 | One B 10 | 11 | 12 | First B 13 | Second B 14 | 15 | String B 16 | 17 | First A 18 | Second A 19 | 20 | String A 21 | 22 | Few A 23 | One A 24 | Many A 25 | Zero A 26 | Other A 27 | Two A 28 | 29 | 30 | -------------------------------------------------------------------------------- /test-resources/strings_order_output.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | String A 4 | String B 5 | 6 | First A 7 | Second A 8 | 9 | 10 | First B 11 | Second B 12 | 13 | 14 | Zero A 15 | One A 16 | Two A 17 | Few A 18 | Many A 19 | Other A 20 | 21 | 22 | Zero B 23 | One B 24 | Two B 25 | Few B 26 | Many B 27 | Other B 28 | 29 | 30 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | sudo: false 3 | language: python 4 | python: 5 | - '2.7' 6 | - '3.6' 7 | 8 | install: 9 | - pip install -r requirements.txt 10 | - python setup.py install 11 | 12 | script: 13 | - stringsheet --version 14 | - pytest 15 | 16 | deploy: 17 | provider: pypi 18 | user: 'Tunous' 19 | password: 20 | secure: nKovhdK+Dqs7iCX7A13BcAB3eRCdOsku0PeKGv+DBSWnthKjflun3gTQOwaGWF0qy5qmGMqoPa3bmdnlYsEiy3u83HvVoWbYtw9nQNSlI0JY4y3D5iCvGNCgDrZK7AFpVFaETJ0EVXaEOTankSNBC1f9EhdDHd7HE9Gb30gwaMO23JoPtM6y2JUpDw255LBSzY5DBb+JRycMJDgstiQLv2h5zhaCSHC8BVomfI8Ybj5dQdJioWIS0AXAFCputXuJ2DMXkmgW1BYOWoDMDl5qyMnfOGPol1w1qbkpP+dfHAt7/8j3t1OOGcXXK/IhMMMPYT73gsdYTuLJa4meUyS4Hxx3ou7UaHkD07EOuAvEN9+6Dm1ijQRewvVZ7E/yFZAoh/sg8Y1ISE/aV/qNgVsadLmnwuABiKvxGBrtq95V8WJ10Ruxr/pGOQ6EcwDkbX9NIMxLuIrnjgellxk64idobHMZVG24TV06PTMRasaBAXMIVwnLdh5LPrLphxGM2x4Cd1bsGU1gdoVE4Rk0o2Ry1ucULPFHP0WWlZXPzQD8BtWsUhVfaO7YDafzFJx/gMfhQy4JzutcT1jV5XQ2vMzRnCSFNVmsUsNsk+cJnySeGF7PDqjethAIjCEEcA49dNLJBNXT7vAAGizMnBupr5jumkkzh/UUJVapntWckLnIpUQ= 21 | on: 22 | tags: true 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from io import open 4 | from setuptools import setup 5 | 6 | about = {} 7 | here = os.path.abspath(os.path.dirname(__file__)) 8 | with open(os.path.join(here, 'stringsheet', '__init__.py'), encoding='utf-8') as f: 9 | for line in f: 10 | if line.startswith('__'): 11 | (key, value) = line.split('=') 12 | about[key.strip()] = value.strip().strip('\'') 13 | 14 | with open('README.rst', encoding='utf-8') as f: 15 | readme = f.read() 16 | 17 | setup( 18 | name=about['__title__'], 19 | version=about['__version__'], 20 | description=about['__description__'], 21 | long_description=readme, 22 | author=about['__author__'], 23 | author_email=about['__author_email__'], 24 | url=about['__url__'], 25 | license=about['__license__'], 26 | packages=['stringsheet'], 27 | install_requires=[ 28 | 'httplib2', 29 | 'apiclient', 30 | 'lxml', 31 | 'google-api-python-client' 32 | ], 33 | entry_points={ 34 | 'console_scripts': [ 35 | 'stringsheet = stringsheet.cli:main' 36 | ] 37 | } 38 | ) 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Łukasz Rutkowski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test-resources/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | String 5 | String 2 6 | 7 | 8 | One 9 | 10 | Other 11 | 12 | 13 | 14 | 15 | Some item 16 | More items 17 | 18 | More 19 | 20 | 21 | One 22 | 23 | Zero 24 | Two 25 | Other 26 | Few 27 | Many 28 | 29 | 30 | 31 | 32 | 33 | 34 | First 35 | Second 36 | 37 | 38 | Partly added 39 | A string 40 | 41 | -------------------------------------------------------------------------------- /.idea/codeStyleSettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 26 | 28 | -------------------------------------------------------------------------------- /test-resources/strings_comments.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | With comment 5 | Without comment 6 | 7 | 8 | After blank line 9 | 10 | 11 | 12 | With comment 13 | Without comment 14 | 15 | 16 | After blank line 17 | 18 | 19 | 20 | 21 | Without comment 22 | 23 | With own comment 24 | 25 | 26 | After blank line 27 | 28 | 29 | 30 | 31 | One 32 | Two 33 | 34 | 35 | Other 36 | 37 | 38 | 39 | 40 | 41 | One 42 | Two 43 | 44 | 45 | Other 46 | 47 | 48 | -------------------------------------------------------------------------------- /tests/test_parse_directory.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from stringsheet.parser import parse_directory 4 | 5 | 6 | class ParseDirectoryTestCase(unittest.TestCase): 7 | """Test that the parser can find strings in all XML files under the 8 | specified directory. 9 | """ 10 | 11 | def setUp(self): 12 | self.resources = parse_directory('test-resources/strings') 13 | 14 | def test_finds_all_strings(self): 15 | self.assertEqual(2, self.resources.count(), 16 | 'Found incorrect number of strings') 17 | 18 | 19 | class ParseNonExistingDirectoryTestCase(unittest.TestCase): 20 | """Test that the parser handles non-existing files.""" 21 | 22 | def test_crashes(self): 23 | with self.assertRaises(OSError): 24 | parse_directory('test-resources/non_existing') 25 | 26 | 27 | class ParseDirectoryWithNonTranslatableFileTestCase(unittest.TestCase): 28 | """Test that the parser doesn't parse strings from "donottranslate.xml" 29 | files. 30 | """ 31 | 32 | def setUp(self): 33 | self.resources = parse_directory( 34 | 'test-resources/strings_non_translatable_file') 35 | 36 | def test_doesnt_find_string_in_non_translatable_file(self): 37 | self.assertNotIn('non_translatable', self.resources) 38 | 39 | 40 | if __name__ == '__main__': 41 | unittest.main() 42 | -------------------------------------------------------------------------------- /stringsheet.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 21 | 22 | 24 | 25 | 28 | -------------------------------------------------------------------------------- /tests/test_parse_resources.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from stringsheet.parser import parse_resources 4 | 5 | 6 | class ResourcesParseTestCase(unittest.TestCase): 7 | """Test that parser correctly extracts strings from Android resources 8 | directory 9 | """ 10 | 11 | def setUp(self): 12 | self.resources = parse_resources('test-resources/res') 13 | self.languages = ['pl', 'de', 'zh-rCN', 'zh-rTW'] 14 | 15 | def test_finds_all_languages(self): 16 | self.assertIn('default', self.resources) 17 | for language in self.languages: 18 | self.assertIn(language, self.resources) 19 | 20 | def test_number_of_languages_is_correct(self): 21 | self.assertEqual(len(self.resources), 5) 22 | 23 | def test_doesnt_find_invalid_languages(self): 24 | self.assertNotIn('night', self.resources) 25 | self.assertNotIn('v21', self.resources) 26 | self.assertNotIn('w820dp', self.resources) 27 | 28 | def test_finds_all_strings(self): 29 | self.assertIn('string', self.resources['default']) 30 | for language in self.languages: 31 | self.assertIn('string', self.resources[language]) 32 | 33 | def test_translations_are_correct(self): 34 | self.assertEqual('String', 35 | self.resources['default']._strings['string'].text) 36 | for language in self.languages: 37 | self.assertEqual('String (' + language + ')', 38 | self.resources[language]._strings['string'].text) 39 | 40 | def test_non_existing_translations_are_skipped(self): 41 | self.assertIn('partly_added', self.resources['default']) 42 | self.assertIn('partly_added', self.resources['de']) 43 | self.assertNotIn('partly_added', self.resources['pl']) 44 | 45 | 46 | if __name__ == '__main__': 47 | unittest.main() 48 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | StringSheet 2 | =========== 3 | 4 | .. image:: https://travis-ci.org/Tunous/StringSheet.svg?branch=master 5 | :target: https://travis-ci.org/Tunous/StringSheet 6 | .. image:: https://badge.fury.io/py/stringsheet.svg 7 | :target: https://badge.fury.io/py/stringsheet 8 | 9 | Script for managing Android translations using Google Spreadsheets. 10 | 11 | Usage 12 | ===== 13 | 14 | create 15 | ^^^^^^ 16 | 17 | Create a new spreadsheet and automatically upload your strings. 18 | 19 | .. code-block:: sh 20 | 21 | $ stringsheet create "My project" "~/src/myproject/app/src/main/res" 22 | 23 | *Note: The path should point to the res directory of your Android project.* 24 | 25 | download 26 | ^^^^^^^^ 27 | 28 | Download translations from spreadsheet. 29 | 30 | .. code-block:: sh 31 | 32 | $ stringsheet download spreadsheetId "~/src/myproject/app/src/main/res" 33 | 34 | upload 35 | ^^^^^^ 36 | 37 | Upload strings to existing spreadsheet. 38 | 39 | .. code-block:: sh 40 | 41 | $ stringsheet upload spreadsheetId "~/src/myproject/app/src/main/res" 42 | 43 | Note: This command will override all strings in the spreadsheet. You should first download the spreadsheet using the previous command and commit them to your project before uploading 44 | 45 | Installation 46 | ============ 47 | 48 | .. code-block:: sh 49 | 50 | $ pip install stringsheet 51 | 52 | Features 53 | ======== 54 | 55 | - Support for all string formats: 56 | 57 | - string 58 | - string-array 59 | - plurals 60 | 61 | - Automatic spreadsheet formatting durning creation: 62 | 63 | - Protection of informational columns and rows 64 | - Highlighting of missing translations with conditional formatting 65 | 66 | - Support for creating separate sheets for different languages (see `Multi-sheet` section) 67 | 68 | Multi-sheet 69 | =========== 70 | 71 | The create command contains an additional argument called :code:`--multi-sheet` or :code:`-m`. When used the created spreadsheet will consist of multiple sheets, each for a different language. 72 | -------------------------------------------------------------------------------- /tests/test_compare.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from stringsheet.comparator import compare_strings 4 | from stringsheet.comparator import string_order 5 | 6 | 7 | class CompareTestCase(unittest.TestCase): 8 | def assert_before(self, a, b): 9 | self.assertLess(compare_strings(a, b), 0) 10 | 11 | def assert_after(self, a, b): 12 | self.assertGreater(compare_strings(a, b), 0) 13 | 14 | def test_orders_strings_alphabetically(self): 15 | self.assert_before('a_string', 'b_string') 16 | self.assert_after('b_string', 'a_string') 17 | 18 | def test_orders_arrays_by_index(self): 19 | self.assert_before('array[0]', 'array[1]') 20 | self.assert_after('array[1]', 'array[0]') 21 | 22 | def test_orders_plurals_by_quantity(self): 23 | self.assert_before('plural{zero}', 'plural{one}') 24 | self.assert_before('plural{one}', 'plural{two}') 25 | self.assert_before('plural{two}', 'plural{few}') 26 | self.assert_before('plural{few}', 'plural{many}') 27 | self.assert_before('plural{many}', 'plural{other}') 28 | 29 | self.assert_after('plural{other}', 'plural{many}') 30 | self.assert_after('plural{many}', 'plural{few}') 31 | self.assert_after('plural{few}', 'plural{two}') 32 | self.assert_after('plural{two}', 'plural{one}') 33 | self.assert_after('plural{one}', 'plural{zero}') 34 | 35 | def test_orders_by_type(self): 36 | self.assert_before('string', 'string[0]') 37 | self.assert_before('string', 'string{one}') 38 | self.assert_before('string[0]', 'string{other}') 39 | self.assert_after('string{zero}', 'string') 40 | self.assert_after('string{two}', 'string[1]') 41 | self.assert_after('string[2]', 'string') 42 | 43 | def test_order_is_valid(self): 44 | actual = [ 45 | 'b_plural{one}', 'b_string', 'a_plural{other}', 'b_plural{zero}', 46 | 'a_plural{many}', 'b_array[1]', 'a_array[1]', 'b_array[0]', 47 | 'a_string', 'a_array[0]' 48 | ] 49 | expected = [ 50 | 'a_string', 'b_string', 'a_array[0]', 'a_array[1]', 'b_array[0]', 51 | 'b_array[1]', 'a_plural{many}', 'a_plural{other}', 'b_plural{zero}', 52 | 'b_plural{one}' 53 | ] 54 | self.assertEqual(sorted(actual, key=string_order), expected) 55 | 56 | 57 | if __name__ == '__main__': 58 | unittest.main() 59 | -------------------------------------------------------------------------------- /stringsheet/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | import stringsheet.main as ss 4 | from . import __version__ 5 | 6 | 7 | def create(args): 8 | ss.create(args.project_name, args.source_dir, args.multi_sheet) 9 | 10 | 11 | def upload(args): 12 | ss.upload(args.spreadsheet_id, args.source_dir) 13 | 14 | 15 | def download(args): 16 | ss.download(args.spreadsheet_id, args.target_dir) 17 | 18 | 19 | def parse_args(): 20 | arg_parser = argparse.ArgumentParser( 21 | description='Manage Android translations using Google Spreadsheets', 22 | prog='stringsheet') 23 | 24 | arg_parser.add_argument( 25 | '-v', '--version', 26 | action='version', 27 | version='%(prog)s ' + __version__) 28 | 29 | subparsers = arg_parser.add_subparsers(dest='operation') 30 | subparsers.required = True 31 | 32 | parser_create = subparsers.add_parser( 33 | 'create', 34 | help='Create new Google Spreadsheet for managing Android strings') 35 | parser_create.add_argument( 36 | 'project_name', 37 | help='The name of the project') 38 | parser_create.add_argument( 39 | 'source_dir', 40 | help='A path to resources directory of Android project') 41 | parser_create.add_argument( 42 | '-m', '--multi-sheet', 43 | action='store_true', 44 | help='Upload each language to a separate sheet (in the same file)') 45 | parser_create.set_defaults(func=create) 46 | 47 | parser_upload = subparsers.add_parser( 48 | 'upload', 49 | help='Upload Android strings to Google Spreadsheet') 50 | parser_upload.add_argument( 51 | 'spreadsheet_id', 52 | help='Id of the spreadsheet to upload to') 53 | parser_upload.add_argument( 54 | 'source_dir', 55 | help='A path to resources directory of Android project') 56 | parser_upload.set_defaults(func=upload) 57 | 58 | parser_download = subparsers.add_parser( 59 | 'download', 60 | help='Download Google Spreadsheet as strings files') 61 | parser_download.add_argument( 62 | 'spreadsheet_id', 63 | help='Id of the spreadsheet to download') 64 | parser_download.add_argument( 65 | 'target_dir', 66 | help='A path to directory where to save downloaded strings') 67 | parser_download.set_defaults(func=download) 68 | 69 | return arg_parser.parse_args() 70 | 71 | 72 | def main(): 73 | args = parse_args() 74 | args.func(args) 75 | 76 | 77 | if __name__ == '__main__': 78 | main() 79 | -------------------------------------------------------------------------------- /tests/test_parse_spreadsheet.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | 4 | from stringsheet.model import ResourceContainer 5 | from stringsheet.parser import parse_spreadsheet_values 6 | 7 | 8 | class BaseSpreadsheetDataTestCase(unittest.TestCase): 9 | @property 10 | def test_file(self): 11 | raise NotImplementedError 12 | 13 | def setUp(self): 14 | with open('test-resources/output/%s' % self.test_file) as f: 15 | output = json.load(f) 16 | values = output['values'] 17 | self.resources = ResourceContainer() 18 | parse_spreadsheet_values(self.resources, values) 19 | 20 | 21 | class ValidDataTestCase(BaseSpreadsheetDataTestCase): 22 | test_file = 'valid.json' 23 | 24 | def test_finds_all_languages(self): 25 | self.assertEqual(len(self.resources), 3) 26 | 27 | def test_finds_correct_languages(self): 28 | self.assertIn('default', self.resources) 29 | self.assertIn('de', self.resources) 30 | self.assertIn('pl', self.resources) 31 | 32 | def test_finds_all_strings(self): 33 | for language in ['default', 'de', 'pl']: 34 | strings = self.resources[language] 35 | self.assertEqual(2, strings.count()) 36 | self.assertIn('string', strings) 37 | self.assertIn('partial_string', strings) 38 | 39 | 40 | class EmptyRowTestCase(BaseSpreadsheetDataTestCase): 41 | test_file = 'empty_row.json' 42 | 43 | def test_finds_correct_number_of_strings(self): 44 | for language in ['default', 'de', 'pl']: 45 | strings = self.resources[language] 46 | self.assertEqual(2, strings.count()) 47 | self.assertIn('string', strings) 48 | self.assertIn('string_2', strings) 49 | 50 | 51 | class RowWithMissingIdTestCase(BaseSpreadsheetDataTestCase): 52 | test_file = 'missing_id_row.json' 53 | 54 | def test_finds_correct_number_of_strings(self): 55 | for language in ['default', 'de', 'pl']: 56 | strings = self.resources[language] 57 | self.assertEqual(2, strings.count()) 58 | self.assertIn('string', strings) 59 | self.assertIn('string_2', strings) 60 | 61 | 62 | class RowWithIncorrectlyNamedIdTestCase(BaseSpreadsheetDataTestCase): 63 | test_file = 'incorrectly_named_id.json' 64 | 65 | def test_finds_correct_number_of_strings(self): 66 | for language in ['default', 'de', 'pl']: 67 | strings = self.resources[language] 68 | self.assertEqual(2, strings.count()) 69 | self.assertIn('string', strings) 70 | self.assertIn('string_2', strings) 71 | 72 | 73 | if __name__ == '__main__': 74 | unittest.main() 75 | -------------------------------------------------------------------------------- /stringsheet/comparator.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import re 3 | 4 | from . import constants 5 | 6 | ARRAY_ID_PATTERN = re.compile('^(\w+)\[(\d+)\]$') 7 | """Pattern for matching strings in array format. 8 | 9 | Example: ``name[0]`` 10 | """ 11 | 12 | PLURAL_ID_PATTERN = re.compile('^(\w+){(zero|one|two|few|many|other)\}$') 13 | """Pattern for matching strings in plural format. 14 | 15 | Example: ``name{zero}``, ``name{many}`` 16 | """ 17 | 18 | 19 | def _compare_alphabetically(a, b): 20 | return (a > b) - (a < b) 21 | 22 | 23 | def _compare_quantities(a, b): 24 | return constants.QUANTITIES.index(a) - constants.QUANTITIES.index(b) 25 | 26 | 27 | def compare_strings(a, b): 28 | """Compare two spreadsheet string ids and return their order. 29 | 30 | The format of provided parameters should be as follows: 31 | - string_name - regular string 32 | - string_name[index] - array string 33 | - string_name{quantity} - plural string 34 | 35 | The order returned by this function is as follows: 36 | 1. Regular strings sorted alphabetically, 37 | 2. String arrays sorted alphabetically, with each array sorted by index. 38 | 3. Plurals sorted alphabetically, with each plural sorted based on 39 | quantity. 40 | 41 | And the quantity order is as follows: zero, one, two, few, many, other 42 | 43 | Args: 44 | a (str): The first string in spreadsheet string id format. 45 | b (str): The second string in spreadsheet string id format. 46 | 47 | Returns: 48 | int: Positive value if the first id should come before second id, 49 | negative value if it should come after second id and 0 if both 50 | ids are equal. 51 | """ 52 | a_is_array = ARRAY_ID_PATTERN.match(a) 53 | b_is_array = ARRAY_ID_PATTERN.match(b) 54 | a_is_plural = PLURAL_ID_PATTERN.match(a) 55 | b_is_plural = PLURAL_ID_PATTERN.match(b) 56 | 57 | if a_is_array: 58 | if b_is_plural: 59 | # Arrays before plurals 60 | return -1 61 | if not b_is_array: 62 | # Arrays after strings 63 | return 1 64 | a_name = a_is_array.group(1) 65 | b_name = b_is_array.group(1) 66 | if a_name == b_name: 67 | # For the same array compare by index 68 | return int(a_is_array.group(2)) - int(b_is_array.group(2)) 69 | return _compare_alphabetically(a_name, b_name) 70 | 71 | if a_is_plural: 72 | if not b_is_plural: 73 | # Plurals after strings and arrays 74 | return 1 75 | a_name = a_is_plural.group(1) 76 | b_name = b_is_plural.group(1) 77 | if a_name == b_name: 78 | return _compare_quantities(a_is_plural.group(2), 79 | b_is_plural.group(2)) 80 | return _compare_alphabetically(a_name, b_name) 81 | 82 | if b_is_array or b_is_plural: 83 | # Strings before arrays and plurals 84 | return -1 85 | 86 | return _compare_alphabetically(a, b) 87 | 88 | 89 | def quantity_order(quantity): 90 | return constants.QUANTITIES.index(quantity) 91 | 92 | 93 | string_order = functools.cmp_to_key(compare_strings) 94 | -------------------------------------------------------------------------------- /stringsheet/writer.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import os 3 | 4 | from lxml import etree 5 | 6 | 7 | def _indent(element, indent_char='\t', level=0): 8 | indent_text = '\n' + level * indent_char 9 | if len(element): 10 | if not element.text or not element.text.strip(): 11 | element.text = indent_text + indent_char 12 | if not element.tail or not element.tail.strip(): 13 | element.tail = indent_text 14 | for element in element: 15 | _indent(element, indent_char, level + 1) 16 | if not element.tail or not element.tail.strip(): 17 | element.tail = indent_text 18 | elif level and (not element.tail or not element.tail.strip()): 19 | element.tail = indent_text 20 | 21 | 22 | def builds_strings_tree(resources): 23 | root = etree.Element('resources') 24 | 25 | for string in resources.sorted_strings: 26 | if not string.text: 27 | continue 28 | 29 | xml_string = etree.SubElement(root, 'string', name=string.name) 30 | xml_string.text = string.text 31 | 32 | for array in resources.sorted_arrays: 33 | has_text = False 34 | for item in array: 35 | if item.text: 36 | has_text = True 37 | break 38 | 39 | if not has_text: 40 | continue 41 | 42 | string_array = etree.SubElement(root, 'string-array', name=array.name) 43 | for item in array: 44 | etree.SubElement(string_array, 'item').text = item.text 45 | 46 | for plural in resources.sorted_plurals: 47 | has_text = False 48 | for item in plural.sorted_items: 49 | if item.text: 50 | has_text = True 51 | break 52 | 53 | if not has_text: 54 | continue 55 | 56 | plurals = etree.SubElement(root, 'plurals', name=plural.name) 57 | for item in plural.sorted_items: 58 | xml_item = etree.SubElement(plurals, 'item', quantity=item.quantity) 59 | xml_item.text = item.text 60 | 61 | _indent(root) 62 | return etree.ElementTree(root) 63 | 64 | 65 | def get_strings_text(resources): 66 | tree = builds_strings_tree(resources) 67 | return etree.tostring(tree, 68 | pretty_print=True, 69 | xml_declaration=True, 70 | encoding='utf-8', 71 | with_tail=False) 72 | 73 | 74 | def write_strings_file(directory, resources): 75 | tree = builds_strings_tree(resources) 76 | file_path = os.path.join(directory, 'strings.xml') 77 | tree.write(file_path, 78 | pretty_print=True, 79 | xml_declaration=True, 80 | encoding='utf-8', 81 | with_tail=False) 82 | 83 | 84 | def _make_dir(path): 85 | try: 86 | os.makedirs(path) 87 | except OSError as e: 88 | if e.errno != errno.EEXIST: 89 | raise 90 | 91 | 92 | def write_strings_to_directory(strings_by_language, target_dir): 93 | _make_dir(target_dir) 94 | for language in strings_by_language.languages(): 95 | values_dir = os.path.join(target_dir, 'values-' + language) 96 | _make_dir(values_dir) 97 | 98 | write_strings_file(values_dir, strings_by_language[language]) 99 | -------------------------------------------------------------------------------- /tests/test_create_spreadsheet_values.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from stringsheet.parser import create_spreadsheet_values 4 | from stringsheet.parser import create_language_sheet_values 5 | from stringsheet.parser import parse_resources 6 | 7 | 8 | class BaseTestCase(unittest.TestCase): 9 | def setUp(self): 10 | self.resources = parse_resources('test-resources/res') 11 | 12 | 13 | class CreateSpreadsheetValuesTestCase(BaseTestCase): 14 | def setUp(self): 15 | super(CreateSpreadsheetValuesTestCase, self).setUp() 16 | self.values = create_spreadsheet_values(self.resources) 17 | 18 | def test_rows_are_valid(self): 19 | rows = [ 20 | ['id', 'comment', 'default', 'de', 'pl', 'zh-rCN', 'zh-rTW'], 21 | ['a_string', '', 'A string', '', '', '', ''], 22 | ['partly_added', '', 'Partly added', 'Partly added (de)', '', '', 23 | ''], 24 | ['string', 'String with comment', 'String', 'String (de)', 25 | 'String (pl)', 'String (zh-rCN)', 'String (zh-rTW)'], 26 | ['string_2', '', 'String 2', '', '', '', ''], 27 | ['array[0]', 'Item comment', 'First', '', '', '', ''], 28 | ['array[1]', '', 'Second', '', '', '', ''], 29 | ['array_comment[0]', 'Array comment', 'Some item', '', '', '', ''], 30 | ['array_comment[1]', 'Array comment', 'More items', '', '', '', ''], 31 | ['array_comment[2]', 'Comment', 'More', '', '', '', ''], 32 | ['plural{zero}', 'Parent comment', 'Other', '', '', '', ''], 33 | ['plural{one}', 'Parent comment', 'One', '', '', '', ''], 34 | ['plural{two}', 'Parent comment', 'Other', '', '', '', ''], 35 | ['plural{few}', 'Parent comment', 'Other', '', '', '', ''], 36 | ['plural{many}', 'Parent comment', 'Other', '', '', '', ''], 37 | ['plural{other}', 'Comment', 'Other', '', '', '', ''], 38 | ['plurals{zero}', 'Item comment', 'Zero', '', '', '', ''], 39 | ['plurals{one}', '', 'One', '', '', '', ''], 40 | ['plurals{two}', '', 'Two', '', '', '', ''], 41 | ['plurals{few}', '', 'Few', '', '', '', ''], 42 | ['plurals{many}', '', 'Many', '', '', '', ''], 43 | ['plurals{other}', '', 'Other', '', '', '', ''], 44 | ] 45 | self.assertEqual(len(rows), len(self.values)) 46 | for index, row in enumerate(rows): 47 | self.assertEqual(row, self.values[index]) 48 | 49 | 50 | class CreateLanguageSpreadsheetValuesTestCase(BaseTestCase): 51 | def setUp(self): 52 | super(CreateLanguageSpreadsheetValuesTestCase, self).setUp() 53 | self.values = create_language_sheet_values(self.resources, 'de') 54 | 55 | def test_rows_are_valid(self): 56 | rows = [ 57 | ['id', 'comment', 'default', 'de'], 58 | ['a_string', '', 'A string', ''], 59 | ['partly_added', '', 'Partly added', 'Partly added (de)'], 60 | ['string', 'String with comment', 'String', 'String (de)'], 61 | ['string_2', '', 'String 2', ''], 62 | ['array[0]', 'Item comment', 'First', ''], 63 | ['array[1]', '', 'Second', ''], 64 | ['array_comment[0]', 'Array comment', 'Some item', ''], 65 | ['array_comment[1]', 'Array comment', 'More items', ''], 66 | ['array_comment[2]', 'Comment', 'More', ''], 67 | ['plural{zero}', 'Parent comment', 'Other', ''], 68 | ['plural{one}', 'Parent comment', 'One', ''], 69 | ['plural{two}', 'Parent comment', 'Other', ''], 70 | ['plural{few}', 'Parent comment', 'Other', ''], 71 | ['plural{many}', 'Parent comment', 'Other', ''], 72 | ['plural{other}', 'Comment', 'Other', ''], 73 | ['plurals{zero}', 'Item comment', 'Zero', ''], 74 | ['plurals{one}', '', 'One', ''], 75 | ['plurals{two}', '', 'Two', ''], 76 | ['plurals{few}', '', 'Few', ''], 77 | ['plurals{many}', '', 'Many', ''], 78 | ['plurals{other}', '', 'Other', ''], 79 | ] 80 | self.assertEqual(len(rows), len(self.values)) 81 | for index, row in enumerate(rows): 82 | self.assertEqual(row, self.values[index]) 83 | 84 | 85 | class CreateTemplateSpreadsheetValuesTestCase(BaseTestCase): 86 | def setUp(self): 87 | super(CreateTemplateSpreadsheetValuesTestCase, self).setUp() 88 | self.values = create_language_sheet_values(self.resources, 'Template') 89 | 90 | def test_rows_are_valid(self): 91 | rows = [ 92 | ['id', 'comment', 'default', 'language-id'], 93 | ['a_string', '', 'A string', ''], 94 | ['partly_added', '', 'Partly added', ''], 95 | ['string', 'String with comment', 'String', ''], 96 | ['string_2', '', 'String 2', ''], 97 | ['array[0]', 'Item comment', 'First', ''], 98 | ['array[1]', '', 'Second', ''], 99 | ['array_comment[0]', 'Array comment', 'Some item', ''], 100 | ['array_comment[1]', 'Array comment', 'More items', ''], 101 | ['array_comment[2]', 'Comment', 'More', ''], 102 | ['plural{zero}', 'Parent comment', 'Other', ''], 103 | ['plural{one}', 'Parent comment', 'One', ''], 104 | ['plural{two}', 'Parent comment', 'Other', ''], 105 | ['plural{few}', 'Parent comment', 'Other', ''], 106 | ['plural{many}', 'Parent comment', 'Other', ''], 107 | ['plural{other}', 'Comment', 'Other', ''], 108 | ['plurals{zero}', 'Item comment', 'Zero', ''], 109 | ['plurals{one}', '', 'One', ''], 110 | ['plurals{two}', '', 'Two', ''], 111 | ['plurals{few}', '', 'Few', ''], 112 | ['plurals{many}', '', 'Many', ''], 113 | ['plurals{other}', '', 'Other', ''], 114 | ] 115 | self.assertEqual(len(rows), len(self.values)) 116 | for index, row in enumerate(rows): 117 | self.assertEqual(row, self.values[index]) 118 | 119 | 120 | if __name__ == '__main__': 121 | unittest.main() 122 | -------------------------------------------------------------------------------- /stringsheet/api.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import webbrowser 4 | 5 | import httplib2 6 | from apiclient import discovery 7 | from oauth2client import client 8 | from oauth2client.file import Storage 9 | 10 | SCOPES = 'https://www.googleapis.com/auth/spreadsheets' 11 | CLIENT_SECRET_FILE = 'client_secret.json' 12 | APPLICATION_NAME = 'StringSheet' 13 | 14 | _BROWSER_OPENED_MESSAGE = """ 15 | Your browser has been opened to visit: 16 | 17 | {address} 18 | """ 19 | 20 | _CODE_PROMPT = 'Please authenticate and enter verification code: ' 21 | 22 | 23 | def _get_credentials(): 24 | """Get valid user credentials from storage. 25 | 26 | If nothing has been stored, or if the stored credentials are invalid, 27 | the OAuth2 flow is completed to obtain the new credentials. 28 | 29 | Returns: 30 | Credentials, the obtained credential. 31 | """ 32 | home_dir = os.path.expanduser('~') 33 | credential_dir = os.path.join(home_dir, '.cache', 'credentials') 34 | if not os.path.exists(credential_dir): 35 | os.makedirs(credential_dir) 36 | credential_path = os.path.join( 37 | credential_dir, 'sheets.googleapis.stringsheet.json') 38 | 39 | store = Storage(credential_path) 40 | credentials = store.get() if os.path.isfile(credential_path) else None 41 | 42 | if not credentials or credentials.invalid: 43 | flow = client.OAuth2WebServerFlow( 44 | client_id='544612569534-bul8pt6lt594cilmt545rrte934hanuc' 45 | '.apps.googleusercontent.com', 46 | client_secret='4uo68aoi6j-YjXrCXqKUsyHi', 47 | scope=SCOPES, 48 | redirect_uri=client.OOB_CALLBACK_URN, 49 | auth_uri='https://accounts.google.com/o/oauth2/auth', 50 | token_uri='https://accounts.google.com/o/oauth2/token' 51 | ) 52 | flow.user_agent = APPLICATION_NAME 53 | authorize_url = flow.step1_get_authorize_url() 54 | webbrowser.open(authorize_url) 55 | 56 | try: 57 | print(_BROWSER_OPENED_MESSAGE.format(address=authorize_url)) 58 | code = input(_CODE_PROMPT).strip() 59 | except KeyboardInterrupt: 60 | sys.exit('\nAuthentication cancelled.') 61 | 62 | try: 63 | credentials = flow.step2_exchange(code) 64 | except client.FlowExchangeError as e: 65 | sys.exit('Authentication has failed: {0}'.format(e)) 66 | 67 | print('Storing credentials to ' + credential_path) 68 | store.put(credentials) 69 | credentials.set_store(store) 70 | 71 | print('Authentication successful.') 72 | 73 | return credentials 74 | 75 | 76 | def get_service(): 77 | """Construct a Resource for interacting with Google Spreadsheets API.""" 78 | credentials = _get_credentials() 79 | http = credentials.authorize(httplib2.Http()) 80 | discovery_url = 'https://sheets.googleapis.com/$discovery/rest?version=v4' 81 | return discovery.build('sheets', 'v4', http=http, 82 | discoveryServiceUrl=discovery_url) 83 | 84 | 85 | def create_spreadsheet(service, body): 86 | return service.spreadsheets().create(body=body).execute() 87 | 88 | 89 | def get_spreadsheet(service, spreadsheet_id): 90 | return service.spreadsheets().get( 91 | spreadsheetId=spreadsheet_id 92 | ).execute() 93 | 94 | 95 | def batch_update_values(service, spreadsheet_id, body): 96 | return service.spreadsheets().values().batchUpdate( 97 | spreadsheetId=spreadsheet_id, 98 | body=body 99 | ).execute() 100 | 101 | 102 | def batch_get_values(service, spreadsheet_id, ranges): 103 | return service.spreadsheets().values().batchGet( 104 | spreadsheetId=spreadsheet_id, 105 | ranges=ranges 106 | ).execute() 107 | 108 | 109 | def batch_update(service, spreadsheet_id, requests): 110 | return service.spreadsheets().batchUpdate( 111 | spreadsheetId=spreadsheet_id, 112 | body={"requests": requests} 113 | ).execute() 114 | 115 | 116 | def create_add_sheet_request(sheet_id, title): 117 | return { 118 | 'addSheet': { 119 | 'properties': { 120 | 'sheetId': sheet_id, 121 | 'title': title 122 | } 123 | } 124 | } 125 | 126 | 127 | def create_value_range(language, values): 128 | return { 129 | 'range': language + "!A:Z", 130 | 'values': values 131 | } 132 | 133 | 134 | def create_protected_range_request(sheet_id, start_row_index): 135 | return { 136 | 'addProtectedRange': { 137 | 'protectedRange': { 138 | 'range': { 139 | 'sheetId': sheet_id 140 | }, 141 | 'unprotectedRanges': [{ 142 | 'sheetId': sheet_id, 143 | 'startRowIndex': start_row_index, 144 | 'startColumnIndex': 3, 145 | }], 146 | 'editors': { 147 | # Required otherwise protection doesn't work correctly 148 | 'users': [] 149 | }, 150 | 'warningOnly': False 151 | } 152 | } 153 | } 154 | 155 | 156 | def create_frozen_properties_request(sheet_id, frozen_row_count, 157 | frozen_column_count): 158 | return { 159 | 'updateSheetProperties': { 160 | 'properties': { 161 | 'sheetId': sheet_id, 162 | 'gridProperties': { 163 | 'frozenRowCount': frozen_row_count, 164 | 'frozenColumnCount': frozen_column_count 165 | } 166 | }, 167 | 'fields': 'gridProperties.frozenRowCount,' 168 | 'gridProperties.frozenColumnCount' 169 | } 170 | } 171 | 172 | 173 | def create_conditional_format_request(sheet_id, start_row, end_row, 174 | start_column, end_column): 175 | return { 176 | 'addConditionalFormatRule': { 177 | 'rule': { 178 | 'ranges': [{ 179 | 'sheetId': sheet_id, 180 | 'startRowIndex': start_row, 181 | 'endRowIndex': end_row, 182 | 'startColumnIndex': start_column, 183 | 'endColumnIndex': end_column 184 | }], 185 | 'booleanRule': { 186 | 'condition': { 187 | 'type': 'BLANK' 188 | }, 189 | 'format': { 190 | 'backgroundColor': { 191 | 'red': 244 / 255, 192 | 'green': 199 / 255, 193 | 'blue': 195 / 255, 194 | 'alpha': 1 195 | } 196 | } 197 | } 198 | } 199 | } 200 | } 201 | 202 | 203 | def create_spreadsheet_body(title, multi_sheet, languages, row_count): 204 | if multi_sheet: 205 | sheets = [{ 206 | 'properties': { 207 | 'title': 'Overview', 208 | 'sheetId': 0 209 | }, 210 | }] 211 | 212 | column_metadata = [{'pixelSize': 250} for _ in range(4)] 213 | 214 | # Add template entry to allow for easy addition of new languages 215 | # by translators 216 | languages.insert(0, 'Template') 217 | 218 | sheet_id = 1 219 | 220 | for language in languages: 221 | sheets.append({ 222 | 'properties': { 223 | 'title': language, 224 | 'sheetId': sheet_id, 225 | 'gridProperties': { 226 | 'rowCount': row_count, 227 | 'columnCount': 4, 228 | 'frozenRowCount': 1 229 | } 230 | }, 231 | 'data': [{ 232 | 'startColumn': 0, 233 | 'columnMetadata': column_metadata 234 | }] 235 | }) 236 | sheet_id += 1 237 | else: 238 | num_columns = len(languages) + 3 239 | column_metadata = [{'pixelSize': 250} for _ in range(num_columns)] 240 | sheets = [{ 241 | 'properties': { 242 | 'title': 'Translations', 243 | 'sheetId': 0, 244 | 'gridProperties': { 245 | 'rowCount': row_count, 246 | 'frozenRowCount': 1, 247 | 'frozenColumnCount': 3 248 | } 249 | }, 250 | 'data': [{ 251 | 'startColumn': 0, 252 | 'columnMetadata': column_metadata 253 | }] 254 | }] 255 | return { 256 | 'properties': { 257 | 'title': title, 258 | 'defaultFormat': { 259 | 'wrapStrategy': 'WRAP' 260 | } 261 | }, 262 | 'sheets': sheets 263 | } 264 | -------------------------------------------------------------------------------- /stringsheet/main.py: -------------------------------------------------------------------------------- 1 | from . import api 2 | from . import model 3 | from . import parser 4 | from . import writer 5 | 6 | 7 | def create(project_name, source_dir='.', multi_sheet=False): 8 | """Create new Google Spreadsheet for managing translations. 9 | 10 | Args: 11 | project_name (str): The name of your Android project. This will 12 | be used to name the spreadsheet. 13 | source_dir (str): A path to the resources directory of your 14 | Android project. 15 | multi_sheet (bool): Upload each language to a separate sheet 16 | (in the same file) 17 | """ 18 | service = _authenticate() 19 | resources = _parse_resources(source_dir) 20 | spreadsheet_id = _create_spreadsheet(service, project_name, multi_sheet, 21 | resources) 22 | 23 | spreadsheet_link = create_link(spreadsheet_id) 24 | print('Link:', spreadsheet_link) 25 | 26 | _upload(service, spreadsheet_id, resources) 27 | _create_formatting_rules(service, spreadsheet_id, multi_sheet, resources) 28 | print() 29 | print('Success') 30 | 31 | 32 | def upload(spreadsheet_id, source_dir='.'): 33 | """Uploads project strings to Google Spreadsheet. 34 | 35 | If ``spreadsheet_id`` is empty a new spreadsheet will be created. 36 | 37 | ``project_title`` is only required when no ``spreadsheet_id`` is specified. 38 | It will be then used to give a name to the newly created spreadsheet. 39 | 40 | Args: 41 | spreadsheet_id (str): The id of the Google Spreadsheet to use. 42 | source_dir (str): A path to the resources directory of your Android 43 | project. 44 | """ 45 | service = _authenticate() 46 | resources = _parse_resources(source_dir) 47 | _upload(service, spreadsheet_id, resources) 48 | print() 49 | print('Success') 50 | 51 | 52 | def download(spreadsheet_id, target_dir='.'): 53 | """Parse Google spreadsheet and save the result as Android strings. 54 | 55 | Parse the spreadsheet with the specified ``spreadsheet_id`` and save 56 | the result as Android values directories with strings files in the 57 | specified ``target_dir``. 58 | 59 | Args: 60 | spreadsheet_id (str): The id of the Google spreadsheet to parse. 61 | target_dir (str): A path to the directory where the resulting files 62 | should be saved. Usually you want to set this to the resources 63 | directory of your Android project. 64 | """ 65 | service = _authenticate() 66 | strings_by_language = _download_strings(service, spreadsheet_id) 67 | _write_strings(strings_by_language, target_dir) 68 | print() 69 | print('Success') 70 | 71 | 72 | def create_link(spreadsheet_id): 73 | return 'https://docs.google.com/spreadsheets/d/{}/edit'.format( 74 | spreadsheet_id) 75 | 76 | 77 | def _authenticate(): 78 | print(':: Authenticating...') 79 | service = api.get_service() 80 | return service 81 | 82 | 83 | def _parse_resources(source_dir): 84 | print(':: Parsing strings...') 85 | resources = parser.parse_resources(source_dir) 86 | 87 | num_languages = len(resources.languages()) 88 | num_strings = resources['default'].count() 89 | print('Found %d languages and %d strings' % (num_languages, num_strings)) 90 | 91 | return resources 92 | 93 | 94 | def _create_spreadsheet(service, project_name, multi_sheet, resources): 95 | print(':: Creating spreadsheet...') 96 | spreadsheet_name = project_name + ' (Translations)' 97 | spreadsheet_body = api.create_spreadsheet_body( 98 | spreadsheet_name, 99 | multi_sheet, 100 | resources.languages(), 101 | resources['default'].item_count() + 1) 102 | response = api.create_spreadsheet(service, spreadsheet_body) 103 | 104 | spreadsheet_id = response['spreadsheetId'] 105 | print('Created new spreadsheet with id:', spreadsheet_id) 106 | 107 | return spreadsheet_id 108 | 109 | 110 | def _upload(service, spreadsheet_id, resources): 111 | print(':: Uploading strings...') 112 | 113 | data = [] 114 | requests = [] 115 | 116 | free_sheet_id, sheet_id_by_title = _get_sheets(service, spreadsheet_id) 117 | languages = resources.languages() 118 | 119 | num_valid = sum(1 for language in sheet_id_by_title.keys() 120 | if parser.is_language_valid(language)) 121 | has_template = 'Template' in sheet_id_by_title 122 | 123 | if num_valid == 0 and not has_template: 124 | values = parser.create_spreadsheet_values(resources) 125 | data.append({ 126 | 'range': 'A:Z', 127 | 'values': values 128 | }) 129 | else: 130 | languages.insert(0, 'Template') 131 | for language in languages: 132 | values = parser.create_language_sheet_values(resources, language) 133 | data.append(api.create_value_range(language, values)) 134 | 135 | if language not in sheet_id_by_title: 136 | requests.append(api.create_add_sheet_request( 137 | free_sheet_id, language 138 | )) 139 | requests.append(api.create_frozen_properties_request( 140 | free_sheet_id, 1, 0 141 | )) 142 | free_sheet_id += 1 143 | 144 | if requests: 145 | api.batch_update(service, spreadsheet_id, requests) 146 | 147 | body = { 148 | 'valueInputOption': 'RAW', 149 | 'data': data 150 | } 151 | 152 | response = api.batch_update_values(service, spreadsheet_id, body) 153 | 154 | print('Strings uploaded:') 155 | print(' > Updated rows: %d' % response['totalUpdatedRows']) 156 | print(' > Updated columns: %d' % response['totalUpdatedColumns']) 157 | print(' > Updated cells: %d' % response['totalUpdatedCells']) 158 | print(' > Updated sheets: %d' % response['totalUpdatedSheets']) 159 | 160 | return response 161 | 162 | 163 | def _create_formatting_rules(service, spreadsheet_id, multi_sheet, resources): 164 | print(':: Creating formatting rules...') 165 | languages = resources.languages() 166 | num_languages = len(languages) 167 | num_columns = num_languages + 3 168 | num_rows = resources['default'].item_count() + 1 169 | num_sheets = num_languages + 2 if multi_sheet else 1 170 | 171 | start_index = 1 if multi_sheet else 0 172 | 173 | requests = [] 174 | start_row_index = 1 if multi_sheet else 0 175 | for i in range(start_index, num_sheets): 176 | requests.append(api.create_protected_range_request(i, start_row_index)) 177 | requests.append(api.create_conditional_format_request( 178 | i, 1, num_rows, 3, num_columns)) 179 | 180 | api.batch_update(service, spreadsheet_id, requests) 181 | 182 | 183 | def _download_strings(service, spreadsheet_id): 184 | print(':: Downloading strings...') 185 | ranges = _get_sheet_ranges(service, spreadsheet_id) 186 | response = api.batch_get_values(service, spreadsheet_id, ranges) 187 | 188 | resource_container = model.ResourceContainer() 189 | for value_range in response['valueRanges']: 190 | if 'values' not in value_range: 191 | continue 192 | values = value_range['values'] 193 | parser.parse_spreadsheet_values(resource_container, values) 194 | 195 | num_languages = len(resource_container) - 1 196 | print('Downloaded translations in %d languages:' % num_languages) 197 | 198 | total_strings = resource_container['default'].count() 199 | 200 | for language in resource_container.languages(): 201 | language_strings = resource_container[language] 202 | num_strings = language_strings.count() 203 | progress = (num_strings / total_strings) * 100 204 | print(' > %s: %d/%d (%d%%)' 205 | % (language, num_strings, total_strings, progress)) 206 | 207 | return resource_container 208 | 209 | 210 | def _write_strings(strings_by_language, target_dir): 211 | print(':: Saving string files...') 212 | writer.write_strings_to_directory(strings_by_language, target_dir) 213 | 214 | print('Saved all strings to "%s"' % target_dir) 215 | 216 | 217 | def _get_sheets(service, spreadsheet_id): 218 | response = api.get_spreadsheet(service, spreadsheet_id) 219 | sheet_id_by_title = {} 220 | free_sheet_id = 1 221 | for sheet in response['sheets']: 222 | properties = sheet['properties'] 223 | sheet_id = properties['sheetId'] 224 | title = properties['title'] 225 | sheet_id_by_title[title] = sheet_id 226 | free_sheet_id = max(free_sheet_id, sheet_id + 1) 227 | return free_sheet_id, sheet_id_by_title 228 | 229 | 230 | def _get_sheet_ranges(service, spreadsheet_id): 231 | spreadsheet = api.get_spreadsheet(service, spreadsheet_id) 232 | ranges = [] 233 | 234 | for sheet in spreadsheet['sheets']: 235 | title = sheet['properties']['title'] 236 | if parser.is_language_valid(title): 237 | ranges.append("'%s'" % title) 238 | 239 | return ranges if ranges else ['A:Z'] 240 | -------------------------------------------------------------------------------- /stringsheet/model.py: -------------------------------------------------------------------------------- 1 | from operator import attrgetter 2 | 3 | from stringsheet import comparator 4 | 5 | 6 | def _is_translatable(element): 7 | return element.get('translatable', 'true').lower() == 'true' 8 | 9 | 10 | def _is_reference(element): 11 | return element.text.startswith(('@', '?')) 12 | 13 | 14 | class String: 15 | """Model representing tag in Android string resources.""" 16 | 17 | def __init__(self, name, text, comment): 18 | self.text = text 19 | self.name = name 20 | self.comment = comment 21 | 22 | @staticmethod 23 | def is_valid(element): 24 | return (element.tag == 'string' and 25 | 'name' in element.attrib and 26 | _is_translatable(element) and 27 | not _is_reference(element)) 28 | 29 | 30 | class StringArrayItem: 31 | """Model representing tag for arrays in Android string resources.""" 32 | 33 | def __init__(self, text, comment): 34 | self.text = text 35 | self.comment = comment 36 | 37 | @staticmethod 38 | def is_valid(element): 39 | return element.tag == 'item' and not _is_reference(element) 40 | 41 | 42 | class StringArray: 43 | """Model representing tag in Android string resources.""" 44 | 45 | def __init__(self, name, comment): 46 | self.name = name 47 | self.comment = comment 48 | self._items = [] 49 | 50 | def __len__(self): 51 | return len(self._items) 52 | 53 | def __getitem__(self, index): 54 | return self._items[index] 55 | 56 | def insert(self, index, item): 57 | self._items.insert(index, item) 58 | 59 | def add_item(self, text, comment): 60 | item = StringArrayItem(text, comment) 61 | self._items.append(item) 62 | 63 | @staticmethod 64 | def is_valid(element): 65 | return (element.tag == 'string-array' and 66 | 'name' in element.attrib and 67 | _is_translatable(element)) 68 | 69 | 70 | class PluralItem: 71 | """Model representing tag in Android string resources.""" 72 | 73 | def __init__(self, quantity, text, comment): 74 | self.quantity = quantity 75 | self.text = text 76 | self.comment = comment 77 | 78 | @staticmethod 79 | def is_valid(element): 80 | return (element.tag == 'item' and 81 | 'quantity' in element.attrib and 82 | not _is_reference(element)) 83 | 84 | 85 | class PluralString: 86 | """Model representing tag for plurals in Android string resources.""" 87 | 88 | def __init__(self, name, comment): 89 | self.name = name 90 | self.comment = comment 91 | self._items = {} 92 | 93 | def __getitem__(self, quantity): 94 | return self._items[quantity] 95 | 96 | def __setitem__(self, quantity, plural_item): 97 | self._items[quantity] = plural_item 98 | 99 | def __len__(self): 100 | return len(self._items) 101 | 102 | def __contains__(self, quantity): 103 | return quantity in self._items 104 | 105 | @property 106 | def sorted_items(self): 107 | return sorted(self._items.values(), 108 | key=lambda item: comparator.quantity_order(item.quantity)) 109 | 110 | @staticmethod 111 | def is_valid(element): 112 | return (element.tag == 'plurals' and 113 | 'name' in element.attrib and 114 | _is_translatable(element)) 115 | 116 | 117 | class Resources: 118 | """Model representing tag in Android string resources.""" 119 | 120 | def __init__(self): 121 | self._strings = {} 122 | self._arrays = {} 123 | self._plurals = {} 124 | 125 | def __contains__(self, item): 126 | return (item in self._strings 127 | or item in self._arrays 128 | or item in self._plurals) 129 | 130 | @staticmethod 131 | def is_valid(element): 132 | return element.tag == 'resources' and _is_translatable(element) 133 | 134 | @property 135 | def sorted_strings(self): 136 | """Return a sorted list of strings stored in this model. 137 | 138 | Returns: 139 | list: List of strings sorted alphabetically based on their name. 140 | """ 141 | return sorted(self._strings.values(), key=attrgetter('name')) 142 | 143 | @property 144 | def sorted_arrays(self): 145 | """Return a sorted list of arrays stored in this model. 146 | 147 | Returns: 148 | list: List of string arrays sorted alphabetically based on their 149 | name. 150 | """ 151 | return sorted(self._arrays.values(), key=attrgetter('name')) 152 | 153 | @property 154 | def sorted_plurals(self): 155 | """Return a sorted list of plurals stored in this model 156 | 157 | Returns: 158 | list: List of plurals stored alphabetically based on their name. 159 | """ 160 | return sorted(self._plurals.values(), key=attrgetter('name')) 161 | 162 | def count(self): 163 | """Return a number of all models stored in this model. 164 | 165 | Returns: 166 | int: Number of strings, string arrays and plurals. 167 | """ 168 | return len(self._strings) + len(self._arrays) + len(self._plurals) 169 | 170 | def item_count(self): 171 | """Return a number of all translatable items stored in this model. 172 | 173 | The translatable items are these XML tags: 174 | - string 175 | - string-array > item 176 | - plurals > item 177 | 178 | This value corresponds to the number of rows that should be created 179 | in a spreadsheet to store all strings. 180 | 181 | Returns: 182 | int: Number of all translatable items. 183 | """ 184 | array_item_count = sum([len(it) for it in self._arrays.values()]) 185 | plural_item_count = sum([len(it) for it in self._plurals.values()]) 186 | return len(self._strings) + array_item_count + plural_item_count 187 | 188 | def get_string_text(self, name): 189 | """Return text of a string with the specified name.""" 190 | return self._strings[name].text if name in self._strings else '' 191 | 192 | def get_array_text(self, name, index): 193 | if name not in self._arrays: 194 | return '' 195 | return self._arrays[name].sorted_items[index].text 196 | 197 | def get_plural_text(self, name, quantity): 198 | if name not in self._plurals: 199 | return '' 200 | plural = self._plurals[name] 201 | return plural[quantity].text if quantity in plural else '' 202 | 203 | def add_string(self, string): 204 | self._strings[string.name] = string 205 | 206 | def add_array(self, string_array): 207 | self._arrays[string_array.name] = string_array 208 | 209 | def add_plural(self, plural): 210 | self._plurals[plural.name] = plural 211 | 212 | def add_array_item(self, name, text, comment, index): 213 | if name not in self._arrays: 214 | self._arrays[name] = StringArray(name, '') 215 | self._arrays[name].insert(index, StringArrayItem(text, comment)) 216 | 217 | def add_plural_item(self, name, text, comment, quantity): 218 | if name not in self._plurals: 219 | self._plurals[name] = PluralString(name, '') 220 | self._plurals[name][quantity] = PluralItem(quantity, text, comment) 221 | 222 | 223 | class ResourceContainer: 224 | """Model containing string resources for multiple languages. 225 | 226 | This model works like a dictionary and stores resources for languages 227 | mapped by their id. 228 | """ 229 | 230 | def __init__(self): 231 | self._resources_by_language = {} 232 | 233 | def __getitem__(self, language): 234 | return self._resources_by_language[language] 235 | 236 | def __setitem__(self, language, resources): 237 | self._resources_by_language[language] = resources 238 | 239 | def __contains__(self, language): 240 | return language in self._resources_by_language 241 | 242 | def __len__(self): 243 | return len(self._resources_by_language) 244 | 245 | def update(self, language, resources): 246 | if language not in self._resources_by_language: 247 | self._resources_by_language[language] = resources 248 | else: 249 | existing = self._resources_by_language[language] 250 | for string in resources.sorted_strings: 251 | existing.add_string(string) 252 | for array in resources.sorted_arrays: 253 | existing.add_array(array) 254 | for plural in resources.sorted_plurals: 255 | existing.add_plural(plural) 256 | 257 | def languages(self): 258 | """Return a sorted list of languages stored in this model. 259 | 260 | Returns: 261 | list: List of language ids sorted alphabetically. 262 | """ 263 | keys = self._resources_by_language.keys() 264 | return sorted([it for it in keys if it != 'default']) 265 | -------------------------------------------------------------------------------- /stringsheet/parser.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from lxml import etree 4 | 5 | from . import comparator 6 | from . import constants 7 | from . import model 8 | 9 | _COLUMN_LANGUAGE_ID_TEMPLATE = 'language-id' 10 | 11 | 12 | def parse_file(source, resources): 13 | """Parse the ``source`` file and extract all found strings to ``resources``. 14 | 15 | Args: 16 | source: The source object to parse. Can be any of the following: 17 | 18 | - a file name/path 19 | - a file object 20 | - a file-like object 21 | - a URL using the HTTP or FTP protocol 22 | 23 | resources: The resources model for storing the parsed strings. 24 | """ 25 | tree = etree.parse(source) 26 | root = tree.getroot() 27 | 28 | if not model.Resources.is_valid(root): 29 | return 30 | 31 | latest_comment = '' 32 | for element in root: 33 | if element.tag is etree.Comment: 34 | if element.tail.count('\n') <= 1: 35 | latest_comment = element.text.strip() 36 | continue 37 | 38 | name = element.get('name', None) 39 | if not name: 40 | latest_comment = '' 41 | continue 42 | 43 | if model.String.is_valid(element): 44 | resources.add_string(_parse_string(element, name, latest_comment)) 45 | 46 | elif model.StringArray.is_valid(element): 47 | resources.add_array(_parse_array(element, name, latest_comment)) 48 | 49 | elif model.PluralString.is_valid(element): 50 | resources.add_plural(_parse_plural(element, name, latest_comment)) 51 | 52 | latest_comment = '' 53 | 54 | 55 | def _parse_string(element, name, comment): 56 | return model.String(name, element.text, comment) 57 | 58 | 59 | def _parse_array(element, name, comment): 60 | string_array = model.StringArray(name, comment) 61 | latest_item_comment = comment 62 | for item in element: 63 | if item.tag is etree.Comment: 64 | latest_item_comment = _parse_comment(item, comment) 65 | continue 66 | 67 | if model.StringArrayItem.is_valid(item): 68 | string_array.add_item(item.text, latest_item_comment) 69 | 70 | latest_item_comment = comment 71 | return string_array 72 | 73 | 74 | def _parse_plural(element, name, comment): 75 | plural = model.PluralString(name, comment) 76 | latest_item_comment = comment 77 | for item in element: 78 | if item.tag is etree.Comment: 79 | latest_item_comment = _parse_comment(item, comment) 80 | continue 81 | 82 | if model.PluralItem.is_valid(item): 83 | quantity = item.get('quantity') 84 | plural[quantity] = model.PluralItem( 85 | quantity, item.text, latest_item_comment) 86 | 87 | latest_item_comment = comment 88 | 89 | for quantity in constants.QUANTITIES: 90 | if quantity not in plural: 91 | # TODO: What to do if plural has no 'other' quantity? 92 | other = plural['other'] 93 | plural[quantity] = model.PluralItem( 94 | quantity, other.text, comment) 95 | 96 | return plural 97 | 98 | 99 | def _parse_comment(item, latest_comment): 100 | return item.text.strip() if item.tail.count('\n') <= 1 else latest_comment 101 | 102 | 103 | def _is_file_valid(file_name): 104 | return file_name.endswith('.xml') and file_name != 'donottranslate.xml' 105 | 106 | 107 | def parse_directory(directory): 108 | """Parse XML files located under the specified directory as strings dict. 109 | 110 | The directory argument usually should point to one of the 'values-lang' 111 | directories located under res directory of an Android project. 112 | 113 | Args: 114 | directory (str): The path to directory with XML files to parse. 115 | 116 | Returns: 117 | model.Resources: A model with parsed resources. 118 | """ 119 | files = os.listdir(directory) 120 | xml_files = [file_name for file_name in files if _is_file_valid(file_name)] 121 | 122 | resources = model.Resources() 123 | for file_name in xml_files: 124 | file_path = os.path.join(directory, file_name) 125 | parse_file(file_path, resources) 126 | return resources 127 | 128 | 129 | def is_language_valid(language): 130 | if language == 'default': 131 | # Special case for identifying strings in primary language 132 | return True 133 | 134 | # Language code might contain a country separator 135 | language, sep, country = language.partition('-r') 136 | 137 | if sep and (not country or len(country) != 2): 138 | # If there was a separator there also must be a country with a length 139 | # of 2 letters. 140 | return False 141 | 142 | # All language codes must be 2 letters long 143 | return len(language) == 2 144 | 145 | 146 | def parse_resources(directory): 147 | """Parse all string resources located under the specified `directory``. 148 | 149 | This function assumes that the passed ``directory`` corresponds to the "res" 150 | directory of an Android project containing "values" directories with strings 151 | for each language. 152 | 153 | Args: 154 | directory (str): The path to res directory of an Android project 155 | containing values directories with strings for each language. 156 | 157 | Returns: 158 | model.ResourceContainer: A dictionary of strings mapped by language and 159 | then by string id. 160 | """ 161 | resources = model.ResourceContainer() 162 | for child_name in os.listdir(directory): 163 | if not child_name.startswith('values'): 164 | continue 165 | 166 | if child_name == 'values': 167 | language = 'default' 168 | else: 169 | _, _, language = child_name.partition('-') 170 | 171 | if is_language_valid(language): 172 | child_path = os.path.join(directory, child_name) 173 | resources[language] = parse_directory(child_path) 174 | return resources 175 | 176 | 177 | def create_language_sheet_values(resources, language): 178 | title = language if language != 'Template' else _COLUMN_LANGUAGE_ID_TEMPLATE 179 | return create_spreadsheet_values(resources, [title]) 180 | 181 | 182 | def create_spreadsheet_values(resources, languages=None): 183 | """Create rows and columns list that can be used to execute API calls. 184 | 185 | Args: 186 | resources (model.ResourceContainer): A model with strings parsed 187 | from Android XML strings files. 188 | languages (list): List of languages for which to create values. If not 189 | specified values will be created for all parsed languages. 190 | 191 | Returns: 192 | list: List of spreadsheet rows and columns. 193 | """ 194 | if not languages: 195 | languages = resources.languages() 196 | rows = [['id', 'comment', 'default'] + languages] 197 | 198 | is_template = (len(languages) == 1 199 | and languages[0] == _COLUMN_LANGUAGE_ID_TEMPLATE) 200 | 201 | default_strings = resources['default'] 202 | for string in default_strings.sorted_strings: 203 | row = [string.name, string.comment, string.text] 204 | if is_template: 205 | row.append('') 206 | else: 207 | for language in languages: 208 | row.append(resources[language].get_string_text(string.name)) 209 | rows.append(row) 210 | 211 | for array in default_strings.sorted_arrays: 212 | for index, item in enumerate(array): 213 | item_name = '{0}[{1}]'.format(array.name, index) 214 | row = [item_name, item.comment, item.text] 215 | if is_template: 216 | row.append('') 217 | else: 218 | for language in languages: 219 | row.append(resources[language].get_array_text( 220 | array.name, index)) 221 | rows.append(row) 222 | 223 | for plural in default_strings.sorted_plurals: 224 | for item in plural.sorted_items: 225 | item_name = '{0}{{{1}}}'.format(plural.name, item.quantity) 226 | row = [item_name, item.comment, item.text] 227 | if is_template: 228 | row.append('') 229 | else: 230 | for language in languages: 231 | row.append(resources[language].get_plural_text( 232 | plural.name, item.quantity)) 233 | rows.append(row) 234 | 235 | return rows 236 | 237 | 238 | def parse_spreadsheet_values(resource_container, values): 239 | """Parse the result returned by Google Spreadsheets API call. 240 | 241 | Args: 242 | resource_container (model.ResourceContainer): A model which will hold 243 | the parsed resources. 244 | values (dict): The json values data returned by Google Spreadsheets API. 245 | """ 246 | title_row = values[0] 247 | 248 | for lang_index in range(2, len(title_row)): 249 | language = title_row[lang_index] 250 | 251 | resources = model.Resources() 252 | for row in values[1:]: 253 | column_count = len(row) 254 | if column_count < 3: 255 | # Actual strings shouldn't be separated by an empty row. 256 | break 257 | 258 | translation = row[lang_index] if column_count > lang_index else '' 259 | string_id = row[0] 260 | comment = row[1] 261 | default_text = row[2] 262 | 263 | if not string_id or not default_text: 264 | # All strings must have id and a default text. 265 | break 266 | 267 | if ' ' in string_id: 268 | # String ids can't contain whitespace characters. 269 | # TODO: Check for more invalid characters 270 | break 271 | 272 | array_match = comparator.ARRAY_ID_PATTERN.match(string_id) 273 | if array_match: 274 | name = array_match.group(1) 275 | index = int(array_match.group(2)) 276 | resources.add_array_item(name, translation, comment, index) 277 | continue 278 | 279 | plural_match = comparator.PLURAL_ID_PATTERN.match(string_id) 280 | if plural_match: 281 | name = plural_match.group(1) 282 | quantity = plural_match.group(2) 283 | resources.add_plural_item(name, translation, comment, quantity) 284 | continue 285 | 286 | resources.add_string(model.String(string_id, translation, comment)) 287 | 288 | resource_container.update(language, resources) 289 | 290 | -------------------------------------------------------------------------------- /tests/test_parse_file.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from stringsheet.parser import parse_file 4 | from stringsheet.writer import get_strings_text 5 | from stringsheet.model import Resources 6 | 7 | 8 | class BaseParseTestCase(unittest.TestCase): 9 | @property 10 | def test_file(self): 11 | raise NotImplementedError 12 | 13 | @property 14 | def output_file(self): 15 | return self.test_file 16 | 17 | def setUp(self): 18 | self.resources = Resources() 19 | parse_file(self.test_file, self.resources) 20 | with open(self.output_file, mode='rb') as f: 21 | self.raw_text = f.read() 22 | 23 | def assert_string(self, name, text, comment=''): 24 | string = self.resources._strings[name] 25 | self.assertEqual(string.text, text, "String text is invalid") 26 | self.assertEqual(string.comment, comment, "String comment is invalid") 27 | 28 | 29 | class ParseBasicStringsTestCase(BaseParseTestCase): 30 | """Test that the parser can correctly find strings in a XML file.""" 31 | 32 | test_file = 'test-resources/strings_basic.xml' 33 | 34 | def test_finds_all_strings(self): 35 | self.assertEqual(2, self.resources.count(), 36 | 'Found incorrect number of strings') 37 | 38 | def test_finds_correct_id(self): 39 | self.assertIn('test_string', self.resources, 'String is missing') 40 | self.assertIn('second_string', self.resources, 'String is missing') 41 | 42 | def test_strings_have_correct_values(self): 43 | self.assert_string('test_string', 'Test string') 44 | self.assert_string('second_string', 'Second string') 45 | 46 | def test_doesnt_contain_unknown_strings(self): 47 | self.assertNotIn('unknown', self.resources, 48 | 'Found string not present in file') 49 | 50 | def test_output_is_valid(self): 51 | self.assertEqual(self.raw_text, get_strings_text(self.resources), 52 | 'Result file is different from required') 53 | 54 | 55 | class ParseNotTranslatableStringsTestCase(BaseParseTestCase): 56 | """Test that the parser doesn't find strings set to not translatable.""" 57 | 58 | test_file = 'test-resources/strings_not_translatable.xml' 59 | output_file = 'test-resources/strings_not_translatable_output.xml' 60 | 61 | def test_finds_all_strings(self): 62 | self.assertEqual(2, self.resources.count(), 63 | 'Found incorrect number of strings') 64 | 65 | def test_doesnt_find_non_translatable_string(self): 66 | self.assertNotIn('not_translatable', self.resources, 67 | 'Found non translatable string') 68 | 69 | def test_finds_translatable_strings(self): 70 | self.assertIn('translatable', self.resources) 71 | self.assertIn('translatable_2', self.resources) 72 | 73 | def test_output_is_valid(self): 74 | self.assertEqual(self.raw_text, get_strings_text(self.resources), 75 | 'Result file is different from required') 76 | 77 | 78 | class ParseEmptyFileTestCase(BaseParseTestCase): 79 | """Test that the parser does not create any strings for empty XML files.""" 80 | 81 | test_file = 'test-resources/strings_empty.xml' 82 | 83 | def test_doesnt_find_any_strings(self): 84 | self.assertEqual(0, self.resources.count(), 85 | 'Found strings in empty file') 86 | 87 | 88 | class ParseNotTranslatableRootTestCase(BaseParseTestCase): 89 | """Test that the parser does not find strings in XML file with 90 | non-translatable root. 91 | """ 92 | 93 | test_file = 'test-resources/strings_not_translatable_root.xml' 94 | 95 | def test_doesnt_find_any_strings(self): 96 | self.assertEqual(0, self.resources.count(), 97 | 'Found strings in non-translatable file') 98 | 99 | 100 | class ParseInvalidRootTestCase(BaseParseTestCase): 101 | """Test that the parser does not find in XML files with invalid root tag.""" 102 | 103 | test_file = 'test-resources/strings_invalid_root_tag.xml' 104 | 105 | def test_doesnt_find_any_strings(self): 106 | self.assertEqual(0, self.resources.count(), 107 | 'Found strings in XML file with invalid root tag') 108 | 109 | 110 | class ParseUnknownElementsTestCase(BaseParseTestCase): 111 | """Test that the parser only finds strings using the XML tag.""" 112 | 113 | test_file = 'test-resources/strings_invalid.xml' 114 | 115 | def test_doesnt_find_any_strings(self): 116 | self.assertEqual(0, self.resources.count(), 117 | 'Found strings with invalid tags') 118 | 119 | 120 | class ParseNonExistingFileTestCase(unittest.TestCase): 121 | """Test that the parser handles non-existing files.""" 122 | 123 | def test_crashes(self): 124 | with self.assertRaises(IOError): 125 | parse_file('test-resources/strings_non_existing.xml', Resources()) 126 | 127 | 128 | class ParseArraysTestCase(BaseParseTestCase): 129 | """Test that the parser handles string arrays.""" 130 | 131 | test_file = 'test-resources/strings_arrays.xml' 132 | output_file = 'test-resources/strings_arrays_output.xml' 133 | 134 | def test_finds_all_strings(self): 135 | self.assertEqual(0, len(self.resources._strings)) 136 | self.assertEqual(2, len(self.resources._arrays)) 137 | self.assertEqual(0, len(self.resources._plurals)) 138 | 139 | def test_finds_all_items(self): 140 | self.assertEqual(3, len(self.resources._arrays['string']._items)) 141 | self.assertEqual(3, len(self.resources._arrays['string_2']._items)) 142 | 143 | def test_items_have_valid_text(self): 144 | items = self.resources._arrays['string']._items 145 | self.assertEqual('First', items[0].text) 146 | self.assertEqual('Second', items[1].text) 147 | self.assertEqual('Third', items[2].text) 148 | 149 | items_2 = self.resources._arrays['string_2']._items 150 | self.assertEqual('First', items_2[0].text) 151 | self.assertEqual('Second', items_2[1].text) 152 | self.assertEqual('Third', items_2[2].text) 153 | 154 | def test_output_is_valid(self): 155 | self.assertEqual(self.raw_text, get_strings_text(self.resources), 156 | 'Result file is different from required') 157 | 158 | def test_item_count_is_correct(self): 159 | self.assertEqual(6, self.resources.item_count()) 160 | 161 | 162 | class ParsePluralsTestCase(BaseParseTestCase): 163 | """Test that the parser handles plural strings.""" 164 | 165 | test_file = 'test-resources/strings_plurals.xml' 166 | output_file = 'test-resources/strings_plurals_output.xml' 167 | 168 | def test_finds_all_strings(self): 169 | self.assertEqual(0, len(self.resources._strings)) 170 | self.assertEqual(0, len(self.resources._arrays)) 171 | self.assertEqual(2, len(self.resources._plurals)) 172 | 173 | def test_finds_all_items(self): 174 | self.assertEqual(6, len(self.resources._plurals['string'])) 175 | self.assertEqual(6, len(self.resources._plurals['string_2'])) 176 | 177 | def test_items_have_valid_text(self): 178 | plural = self.resources._plurals['string'] 179 | self.assertIn('zero', plural) 180 | self.assertIn('one', plural) 181 | self.assertIn('two', plural) 182 | self.assertIn('few', plural) 183 | self.assertIn('many', plural) 184 | self.assertIn('other', plural) 185 | 186 | plural_2 = self.resources._plurals['string_2'] 187 | self.assertIn('zero', plural_2) 188 | self.assertIn('one', plural_2) 189 | self.assertIn('two', plural_2) 190 | self.assertIn('few', plural_2) 191 | self.assertIn('many', plural_2) 192 | self.assertIn('other', plural_2) 193 | 194 | def test_plurals_have_valid_text(self): 195 | plural = self.resources._plurals['string'] 196 | self.assertEqual('Zero', plural['zero'].text) 197 | self.assertEqual('One', plural['one'].text) 198 | self.assertEqual('Two', plural['two'].text) 199 | self.assertEqual('Few', plural['few'].text) 200 | self.assertEqual('Many', plural['many'].text) 201 | self.assertEqual('Other', plural['other'].text) 202 | 203 | plural_2 = self.resources._plurals['string'] 204 | self.assertEqual('Zero', plural_2['zero'].text) 205 | self.assertEqual('One', plural_2['one'].text) 206 | self.assertEqual('Other', plural_2['other'].text) 207 | 208 | def test_missing_quantities_are_created_from_other(self): 209 | plural = self.resources._plurals['string_2'] 210 | self.assertEqual('Other', plural['two'].text) 211 | self.assertEqual('Other', plural['few'].text) 212 | self.assertEqual('Other', plural['many'].text) 213 | 214 | def test_output_is_valid(self): 215 | text = get_strings_text(self.resources) 216 | self.assertEqual(self.raw_text, text, 217 | 'Result file is different from required') 218 | 219 | 220 | class ParseOutputTestCase(BaseParseTestCase): 221 | """Test that the writer saves strings in order.""" 222 | 223 | test_file = 'test-resources/strings_order.xml' 224 | output_file = 'test-resources/strings_order_output.xml' 225 | 226 | def test_order_is_valid(self): 227 | self.assertEqual(self.raw_text, get_strings_text(self.resources), 228 | 'Result file is different from required') 229 | 230 | 231 | class ParseWithCommentsTestCase(BaseParseTestCase): 232 | """Test that the parser reads comments.""" 233 | 234 | test_file = 'test-resources/strings_comments.xml' 235 | output_file = 'test-resources/strings_comments.xml' 236 | 237 | def test_string_has_comment(self): 238 | string = self.resources._strings['string'] 239 | self.assertEqual('String comment', string.comment) 240 | 241 | def test_string_has_no_old_comment(self): 242 | string = self.resources._strings['string_2'] 243 | self.assertEqual('', string.comment) 244 | 245 | def test_string_has_no_comment_after_blank_line(self): 246 | string = self.resources._strings['string_3'] 247 | self.assertEqual('', string.comment) 248 | 249 | def test_array_items_have_comments(self): 250 | array = self.resources._arrays['array'] 251 | self.assertEqual('', array.comment) 252 | self.assertEqual('Array item comment', array[0].comment) 253 | self.assertEqual('', array[1].comment) 254 | self.assertEqual('', array[2].comment) 255 | 256 | def test_array_items_inherit_parent_comment(self): 257 | array = self.resources._arrays['array_2'] 258 | self.assertEqual('Array comment', array.comment) 259 | self.assertEqual('Array comment', array[0].comment) 260 | self.assertEqual('Own comment', array[1].comment) 261 | self.assertEqual('Array comment', array[2].comment) 262 | 263 | def test_plural_items_have_comments(self): 264 | plural = self.resources._plurals['plural'] 265 | self.assertEqual('', plural.comment) 266 | self.assertEqual('Comment', plural['one'].comment) 267 | self.assertEqual('', plural['two'].comment) 268 | self.assertEqual('', plural['other'].comment) 269 | 270 | def test_plural_items_inherit_parent_comments(self): 271 | plural = self.resources._plurals['plural_2'] 272 | self.assertEqual('Plural comment', plural.comment) 273 | self.assertEqual('Comment', plural['one'].comment) 274 | self.assertEqual('Plural comment', plural['two'].comment) 275 | self.assertEqual('Plural comment', plural['other'].comment) 276 | 277 | 278 | if __name__ == '__main__': 279 | unittest.main() 280 | --------------------------------------------------------------------------------