#!/usr/bin/env python # Copyright (c) 2012 The Chromium Authors. All rights reserved. # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. """Generate keyboard layout and hotkey data for the keyboard overlay. This script fetches data from the keyboard layout and hotkey data spreadsheet, and output the data depending on the option. --cc: Rewrites a part of C++ code in chrome/browser/chromeos/webui/keyboard_overlay_ui.cc --grd: Rewrites a part of grd messages in chrome/app/generated_resources.grd --js: Rewrites the entire JavaScript code in chrome/browser/resources/keyboard_overlay/keyboard_overlay_data.js These options can be specified at the same time. e.g. python gen_keyboard_overlay_data.py --cc --grd --js The output directory of the generated files can be changed with --outdir. e.g. (This will generate tmp/keyboard_overlay.js) python gen_keyboard_overlay_data.py --outdir=tmp --js """ import cStringIO import datetime import gdata.spreadsheet.service import getpass import json import optparse import os import re import sys MODIFIER_SHIFT = 1 << 0 MODIFIER_CTRL = 1 << 1 MODIFIER_ALT = 1 << 2 KEYBOARD_GLYPH_SPREADSHEET_KEY = '0Ao3KldW9piwEdExLbGR6TmZ2RU9aUjFCMmVxWkVqVmc' HOTKEY_SPREADSHEET_KEY = '0AqzoqbAMLyEPdE1RQXdodk1qVkFyTWtQbUxROVM1cXc' CC_OUTDIR = 'chrome/browser/ui/webui/chromeos' CC_FILENAME = 'keyboard_overlay_ui.cc' GRD_OUTDIR = 'chrome/app' GRD_FILENAME = 'chromeos_strings.grdp' JS_OUTDIR = 'chrome/browser/resources/chromeos' JS_FILENAME = 'keyboard_overlay_data.js' CC_START = r'IDS_KEYBOARD_OVERLAY_INSTRUCTIONS_HIDE },' CC_END = r'};' GRD_START = r' ' GRD_END = r' ' LABEL_MAP = { 'glyph_arrow_down': 'down', 'glyph_arrow_left': 'left', 'glyph_arrow_right': 'right', 'glyph_arrow_up': 'up', 'glyph_back': 'back', 'glyph_backspace': 'backspace', 'glyph_brightness_down': 'bright down', 'glyph_brightness_up': 'bright up', 'glyph_enter': 'enter', 'glyph_forward': 'forward', 'glyph_fullscreen': 'full screen', # Kana/Eisu key on Japanese keyboard 'glyph_ime': u'\u304b\u306a\u0020\u002f\u0020\u82f1\u6570', 'glyph_lock': 'lock', 'glyph_overview': 'switch window', 'glyph_power': 'power', 'glyph_right': 'right', 'glyph_reload': 'reload', 'glyph_search': 'search', 'glyph_shift': 'shift', 'glyph_tab': 'tab', 'glyph_tools': 'tools', 'glyph_volume_down': 'vol. down', 'glyph_volume_mute': 'mute', 'glyph_volume_up': 'vol. up', }; INPUT_METHOD_ID_TO_OVERLAY_ID = { 'm17n:ar:kbd': 'ar', 'm17n:fa:isiri': 'ar', 'm17n:hi:itrans': 'hi', 'm17n:th:kesmanee': 'th', 'm17n:th:pattachote': 'th', 'm17n:th:tis820': 'th', 'm17n:vi:tcvn': 'vi', 'm17n:vi:telex': 'vi', 'm17n:vi:viqr': 'vi', 'm17n:vi:vni': 'vi', 'm17n:zh:cangjie': 'zh_TW', 'm17n:zh:quick': 'zh_TW', 'mozc': 'en_US', 'mozc-chewing': 'zh_TW', 'mozc-dv': 'en_US_dvorak', 'mozc-hangul': 'ko', 'mozc-jp': 'ja', 'pinyin': 'zh_CN', 'pinyin-dv': 'en_US_dvorak', 'xkb:be::fra': 'fr', 'xkb:be::ger': 'de', 'xkb:be::nld': 'nl', 'xkb:bg::bul': 'bg', 'xkb:bg:phonetic:bul': 'bg', 'xkb:br::por': 'pt_BR', 'xkb:ca::fra': 'fr_CA', 'xkb:ca:eng:eng': 'ca', 'xkb:ch::ger': 'de', 'xkb:ch:fr:fra': 'fr', 'xkb:cz::cze': 'cs', 'xkb:de::ger': 'de', 'xkb:de:neo:ger': 'de_neo', 'xkb:dk::dan': 'da', 'xkb:ee::est': 'et', 'xkb:es::spa': 'es', 'xkb:es:cat:cat': 'ca', 'xkb:fi::fin': 'fi', 'xkb:fr::fra': 'fr', 'xkb:gb:dvorak:eng': 'en_GB_dvorak', 'xkb:gb:extd:eng': 'en_GB', 'xkb:gr::gre': 'el', 'xkb:hr::scr': 'hr', 'xkb:hu::hun': 'hu', 'xkb:il::heb': 'iw', 'xkb:it::ita': 'it', 'xkb:jp::jpn': 'ja', 'xkb:kr:kr104:kor': 'ko', 'xkb:latam::spa': 'es_419', 'xkb:lt::lit': 'lt', 'xkb:lv:apostrophe:lav': 'lv', 'xkb:no::nob': 'no', 'xkb:pl::pol': 'pl', 'xkb:pt::por': 'pt_PT', 'xkb:ro::rum': 'ro', 'xkb:rs::srp': 'sr', 'xkb:ru::rus': 'ru', 'xkb:ru:phonetic:rus': 'ru', 'xkb:se::swe': 'sv', 'xkb:si::slv': 'sl', 'xkb:sk::slo': 'sk', 'xkb:tr::tur': 'tr', 'xkb:ua::ukr': 'uk', 'xkb:us::eng': 'en_US', 'xkb:us:altgr-intl:eng': 'en_US_altgr_intl', 'xkb:us:colemak:eng': 'en_US_colemak', 'xkb:us:dvorak:eng': 'en_US_dvorak', 'xkb:us:intl:eng': 'en_US_intl', 'zinnia-japanese': 'ja', } # The file was first generated in 2012 and we have a policy of not updating # copyright dates. COPYRIGHT_HEADER=\ """// Copyright (c) 2012 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // This is a generated file but may contain local modifications. See // src/tools/gen_keyboard_overlay_data/gen_keyboard_overlay_data.py --help """ # A snippet for grd file GRD_SNIPPET_TEMPLATE=""" %s """ # A snippet for C++ file CC_SNIPPET_TEMPLATE=""" { "%s", %s }, """ def SplitBehavior(behavior): """Splits the behavior to compose a message or i18n-content value. Examples: 'Activate last tab' => ['Activate', 'last', 'tab'] 'Close tab' => ['Close', 'tab'] """ return [x for x in re.split('[ ()"-.,]', behavior) if len(x) > 0] def ToMessageName(behavior): """Composes a message name for grd file. Examples: 'Activate last tab' => IDS_KEYBOARD_OVERLAY_ACTIVATE_LAST_TAB 'Close tab' => IDS_KEYBOARD_OVERLAY_CLOSE_TAB """ segments = [segment.upper() for segment in SplitBehavior(behavior)] return 'IDS_KEYBOARD_OVERLAY_' + ('_'.join(segments)) def ToMessageDesc(description): """Composes a message description for grd file.""" message_desc = 'The text in the keyboard overlay to explain the shortcut' if description: message_desc = '%s (%s).' % (message_desc, description) else: message_desc += '.' return message_desc def Toi18nContent(behavior): """Composes a i18n-content value for HTML/JavaScript files. Examples: 'Activate last tab' => keyboardOverlayActivateLastTab 'Close tab' => keyboardOverlayCloseTab """ segments = [segment.lower() for segment in SplitBehavior(behavior)] result = 'keyboardOverlay' for segment in segments: result += segment[0].upper() + segment[1:] return result def ToKeys(hotkey): """Converts the action value to shortcut keys used from JavaScript. Examples: 'Ctrl - 9' => '9<>CTRL' 'Ctrl - Shift - Tab' => 'tab<>CTRL<>SHIFT' """ values = hotkey.split(' - ') modifiers = sorted(value.upper() for value in values if value in ['Shift', 'Ctrl', 'Alt', 'Search']) keycode = [value.lower() for value in values if value not in ['Shift', 'Ctrl', 'Alt', 'Search']] # The keys which are highlighted even without modifier keys. base_keys = ['backspace', 'power'] if not modifiers and (keycode and keycode[0] not in base_keys): return None return '<>'.join(keycode + modifiers) def ParseOptions(): """Parses the input arguemnts and returns options.""" # default_username = os.getusername() + '@google.com'; default_username = '%s@google.com' % os.environ.get('USER') parser = optparse.OptionParser() parser.add_option('--key', dest='key', help='The key of the spreadsheet (required).') parser.add_option('--username', dest='username', default=default_username, help='Your user name (default: %s).' % default_username) parser.add_option('--password', dest='password', help='Your password.') parser.add_option('--account_type', default='GOOGLE', dest='account_type', help='Account type used for gdata login (default: GOOGLE)') parser.add_option('--js', dest='js', default=False, action='store_true', help='Output js file.') parser.add_option('--grd', dest='grd', default=False, action='store_true', help='Output resource file.') parser.add_option('--cc', dest='cc', default=False, action='store_true', help='Output cc file.') parser.add_option('--outdir', dest='outdir', default=None, help='Specify the directory files are generated.') (options, unused_args) = parser.parse_args() if not options.username.endswith('google.com'): print 'google.com account is necessary to use this script.' sys.exit(-1) if (not (options.js or options.grd or options.cc)): print 'Either --js, --grd, or --cc needs to be specified.' sys.exit(-1) # Get the password from the terminal, if needed. if not options.password: options.password = getpass.getpass( 'Application specific password for %s: ' % options.username) return options def InitClient(options): """Initializes the spreadsheet client.""" client = gdata.spreadsheet.service.SpreadsheetsService() client.email = options.username client.password = options.password client.source = 'Spread Sheet' client.account_type = options.account_type print 'Logging in as %s (%s)' % (client.email, client.account_type) client.ProgrammaticLogin() return client def PrintDiffs(message, lhs, rhs): """Prints the differences between |lhs| and |rhs|.""" dif = set(lhs).difference(rhs) if dif: print message, ', '.join(dif) def FetchSpreadsheetFeeds(client, key, sheets, cols): """Fetch feeds from the spreadsheet. Args: client: A spreadsheet client to be used for fetching data. key: A key string of the spreadsheet to be fetched. sheets: A list of the sheet names to read data from. cols: A list of columns to read data from. """ worksheets_feed = client.GetWorksheetsFeed(key) print 'Fetching data from the worksheet: %s' % worksheets_feed.title.text worksheets_data = {} titles = [] for entry in worksheets_feed.entry: worksheet_id = entry.id.text.split('/')[-1] list_feed = client.GetListFeed(key, worksheet_id) list_data = [] # Hack to deal with sheet names like 'sv (Copy of fl)' title = list_feed.title.text.split('(')[0].strip() titles.append(title) if title not in sheets: continue print 'Reading data from the sheet: %s' % list_feed.title.text for i, entry in enumerate(list_feed.entry): line_data = {} for k in entry.custom: if (k not in cols) or (not entry.custom[k].text): continue line_data[k] = entry.custom[k].text list_data.append(line_data) worksheets_data[title] = list_data PrintDiffs('Exist only on the spreadsheet: ', titles, sheets) PrintDiffs('Specified but do not exist on the spreadsheet: ', sheets, titles) return worksheets_data def FetchKeyboardGlyphData(client): """Fetches the keyboard glyph data from the spreadsheet.""" glyph_cols = ['scancode', 'p0', 'p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', 'p8', 'p9', 'label', 'format', 'notes'] keyboard_glyph_data = FetchSpreadsheetFeeds( client, KEYBOARD_GLYPH_SPREADSHEET_KEY, INPUT_METHOD_ID_TO_OVERLAY_ID.values(), glyph_cols) ret = {} for lang in keyboard_glyph_data: ret[lang] = {} keys = {} for line in keyboard_glyph_data[lang]: scancode = line.get('scancode') if (not scancode) and line.get('notes'): ret[lang]['layoutName'] = line['notes'] continue del line['scancode'] if 'notes' in line: del line['notes'] if 'label' in line: line['label'] = LABEL_MAP.get(line['label'], line['label']) keys[scancode] = line # Add a label to space key if '39' not in keys: keys['39'] = {'label': 'space'} ret[lang]['keys'] = keys return ret def FetchLayoutsData(client): """Fetches the keyboard glyph data from the spreadsheet.""" layout_names = ['U_layout', 'J_layout', 'E_layout', 'B_layout'] cols = ['scancode', 'x', 'y', 'w', 'h'] layouts = FetchSpreadsheetFeeds(client, KEYBOARD_GLYPH_SPREADSHEET_KEY, layout_names, cols) ret = {} for layout_name, layout in layouts.items(): ret[layout_name[0]] = [] for row in layout: line = [] for col in cols: value = row.get(col) if not value: line.append('') else: if col != 'scancode': value = float(value) line.append(value) ret[layout_name[0]].append(line) return ret def FetchHotkeyData(client): """Fetches the hotkey data from the spreadsheet.""" hotkey_sheet = ['Cross Platform Behaviors'] hotkey_cols = ['behavior', 'context', 'kind', 'actionctrlctrlcmdonmac', 'chromeos', 'descriptionfortranslation'] hotkey_data = FetchSpreadsheetFeeds(client, HOTKEY_SPREADSHEET_KEY, hotkey_sheet, hotkey_cols) action_to_id = {} id_to_behavior = {} # (behavior, action) result = [] for line in hotkey_data['Cross Platform Behaviors']: if (not line.get('chromeos')) or (line.get('kind') != 'Key'): continue action = ToKeys(line['actionctrlctrlcmdonmac']) if not action: continue behavior = line['behavior'].strip() description = line.get('descriptionfortranslation') result.append((behavior, action, description)) return result def UniqueBehaviors(hotkey_data): """Retrieves a sorted list of unique behaviors from |hotkey_data|.""" return sorted(set((behavior, description) for (behavior, _, description) in hotkey_data), cmp=lambda x, y: cmp(ToMessageName(x[0]), ToMessageName(y[0]))) def GetPath(path_from_src): """Returns the absolute path of the specified path.""" path = os.path.join(os.path.dirname(__file__), '../..', path_from_src) if not os.path.isfile(path): print 'WARNING: %s does not exist. Maybe moved or renamed?' % path return path def OutputFile(outpath, snippet): """Output the snippet into the specified path.""" out = file(outpath, 'w') out.write(COPYRIGHT_HEADER + '\n') out.write(snippet) print 'Output ' + os.path.normpath(outpath) def RewriteFile(start, end, original_dir, original_filename, snippet, outdir=None): """Replaces a part of the specified file with snippet and outputs it.""" original_path = GetPath(os.path.join(original_dir, original_filename)) original = file(original_path, 'r') original_content = original.read() original.close() if outdir: outpath = os.path.join(outdir, original_filename) else: outpath = original_path out = file(outpath, 'w') rx = re.compile(r'%s\n.*?%s\n' % (re.escape(start), re.escape(end)), re.DOTALL) new_content = re.sub(rx, '%s\n%s%s\n' % (start, snippet, end), original_content) out.write(new_content) out.close() print 'Output ' + os.path.normpath(outpath) def OutputJson(keyboard_glyph_data, hotkey_data, layouts, var_name, outdir): """Outputs the keyboard overlay data as a JSON file.""" action_to_id = {} for (behavior, action, _) in hotkey_data: i18nContent = Toi18nContent(behavior) action_to_id[action] = i18nContent data = {'keyboardGlyph': keyboard_glyph_data, 'shortcut': action_to_id, 'layouts': layouts, 'inputMethodIdToOverlayId': INPUT_METHOD_ID_TO_OVERLAY_ID} if not outdir: outdir = JS_OUTDIR outpath = GetPath(os.path.join(outdir, JS_FILENAME)) json_data = json.dumps(data, sort_keys=True, indent=2) # Remove redundant spaces after ',' json_data = json_data.replace(', \n', ',\n') # Replace double quotes with single quotes to avoid lint warnings. json_data = json_data.replace('\"', '\'') snippet = 'var %s = %s;\n' % (var_name, json_data) OutputFile(outpath, snippet) def OutputGrd(hotkey_data, outdir): """Outputs a part of messages in the grd file.""" snippet = cStringIO.StringIO() for (behavior, description) in UniqueBehaviors(hotkey_data): # Do not generate message for 'Show wrench menu'. It is handled manually # based on branding. if behavior == 'Show wrench menu': continue snippet.write(GRD_SNIPPET_TEMPLATE % (ToMessageName(behavior), ToMessageDesc(description), behavior)) RewriteFile(GRD_START, GRD_END, GRD_OUTDIR, GRD_FILENAME, snippet.getvalue(), outdir) def OutputCC(hotkey_data, outdir): """Outputs a part of code in the C++ file.""" snippet = cStringIO.StringIO() for (behavior, _) in UniqueBehaviors(hotkey_data): message_name = ToMessageName(behavior) output = CC_SNIPPET_TEMPLATE % (Toi18nContent(behavior), message_name) # Break the line if the line is longer than 80 characters if len(output) > 80: output = output.replace(' ' + message_name, '\n %s' % message_name) snippet.write(output) RewriteFile(CC_START, CC_END, CC_OUTDIR, CC_FILENAME, snippet.getvalue(), outdir) def main(): options = ParseOptions() client = InitClient(options) hotkey_data = FetchHotkeyData(client) if options.js: keyboard_glyph_data = FetchKeyboardGlyphData(client) if options.js: layouts = FetchLayoutsData(client) OutputJson(keyboard_glyph_data, hotkey_data, layouts, 'keyboardOverlayData', options.outdir) if options.grd: OutputGrd(hotkey_data, options.outdir) if options.cc: OutputCC(hotkey_data, options.outdir) if __name__ == '__main__': main()