#!/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 . 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 -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, '', '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, '', '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, '', 'list files'), '-c?': (str, '', 'list components'), '-l?': (str, '', 'list languages'), '-k': (int, '', '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 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, '', 'Row-id of a component'), 'key': (str, '', '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, '', '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 " 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 = ' 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()