├── 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 |
8 |
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 |
4 |
5 |
6 |
14 |
15 |
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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/Unittests.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
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 |
27 |
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 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
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 |
--------------------------------------------------------------------------------