Initial
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.DS_Store
|
||||
._*
|
||||
|
||||
stringslator.db
|
||||
7
LICENSE
Normal file
7
LICENSE
Normal file
@@ -0,0 +1,7 @@
|
||||
Copyright 2019 Oleg Geier
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
95
README.md
Normal file
95
README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# stringslator
|
||||
|
||||
Simple system strings localization for the masses. NSLocalizedString()
|
||||
|
||||
|
||||
|
||||
## What is it?
|
||||
|
||||
This script was inspired by an App called [System Strings][1] by Oleg Andreev. The functionality is similar but doesn't rely on a dictionary that is distributed together with the application.
|
||||
|
||||
Instead this script will parse all translation files (`.strings`
|
||||
files) that are already present in the operating system. Common translations like 'Abort' were already translated by Apple Inc. (and other software distributors).
|
||||
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
First we have to create an index on some files. Lets start with macOS' system applications.
|
||||
|
||||
```
|
||||
stringslator.py add -r /System/
|
||||
```
|
||||
|
||||
This takes roughly 1 minute. The SQLite database is now initialized (133mb with 1.7M strings) in the same directory as the python script. We can start searching for some translations:
|
||||
|
||||
```
|
||||
stringslator.py search "Update s%"
|
||||
```
|
||||
```
|
||||
141 | Update Security Code --- ('UPDATECODE')
|
||||
320 | Update Services --- ('kPerformSDPQueryKey')
|
||||
454 | Update suchen --- ('SOFTWARE_UPDATE_FINDING')
|
||||
454 | Update suchen --- ('MIGRATION_UPDATE_FINDING')
|
||||
1747 | Update Services --- ('kPerformSDPQueryKey')
|
||||
|
||||
5 results.
|
||||
```
|
||||
|
||||
Notice here, we used `%` to match an arbitrary suffix. Use `_` for a single character wildcard. SQLite like matching rules apply. (see Notes below for language specific search).
|
||||
|
||||
After we found a translation we want, lets export the translations for all available languages. For that, we use the first column id and the title-key value inside `('...')`.
|
||||
|
||||
```
|
||||
stringslator.py export 141 "UPDATECODE"
|
||||
```
|
||||
```
|
||||
ar|تحديث رمز الأمن
|
||||
ca|Actualitzar codi de seguretat
|
||||
cs|Aktualizovat zabezpečovací kód
|
||||
da|Opdater sikkerhedskode
|
||||
de|Sicherheitscode aktualisieren
|
||||
el|Ενημέρωση κωδικού ασφαλείας
|
||||
en|Update Security Code
|
||||
es|Actualizar código de seguridad
|
||||
es_419|Actualizar código de seguridad
|
||||
fi|Päivitä suojakoodi
|
||||
fr|Mettre à jour le code de sécurité
|
||||
he|עדכן/י קוד אבטחה
|
||||
hi|सुरक्षा कोड अपडेट करें
|
||||
hr|Ažuriraj sigurnosni kôd
|
||||
hu|Biztonsági kód frissítése
|
||||
id|Perbarui Kode Keamanan
|
||||
it|Aggiorna codice di sicurezza
|
||||
ja|セキュリティコードをアップデート
|
||||
ko|보안 코드 업데이트
|
||||
ms|Kemas Kini Kod Keselamatan
|
||||
nl|Werk beveiligingscode bij
|
||||
no|Oppdater sikkerhetskode
|
||||
pl|Uaktualnij kod bezpieczeństwa
|
||||
pt|Atualizar Código de Segurança
|
||||
pt_PT|Atualizar código de segurança
|
||||
ro|Actualizează codul de securitate
|
||||
ru|Обновить код безопасности
|
||||
sk|Aktualizovať bezpečnostný kód
|
||||
sv|Uppdatera säkerhetskod
|
||||
th|อัพเดทรหัสความปลอดภัย
|
||||
tr|Güvenlik Kodunu Güncelle
|
||||
uk|Оновити захисний код
|
||||
vi|Cập nhật Mã Bảo mật
|
||||
zh_CN|更新安全码
|
||||
zh_TW|更新安全碼
|
||||
```
|
||||
|
||||
For a quick translation job this should be sufficient. If you need some advanced processing, you can also use the SQLite db directly.
|
||||
|
||||
If you later decide to add or remove additional applications to the db, use the `add` and `delete` commands respectively. Apps can also be deleted by their file-id. All commands show a help `-h` window to describe available options.
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
Search will always search case independent and by default English and German translations. If you want to change this behavior go to `cli_search` and modify `langs=["en%", "de%", "Ger%"]`.
|
||||
|
||||
|
||||
[1]: https://itunes.apple.com/us/app/system-strings/id570467776?l=en
|
||||
699
stringslator.py
Executable file
699
stringslator.py
Executable file
@@ -0,0 +1,699 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import sys
|
||||
import sqlite3 # v3.3+
|
||||
import plistlib
|
||||
import codecs
|
||||
import re
|
||||
import argparse
|
||||
|
||||
scriptFilePath = os.path.realpath(__file__)
|
||||
scriptRoot = os.path.abspath(os.path.join(scriptFilePath, os.pardir))
|
||||
PATH_DB = os.path.join(scriptRoot, 'stringslator.db')
|
||||
|
||||
|
||||
def main():
|
||||
ARGSParser().parse()
|
||||
|
||||
# --------------------------------------------
|
||||
#
|
||||
# StringsDB
|
||||
#
|
||||
# --------------------------------------------
|
||||
|
||||
|
||||
class StringsDB(object):
|
||||
""" Communication with, and processing of stringslator db """
|
||||
db = None
|
||||
sql = None
|
||||
|
||||
def __init__(self):
|
||||
super(StringsDB, self).__init__()
|
||||
self.db = sqlite3.connect(PATH_DB)
|
||||
self.sql = self.db.cursor()
|
||||
self.createTablesIfNeeded()
|
||||
|
||||
def __del__(self):
|
||||
self.db.commit()
|
||||
self.db.close()
|
||||
|
||||
# --------------------------------------------
|
||||
# stringslator API
|
||||
# --------------------------------------------
|
||||
|
||||
def apiInfo(self, f_id, isComponent=False):
|
||||
""" Return 3-tuple (dbFetchFile(), dbFetchComponents(), dbFetchCounts())
|
||||
"""
|
||||
if isComponent:
|
||||
f_id = self.fetchFileFromComponent(f_id)
|
||||
file = self.fetchFile(f_id)
|
||||
if file is None:
|
||||
return None, None, None
|
||||
return file, self.fetchComponents(f_id), self.fetchCounts(f_id)
|
||||
|
||||
def apiSearch(self, term, titlesearch=False, langs=["en%"]):
|
||||
""" Argument takes search term (use '%' for ambiguous matching).
|
||||
If titlesearch = True, match all rows where title matches exactly.
|
||||
Return array of tuples (f_id, c_id, l_id, title, translation)
|
||||
"""
|
||||
if titlesearch:
|
||||
self.sql.execute("SELECT * FROM _trans WHERE key LIKE ?", [term])
|
||||
else:
|
||||
langIds = self.fetchLanguageIDs(langs)
|
||||
lParam = ','.join('?' * len(langIds))
|
||||
self.sql.execute('''SELECT * FROM _trans WHERE value LIKE ?
|
||||
AND lid IN (%s)''' % lParam, [term] + langIds)
|
||||
return self.sql.fetchall()
|
||||
|
||||
def apiExport(self, c_id, key):
|
||||
""" Return array of tuples (lang, translation) """
|
||||
self.sql.execute('''
|
||||
SELECT l.name, t.value
|
||||
FROM _trans t INNER JOIN _lang l ON l.id = t.lid
|
||||
WHERE cid = ? and key = ?
|
||||
ORDER BY l.name COLLATE NOCASE''', [c_id, key])
|
||||
return self.sql.fetchall()
|
||||
|
||||
def apiAdd(self, path, recursive=False):
|
||||
""" Add path to db by enumerating all string files (recursively) """
|
||||
for resPath in enumerateResourcePaths(path, recursive):
|
||||
self.insertResourceIntoDB(resPath)
|
||||
|
||||
def apiDelete(self, idOrPath, recursive=False):
|
||||
""" Delete an application with given file-id or path.
|
||||
Recursive = True is used for paths only.
|
||||
"""
|
||||
if idOrPath.isdigit():
|
||||
file = self.fetchFile(idOrPath)
|
||||
if not file:
|
||||
print("id %s does not exist." % idOrPath)
|
||||
else:
|
||||
yield self.deleteFile(idOrPath), file[1]
|
||||
else:
|
||||
for f_id, name in self.fetchFileIdsWithPath(idOrPath, recursive):
|
||||
yield self.deleteFile(f_id), name
|
||||
|
||||
def apiList(self, table, term=None):
|
||||
""" table is either 'file', 'comp', 'lang', or 'title'
|
||||
term is either row-id or name like %string%
|
||||
"""
|
||||
TB = {"file": "_file", "comp": "_comp", "lang": "_lang"}
|
||||
if table not in TB:
|
||||
return None
|
||||
tbl = TB[table]
|
||||
stmt = ""
|
||||
if term:
|
||||
if term.isdigit():
|
||||
stmt = "WHERE id = %d" % int(term)
|
||||
else:
|
||||
stmt = "WHERE name like '%%%s%%'" % term
|
||||
|
||||
self.sql.execute('''SELECT id,name FROM %s %s
|
||||
ORDER BY name COLLATE NOCASE''' % (tbl, stmt))
|
||||
return self.sql.fetchall()
|
||||
|
||||
def apiListTitles(self, f_id):
|
||||
""" Return a list of all keys for a given component-id """
|
||||
self.sql.execute('''SELECT cid,key FROM _trans WHERE fid=? GROUP BY key
|
||||
ORDER BY key COLLATE NOCASE''', [f_id])
|
||||
return self.sql.fetchall()
|
||||
|
||||
# --------------------------------------------
|
||||
# SQLite management helper
|
||||
# --------------------------------------------
|
||||
|
||||
def createTablesIfNeeded(self):
|
||||
""" Set schema if not already present """
|
||||
self.sql.execute('''CREATE TABLE IF NOT EXISTS _file (
|
||||
id integer NOT NULL PRIMARY KEY,
|
||||
name text,
|
||||
dir text
|
||||
)''')
|
||||
self.sql.execute('''CREATE TABLE IF NOT EXISTS _comp (
|
||||
id integer NOT NULL PRIMARY KEY,
|
||||
fid integer NOT NULL REFERENCES _file(id),
|
||||
name text
|
||||
)''')
|
||||
self.sql.execute('''CREATE TABLE IF NOT EXISTS _lang (
|
||||
id integer NOT NULL PRIMARY KEY,
|
||||
name text
|
||||
)''')
|
||||
self.sql.execute('''CREATE TABLE IF NOT EXISTS _trans (
|
||||
fid integer NOT NULL REFERENCES _file(id),
|
||||
cid integer NOT NULL REFERENCES _comp(id),
|
||||
lid integer NOT NULL REFERENCES _lang(id),
|
||||
key text,
|
||||
value text
|
||||
)''')
|
||||
|
||||
def fetchIdForTable(self, table, cols=[], vals=[]):
|
||||
""" Fetch row-id for given table, columns, and values """
|
||||
if len(cols) != len(vals):
|
||||
raise Exception("COLS and VALS are not of same length")
|
||||
cols = [x + "=?" for x in cols]
|
||||
self.sql.execute('SELECT id FROM %s WHERE %s' %
|
||||
(table, " AND ".join(cols)), vals)
|
||||
return self.sql.fetchone()
|
||||
|
||||
def insertIdIntoTable(self, table, cols=[], vals=[]):
|
||||
""" Insert new row into table with given values """
|
||||
if len(cols) != len(vals):
|
||||
raise Exception("COLS and VALS are not of same length")
|
||||
self.sql.execute('INSERT INTO %s(%s) VALUES (%s)' % (
|
||||
table, ','.join(cols), ','.join('?' * len(vals))), vals)
|
||||
return self.sql.lastrowid
|
||||
|
||||
def insertOrReturnRowID(self, table, cols, vals=[]):
|
||||
""" Return tuple (row-id, didExistBeforeFlag) """
|
||||
idn = self.fetchIdForTable(table, cols.split(','), vals)
|
||||
if idn is None:
|
||||
return self.insertIdIntoTable(table, cols.split(','), vals), False
|
||||
else:
|
||||
return idn[0], True
|
||||
|
||||
def insertFile(self, path, name):
|
||||
""" Return row id of _file table. Insert new one if necessary. """
|
||||
return self.insertOrReturnRowID('_file', 'name,dir', [name, path])
|
||||
|
||||
def insertComponent(self, f_id, comp):
|
||||
""" Return row id of _comp table. Insert new one if necessary. """
|
||||
return self.insertOrReturnRowID('_comp', 'fid,name', [f_id, comp])[0]
|
||||
|
||||
def insertLang(self, lang):
|
||||
""" Return row id of _lang table. Insert new one if necessary. """
|
||||
return self.insertOrReturnRowID('_lang', 'name', [lang])[0]
|
||||
|
||||
def fetchLanguageIDs(self, langs=[]):
|
||||
""" Return list of ids matching provided langs array """
|
||||
if not langs or type(langs) is not list or len(langs) == 0:
|
||||
raise Exception("fetchLanguageIDs arg is not a list or empty")
|
||||
self.sql.execute("SELECT id FROM _lang WHERE %s" %
|
||||
" OR ".join(["name LIKE ?"] * len(langs)), langs)
|
||||
return [x[0] for x in self.sql]
|
||||
|
||||
def fetchFileFromComponent(self, c_id):
|
||||
""" Return file-id with given component-id """
|
||||
self.sql.execute('SELECT fid FROM _comp WHERE id=?', [c_id])
|
||||
return self.sql.fetchone()[0]
|
||||
|
||||
def fetchFile(self, f_id):
|
||||
""" Return tuple (file-id, file-name, file-dir) """
|
||||
self.sql.execute('SELECT id,name,dir FROM _file WHERE id=?', [f_id])
|
||||
return self.sql.fetchone()
|
||||
|
||||
def fetchComponents(self, f_id):
|
||||
""" Return array of tuple (compunent-id, component-name) """
|
||||
self.sql.execute('''SELECT id,name FROM _comp WHERE fid=?
|
||||
ORDER BY name''', [f_id])
|
||||
return self.sql.fetchall()
|
||||
|
||||
def fetchCounts(self, f_id):
|
||||
""" Return tuple [languages, translations, total] """
|
||||
self.sql.execute('''SELECT count(*) FROM _trans WHERE fid=?
|
||||
GROUP BY lid''', [f_id])
|
||||
counts = [0, 0, 0]
|
||||
for x in self.sql:
|
||||
counts[0] += 1
|
||||
counts[1] = max(x[0], counts[1])
|
||||
counts[2] += x[0]
|
||||
return counts
|
||||
|
||||
def fetchFileIdsWithPath(self, path, recursive=False):
|
||||
""" Return (file-id, file-name) for matching rows.
|
||||
If recursive = True also match all subdirectories.
|
||||
"""
|
||||
path = os.path.abspath(path)
|
||||
if recursive:
|
||||
path = "%s%%" % path
|
||||
self.sql.execute('SELECT id, name FROM _file WHERE dir LIKE ?', [path])
|
||||
return self.sql.fetchall()
|
||||
|
||||
def deleteFile(self, f_id):
|
||||
""" Delete rows in _file, _comp, and _trans where f_id matches """
|
||||
self.sql.execute('DELETE FROM _file WHERE id=?', [f_id])
|
||||
self.sql.execute('DELETE FROM _comp WHERE fid=?', [f_id])
|
||||
self.sql.execute('DELETE FROM _trans WHERE fid=?', [f_id])
|
||||
return self.sql.rowcount # only translations are relevant
|
||||
|
||||
# --------------------------------------------
|
||||
# Insert new application
|
||||
# --------------------------------------------
|
||||
|
||||
def insertResourceIntoDB(self, path):
|
||||
""" Parse 'Resources' folder and insert all localizable strings to db.
|
||||
If path was processed before it will be skipped immediatelly.
|
||||
"""
|
||||
sfe = StringsFileEnumerator(self, path)
|
||||
if not sfe.validPath:
|
||||
print("ERROR: '%s' has no 'Resources' folder." % path)
|
||||
return False
|
||||
if sfe.existing:
|
||||
print("skip existing. '%s'" % sfe.appName)
|
||||
return False
|
||||
|
||||
print("processing '%s'" % sfe.appName)
|
||||
langs, trns = sfe.processResourcesFolder()
|
||||
|
||||
if len(trns) > 0 and len(langs) > 1:
|
||||
self.sql.executemany('INSERT INTO _trans VALUES (?,?,?,?,?)', trns)
|
||||
self.db.commit()
|
||||
print("added id %d '%s' (%d strings, %d languages)" %
|
||||
(sfe.fid, sfe.appName, len(trns), len(langs)))
|
||||
return True
|
||||
else:
|
||||
print("ignored, empty.")
|
||||
self.db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
# --------------------------------------------
|
||||
#
|
||||
# StringsFileEnumerator
|
||||
#
|
||||
# --------------------------------------------
|
||||
|
||||
|
||||
class StringsFileEnumerator(object):
|
||||
""" Helper to find all .strings files in directory 'resPath'.
|
||||
Will return array of extracted languages and translations
|
||||
"""
|
||||
db = None
|
||||
resPath = None
|
||||
appName = None
|
||||
fid = 0
|
||||
existing = True
|
||||
validPath = False
|
||||
|
||||
def __init__(self, stringsDB, path):
|
||||
super(StringsFileEnumerator, self).__init__()
|
||||
self.db = stringsDB
|
||||
self.resPath = self.resourcesPathForPath(path)
|
||||
if self.resPath is not None:
|
||||
self.validPath = True
|
||||
appPath = self.appDirForResourcePath(self.resPath)
|
||||
self.appName = os.path.basename(appPath)
|
||||
self.fid, self.existing = self.db.insertFile(appPath, self.appName)
|
||||
|
||||
# --------------------------------------------
|
||||
# Process .strings files
|
||||
# --------------------------------------------
|
||||
|
||||
def processResourcesFolder(self):
|
||||
""" Enumerate .strings files for all languages (.lproj subfolders) """
|
||||
translations = list()
|
||||
languages = set()
|
||||
for f1, localePath in self.enumerateWithExt(self.resPath, "lproj"):
|
||||
l_id = self.db.insertLang(f1)
|
||||
for f2, locFile in self.enumerateWithExt(localePath, "strings"):
|
||||
languages.add(l_id)
|
||||
c_id = self.db.insertComponent(self.fid, f2)
|
||||
for key, val in self.processStringsFile(locFile):
|
||||
translations.append([self.fid, c_id, l_id, key, val])
|
||||
return languages, translations
|
||||
|
||||
def processStringsFile(self, stringsFile):
|
||||
""" Parse strings file (try XML, then C-source) """
|
||||
with open(stringsFile, 'rb') as fp:
|
||||
try: # try XML format first
|
||||
plist = plistlib.load(fp)
|
||||
for key, val in self.parseStringsFileXML(plist):
|
||||
yield key, val
|
||||
return
|
||||
except plistlib.InvalidFileException:
|
||||
pass
|
||||
try: # then try c-style formatting
|
||||
for key, val in self.parseStringsFileCSource(stringsFile):
|
||||
yield key, val
|
||||
return
|
||||
except Exception as e:
|
||||
print("ERROR: Couldn't read plist '%s'" % stringsFile)
|
||||
raise e
|
||||
|
||||
def parseStringsFileXML(self, xml, prefix=''):
|
||||
""" Parse XML style strings file with nested dicts """
|
||||
for key in xml:
|
||||
val = xml[key]
|
||||
if len(prefix) > 0:
|
||||
key = "%s.%s" % (prefix, key)
|
||||
if type(val) is dict:
|
||||
for key2, val2 in self.parseStringsFileXML(val, prefix=key):
|
||||
yield key2, val2
|
||||
else:
|
||||
yield key, val
|
||||
|
||||
def parseStringsFileCSource(self, filePath):
|
||||
""" Parse C-source-code style strings file.
|
||||
Regex will find assignments and ignore (block-)comments.
|
||||
"""
|
||||
prog = re.compile(r'(?:(?!\s*/\*)(.*?)=(.*?);)|(/\*)|(\*/)')
|
||||
enc = self.findFileEncoding(filePath)
|
||||
with open(filePath, 'r', encoding=enc) as fp:
|
||||
content = fp.read()
|
||||
|
||||
blockComment = False
|
||||
quotes = ["''", '""']
|
||||
for key, val, cmntA, cmntB in prog.findall(content):
|
||||
if cmntA:
|
||||
blockComment = True
|
||||
elif cmntB:
|
||||
blockComment = False
|
||||
elif not blockComment:
|
||||
key = key.strip()
|
||||
if key.startswith("//"): # single line comment
|
||||
continue
|
||||
val = val.strip()
|
||||
if key[0] + key[-1] in quotes:
|
||||
key = key[1:-1]
|
||||
if val[0] + val[-1] in quotes:
|
||||
val = val[1:-1]
|
||||
yield key, val
|
||||
|
||||
def findFileEncoding(self, path):
|
||||
""" Auto detect UTF-8/-16/-32 encoding with BOM """
|
||||
with open(path, 'rb') as fp:
|
||||
header = fp.read(4)
|
||||
for bom, encoding in (
|
||||
(codecs.BOM_UTF32_BE, "utf-32-be"),
|
||||
(codecs.BOM_UTF32_LE, "utf-32-le"),
|
||||
(codecs.BOM_UTF16_BE, "utf-16-be"),
|
||||
(codecs.BOM_UTF16_LE, "utf-16-le"),
|
||||
(codecs.BOM_UTF8, "utf-8")
|
||||
):
|
||||
if header.startswith(bom):
|
||||
break
|
||||
return encoding
|
||||
|
||||
# --------------------------------------------
|
||||
# Folder properties & enumeration
|
||||
# --------------------------------------------
|
||||
|
||||
def resourcesPathForPath(self, path):
|
||||
""" Always navigate into '../Contents/Resources/' folder """
|
||||
try:
|
||||
actual = os.path.basename(os.path.normpath(path))
|
||||
except Exception:
|
||||
return None
|
||||
if actual == "Resources":
|
||||
newPath = path
|
||||
elif actual == "Contents":
|
||||
newPath = os.path.join(path, "Resources")
|
||||
else:
|
||||
newPath = os.path.join(path, "Contents", "Resources")
|
||||
|
||||
if os.path.exists(newPath):
|
||||
return newPath
|
||||
return None
|
||||
|
||||
def appDirForResourcePath(self, resPath):
|
||||
""" Navigate to '../../' from Resources folder """
|
||||
parent = os.path.normpath(resPath)
|
||||
while os.path.basename(parent) in ["Contents", "Resources"]:
|
||||
parent = os.path.abspath(os.path.join(parent, os.pardir))
|
||||
return parent
|
||||
|
||||
def enumerateWithExt(self, resPath, extension):
|
||||
""" Enumerate all files and folders in resPath with given extension """
|
||||
for x in os.listdir(resPath):
|
||||
f, e = os.path.splitext(x)
|
||||
if e.endswith(extension):
|
||||
yield f, os.path.join(resPath, x)
|
||||
|
||||
|
||||
# --------------------------------------------
|
||||
#
|
||||
# UserIO
|
||||
#
|
||||
# --------------------------------------------
|
||||
|
||||
|
||||
class UserIO(object):
|
||||
""" Helper class for user CLI input / output """
|
||||
|
||||
def __init__(self):
|
||||
super(UserIO, self).__init__()
|
||||
|
||||
# https://stackoverflow.com/a/3041990
|
||||
def ask(self, question, default="yes"):
|
||||
"""Ask a yes/no question via raw_input() and return their answer.
|
||||
|
||||
"question" is a string that is presented to the user.
|
||||
"default" is the presumed answer if the user just hits <Enter>.
|
||||
It must be "yes" (the default), "no" or None (meaning
|
||||
an answer is required of the user).
|
||||
|
||||
The "answer" return value is True for "yes" or False for "no".
|
||||
"""
|
||||
valid = {"yes": True, "y": True, "ye": True,
|
||||
"no": False, "n": False}
|
||||
if default is None:
|
||||
prompt = " [y/n] "
|
||||
elif default == "yes":
|
||||
prompt = " [Y/n] "
|
||||
elif default == "no":
|
||||
prompt = " [y/N] "
|
||||
else:
|
||||
raise ValueError("invalid default answer: '%s'" % default)
|
||||
|
||||
while True:
|
||||
sys.stdout.write(question + prompt)
|
||||
choice = input().lower()
|
||||
if default is not None and choice == '':
|
||||
return valid[default]
|
||||
elif choice in valid:
|
||||
return valid[choice]
|
||||
else:
|
||||
sys.stdout.write("Please respond with 'yes' or 'no' "
|
||||
"(or 'y' or 'n').\n")
|
||||
|
||||
def printResults(self, arr, verbose=True):
|
||||
""" Print formatted results dict or 'Nothing found' message.
|
||||
If result array contains more than 100 entries, ask user beforehand.
|
||||
"""
|
||||
if arr is None or len(arr) == 0:
|
||||
print(" Nothing found.")
|
||||
return
|
||||
# s = len(arr)
|
||||
# if s > 100 and not ask("Found %d entries. Show complete list?" % s):
|
||||
# return
|
||||
if verbose:
|
||||
print()
|
||||
if len(arr[0]) == 2:
|
||||
for i, n in arr:
|
||||
print("%5d | %s" % (i, n))
|
||||
elif len(arr[0]) == 5:
|
||||
for f, c, l, key, value in arr:
|
||||
value = value.replace('\n', '\\n')
|
||||
print("%5d | %s --- ('%s')" % (c, value, key))
|
||||
if verbose:
|
||||
print("\n%d results.\n" % len(arr))
|
||||
|
||||
def printInfoForFile(self, file, components, counts):
|
||||
print('Info for file:')
|
||||
print(' id: %d' % file[0])
|
||||
print(" name: '%s'" % file[1])
|
||||
print(" path: '%s'" % file[2])
|
||||
print('components:')
|
||||
self.printResults(components, verbose=False)
|
||||
print("localizable strings:")
|
||||
print(" languages: %d" % counts[0])
|
||||
print(" translations: %d" % counts[1])
|
||||
print(" total: %d" % counts[2])
|
||||
|
||||
def printDeletingFiles(self, delFiles):
|
||||
print("Deleting:")
|
||||
for x in sorted(delFiles):
|
||||
print(" - %s" % x)
|
||||
print()
|
||||
|
||||
|
||||
def enumerateResourcePaths(anyPath, recursive=False):
|
||||
""" Find all subdirectories that contain '../Contents/Resources/'.
|
||||
If recursive = False just yield anyPath.
|
||||
"""
|
||||
if recursive:
|
||||
# if os.path.isdir(anyPath):
|
||||
for x in os.walk(anyPath):
|
||||
# make sure ../Contents/Resources/.. exists
|
||||
if os.path.basename(x[0]) != "Contents":
|
||||
continue
|
||||
if "Resources" in x[1]:
|
||||
yield x[0]
|
||||
else:
|
||||
yield anyPath
|
||||
|
||||
|
||||
# --------------------------------------------
|
||||
#
|
||||
# ARGSParser
|
||||
#
|
||||
# --------------------------------------------
|
||||
|
||||
|
||||
class ARGSParser(object):
|
||||
""" Handle CLI parameter parsing and command calls """
|
||||
parser = None
|
||||
|
||||
def __init__(self):
|
||||
super(ARGSParser, self).__init__()
|
||||
self.parser = self.initCLIParser(
|
||||
[self.cli_add, self.cli_delete, self.cli_list,
|
||||
self.cli_search, self.cli_export, self.cli_info])
|
||||
self.parser.epilog = '''
|
||||
examples:
|
||||
{0} add -r '/System/' '/Applications/baRSS.app'
|
||||
{0} search '% Update%'
|
||||
{0} export 714 kWDLocPerfSignalGraphToolTip
|
||||
|
||||
run <command> -h to show help for command arguments'''.format(__file__)
|
||||
|
||||
# ------------------------------------------------------
|
||||
# CLI interface
|
||||
# ------------------------------------------------------
|
||||
|
||||
def cli_add(self, args):
|
||||
""" Add new application to db """
|
||||
if not args:
|
||||
return 'add', ['a'], 'Add new application to db', {
|
||||
'--recursive': (bool, 'Repeat for subdirectories'),
|
||||
'path+': (str, '<path>', 'App or Resources directory'),
|
||||
}
|
||||
sdb = StringsDB()
|
||||
for path in args.path:
|
||||
sdb.apiAdd(path, args.recursive)
|
||||
|
||||
def cli_delete(self, args):
|
||||
""" Delete application from db """
|
||||
if not args:
|
||||
return 'delete', ['rm'], 'Delete application from db', {
|
||||
'--recursive': (bool, 'Delete apps in subdirectories as well'),
|
||||
'path+': (str, '<file-id|path>', 'Row-id or application path'),
|
||||
}
|
||||
sdb = StringsDB()
|
||||
Del = 0
|
||||
delFiles = []
|
||||
for path in args.path:
|
||||
for changes, filename in sdb.apiDelete(path, args.recursive):
|
||||
Del += changes
|
||||
delFiles.append(filename)
|
||||
|
||||
if len(delFiles) == 0 or Del == 0:
|
||||
print("Nothing to do.")
|
||||
return
|
||||
UserIO().printDeletingFiles(delFiles)
|
||||
if not UserIO().ask("Deleting %d translations. Continue?" % Del, None):
|
||||
sdb.db.rollback()
|
||||
|
||||
def cli_list(self, args):
|
||||
""" List files, components, languages, keys """
|
||||
if not args:
|
||||
return 'list', ['ls'], 'List files, components, languages, keys', {
|
||||
'mutually_exclusive': True,
|
||||
'-f?': (str, '<term>', 'list files'),
|
||||
'-c?': (str, '<term>', 'list components'),
|
||||
'-l?': (str, '<term>', 'list languages'),
|
||||
'-k': (int, '<file-id>', 'list translation keys'),
|
||||
}
|
||||
sdb = StringsDB()
|
||||
if hasattr(args, 'k'):
|
||||
UserIO().printResults(sdb.apiListTitles(args.k))
|
||||
else:
|
||||
for x, tbl in {'f': 'file', 'c': 'comp', 'l': 'lang'}.items():
|
||||
if hasattr(args, x):
|
||||
UserIO().printResults(sdb.apiList(tbl, getattr(args, x)))
|
||||
|
||||
def cli_search(self, args):
|
||||
""" Search db for translation or title-key """
|
||||
if not args:
|
||||
return 'search', ['s'], 'Search db for translation or title-key', {
|
||||
'--keys': (bool, 'search title-keys instead of translations'),
|
||||
'term': (str, '<search-term>',
|
||||
'Search pattern using %% and _ wildcards'),
|
||||
}
|
||||
sdb = StringsDB()
|
||||
UserIO().printResults(sdb.apiSearch(
|
||||
args.term, titlesearch=args.keys, langs=["en%", "de%", "Ger%"]))
|
||||
|
||||
def cli_export(self, args):
|
||||
""" Export translations for title-key """
|
||||
if not args:
|
||||
return 'export', ['e'], 'Export translations for title-key', {
|
||||
'id': (int, '<comp-id>', 'Row-id of a component'),
|
||||
'key': (str, '<title-key>',
|
||||
'Title-key within the same component'),
|
||||
}
|
||||
sdb = StringsDB()
|
||||
for lang, text in sdb.apiExport(args.id, args.key):
|
||||
print("%s|%s" % (lang, text))
|
||||
|
||||
def cli_info(self, args):
|
||||
""" Display info for file-id or component-id """
|
||||
if not args:
|
||||
return 'info', ['i'], 'Display info for file-id or component-id', {
|
||||
'id': (int, '<file-id|comp-id>',
|
||||
'Row id of file or component (-c)'),
|
||||
'--component': (bool, 'search component-id instead of file-id')
|
||||
}
|
||||
sdb = StringsDB()
|
||||
app, components, counts = sdb.apiInfo(args.id, args.component)
|
||||
if app is None:
|
||||
print("\nFile id does not exist. Try search for an id:")
|
||||
print(" %s list -f %%Finder%%\n" % os.path.basename(__file__))
|
||||
else:
|
||||
UserIO().printInfoForFile(app, components, counts)
|
||||
|
||||
# ------------------------------------------------------
|
||||
# argparse stuff
|
||||
# ------------------------------------------------------
|
||||
|
||||
def initCLIParser(self, methods):
|
||||
""" Initialize argparse with commands from method dictionary """
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
parser.formatter_class = argparse.RawTextHelpFormatter
|
||||
parser.set_defaults(func=lambda x: parser.print_help(sys.stderr))
|
||||
subPrs = parser.add_subparsers(title='commands', metavar=" " * 13)
|
||||
for fn in methods:
|
||||
info = fn(None) # call function w/o params to get info dict
|
||||
self.initCLICommand(subPrs, fn, *info)
|
||||
parser.usage = "%(prog)s <command>"
|
||||
return parser
|
||||
|
||||
def initCLICommand(self, parentParser, fn, name, alias, hlp, args):
|
||||
""" Add new command (e.g., add, delete, ...) to parser """
|
||||
cmd = parentParser.add_parser(name, aliases=alias, help=hlp)
|
||||
cmd.set_defaults(func=fn)
|
||||
if name == 'list':
|
||||
cmd.epilog = '<term> can be either row-id or search string.'
|
||||
|
||||
for param, options in args.items():
|
||||
if param.lower() == "mutually_exclusive":
|
||||
cmd = cmd.add_mutually_exclusive_group(required=True)
|
||||
continue
|
||||
self.initCLICommandArgument(cmd, param, options)
|
||||
|
||||
def initCLICommandArgument(self, commandParser, param, options):
|
||||
""" Add command argument (e.g., -k, path, ...) to given cli command """
|
||||
args = {'help': options[-1]}
|
||||
typ = options[0]
|
||||
if param[-1] in ['?', '*', '+']:
|
||||
args['nargs'] = param[-1]
|
||||
param = param[:-1]
|
||||
if typ == bool:
|
||||
args['action'] = 'store_true'
|
||||
else:
|
||||
args['type'] = typ
|
||||
args['metavar'] = options[1]
|
||||
# make short form and prepare for unpacking
|
||||
param = [param[1:3], param] if param.startswith('--') else [param]
|
||||
opt = commandParser.add_argument(*param, **args)
|
||||
if typ != bool:
|
||||
opt.default = argparse.SUPPRESS
|
||||
|
||||
def parse(self):
|
||||
""" Parse the args and call whatever function was selected """
|
||||
args = self.parser.parse_args()
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user