| #!/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' <!-- BEGIN GENERATED KEYBOARD OVERLAY STRINGS -->' |
| GRD_END = r' <!-- END GENERATED KEYBOARD OVERLAY STRINGS -->' |
| |
| 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 = { |
| '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: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::fil': 'en_US', |
| 'xkb:us::ind': 'en_US', |
| 'xkb:us::msa': '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', |
| 'xkb:us:intl:nld': 'en_US_intl', |
| 'xkb:us:intl:por': 'en_US_intl', |
| 'xkb:us:workman:eng': 'en_US_workman', |
| 'xkb:us:workman-intl:eng': 'en_US_workman_intl', |
| } |
| |
| # 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=""" <message name="%s" desc="%s"> |
| %s |
| </message> |
| """ |
| |
| # 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() |