Change how the options are handled

Now the options are parsed by a single function in the Options class,
streamlining how they are interpreted in different parts of the code
(background page and the options page).

Bug: translateselectedtext:11
Change-Id: I1696da55c8c8d99683a0f45080b59849f9d4be4f
diff --git a/src/background.js b/src/background.js
index 286ec97..e72574e 100644
--- a/src/background.js
+++ b/src/background.js
@@ -1,18 +1,8 @@
-import {convertLanguages, isoLangs} from './common/consts.js';
+import {isoLangs} from './common/consts.js';
+import Options from './common/options.js';
 
 var array_elements = [], translator_tab = null, translator_window = null;
 
-function isEmpty(obj) {
-  return Object.keys(obj).length === 0;
-}
-
-function inObject(hayStack, el) {
-  for (var i of Object.keys(hayStack)) {
-    if (hayStack[i] == el) return true;
-  }
-  return false;
-}
-
 function getTranslationUrl(lang, text) {
   var params = new URLSearchParams({
     sl: 'auto',
@@ -24,263 +14,152 @@
 }
 
 function translationClick(info, tab) {
-  chrome.storage.sync.get('uniquetab', items => {
-    var url = getTranslationUrl(
-        array_elements[info.menuItemId]['langCode'], info.selectionText);
-    var settings_tab = {url};
-    if (translator_tab && items.uniquetab == 'yep') {
-      chrome.tabs.update(translator_tab, settings_tab, tab => {
-        chrome.tabs.highlight(
-            {
-              windowId: tab.windowId,
-              tabs: tab.index,
-            },
-            _ => {
-              chrome.windows.update(tab.windowId, {
-                focused: true,
+  Options.getOptions()
+      .then(options => {
+        var url = getTranslationUrl(
+            array_elements[info.menuItemId]['langCode'], info.selectionText);
+        var settings_tab = {url};
+        if (translator_tab && options.uniqueTab == 'yep') {
+          chrome.tabs.update(translator_tab, settings_tab, tab => {
+            chrome.tabs.highlight(
+                {
+                  windowId: tab.windowId,
+                  tabs: tab.index,
+                },
+                () => {
+                  chrome.windows.update(tab.windowId, {
+                    focused: true,
+                  });
+                });
+          });
+        } else if (options.uniqueTab == 'popup') {
+          chrome.windows.create(
+              {
+                type: 'popup',
+                url,
+                width: 1000,
+                height: 382,
+              },
+              function(tab) {
+                translator_window = tab.windowId;
+                translator_tab = tab.id;
+                chrome.windows.onRemoved.addListener(function(windowId) {
+                  if (windowId == translator_window) {
+                    translator_window = null;
+                    translator_tab = null;
+                  }
+                });
               });
-            });
-      });
-    } else if (items.uniquetab == 'panel' || items.uniquetab == 'popup') {
-      chrome.windows.create(
-          {
-            type: 'popup',
-            url,
-            width: 1000,
-            height: 382,
-          },
-          function(tab) {
+        } else {
+          chrome.tabs.create(settings_tab, function(tab) {
             translator_window = tab.windowId;
             translator_tab = tab.id;
-            chrome.windows.onRemoved.addListener(function(windowId) {
-              if (windowId == translator_window) {
+            chrome.tabs.onRemoved.addListener(function(tabId, removeInfo) {
+              if (tabId == translator_tab) {
                 translator_window = null;
                 translator_tab = null;
               }
             });
           });
-    } else {
-      chrome.tabs.create(settings_tab, function(tab) {
-        translator_window = tab.windowId;
-        translator_tab = tab.id;
-        chrome.tabs.onRemoved.addListener(function(tabId, removeInfo) {
-          if (tabId == translator_tab) {
-            translator_window = null;
-            translator_tab = null;
-          }
-        });
+        }
+      })
+      .catch(err => {
+        console.error('Error retrieving options to handle translation', err);
       });
-    }
-  });
 }
 
-function openOptionsPage() {
-  if (chrome.runtime.openOptionsPage) {
-    // New way to open options pages, if supported (Chrome 42+).
-    chrome.runtime.openOptionsPage();
-  } else {
-    chrome.tabs.create(
-        {
-          'url': 'chrome-extension://' +
-              chrome.i18n.getMessage('@@extension_id') + '/options.html',
-          'active': true
-        },
-        tab => {
-          chrome.windows.update(tab.windowId, {focused: true});
-        });
-  }
-}
+function createMenus(options) {
+  chrome.contextMenus.removeAll();
 
-function createmenus() {
-  chrome.storage.sync.get('translateinto', function(items) {
-    chrome.contextMenus.removeAll();
+  let langs = options.targetLangs;
+  let isSingleEntry = Object.values(langs).length == 1;
 
-    var count = 0, singleone = true;
-
-    for (var language of Object.keys(items.translateinto)) {
-      if (count == 0) {
-        count++;
-      } else {
-        singleone = false;
-        break;
-      }
-    }
-
-    if (singleone) {
-      for (var language_id of Object.keys(items.translateinto)) {
-        var language = items.translateinto[language_id];
-        var languagem = isoLangs[language];
-        if (languagem === undefined) {
-          console.error(language + ' doesn\'t exist!');
-          continue;
-        }
-        var id = chrome.contextMenus.create({
-          'id': 'tr_single_parent',
-          'title': chrome.i18n.getMessage('contextmenu_title2', languagem.name),
-          'contexts': ['selection'],
-        });
-        array_elements[id] = new Array();
-        array_elements[id]['langCode'] = language;
-      }
-    } else {
-      var parentEl = chrome.contextMenus.create({
-        'id': 'parent',
-        'title': chrome.i18n.getMessage('contextmenu_title'),
-        'contexts': ['selection']
-      });
-      for (var language_id of Object.keys(items.translateinto)) {
-        var language = items.translateinto[language_id];
-        var languagem = isoLangs[language];
-        if (languagem === undefined) {
-          console.error(language + ' doesn\'t exist!');
-          continue;
-        }
-        var title = languagem.name + ' (' + languagem.nativeName + ')';
-        var id = chrome.contextMenus.create({
-          'id': 'tr_language_' + language,
-          'title': title,
-          'parentId': parentEl,
-          'contexts': ['selection']
-        });
-        array_elements[id] = new Array();
-        array_elements[id]['langCode'] = language;
-      }
-      chrome.contextMenus.create({
-        'id': 'tr_separator',
-        'type': 'separator',
-        'parentId': parentEl,
-        'contexts': ['selection']
-      });
-      chrome.contextMenus.create({
-        'id': 'tr_options',
-        'title': chrome.i18n.getMessage('contextmenu_edit'),
-        'parentId': parentEl,
-        'contexts': ['selection']
-      });
-    }
-  });
-}
-
-chrome.runtime.onInstalled.addListener(function(details) {
-  chrome.storage.sync.get(null, function(items) {
-    if (details.reason == 'install') {
-      if (isEmpty(items)) {
-        var settings = {'translateinto': {}, 'uniquetab': 'popup'},
-            default_language_1 =
-                chrome.i18n.getMessage('@@ui_locale').replace('_', '-'),
-            default_language_2 =
-                chrome.i18n.getMessage('@@ui_locale').split('_')[0];
-
-        if (isoLangs[default_language_1] != undefined)
-          settings.translateinto['0'] = default_language_1;
-        else if (isoLangs[default_language_2] != undefined)
-          settings.translateinto['0'] = default_language_2;
-
-        chrome.storage.sync.set(settings, function() {
-          chrome.notifications.create('install', {
-            type: 'basic',
-            iconUrl: 'icons/translate-128.png',
-            title: chrome.i18n.getMessage('notification_install_title'),
-            message: chrome.i18n.getMessage('notification_install_message'),
-            isClickable: true
-          });
-        });
-      }
-    }
-    if (details.reason == 'update') {
-      var version = details.previousVersion.split('.');
-
-      // Updating from a version previous to v0.6
-      if (version[0] == '0' && version[1] < '6') {
-        var settings = {
-          languages: {},
-          uniquetab: '',
-        };
-        var default_language =
-            chrome.i18n.getMessage('@@ui_locale').split('_')[0];
-
-        if (isoLangs[default_language] != undefined)
-          settings.languages[default_language] = default_language;
-
-        chrome.storage.sync.set(settings);
-      }
-
-      // Updating from a version previous to v0.7
-      if (version[0] == '0' && version[1] < '7') {
-        items.translateinto = {};
-        var i = 0;
-        for (var language in items.languages) {
-          items.translateinto[i] = items.languages[language];
-          i++;
-        }
-        delete items.languages;
-        chrome.storage.sync.set(items);
-      }
-
-      // Remove non-existent languages or change with correct language code
-      if (items.translateinto) {
-        var modified = false;
-        for (var language_id of Object.keys(items.translateinto)) {
-          var language = items.translateinto[language_id];
-          if (isoLangs[language] === undefined) {
-            if (convertLanguages[language] === undefined) {
-              // The language doesn't exist
-              console.log(
-                  'Deleting ' + language +
-                  ' from items.translateinto because it doesn\'t exist.');
-              delete items.translateinto[language_id];
-            } else {
-              // The language doesn't exist but a known replacement is known
-              var newLanguage = convertLanguages[language];
-              console.log('Replacing ' + language + ' with ' + newLanguage);
-
-              // If the converted language is already on the list, just remove
-              // the wrong language, otherwise convert the language
-              if (inObject(items.translateinto, newLanguage))
-                delete items.translateinto[language_id];
-              else
-                items.translateinto[language_id] = newLanguage;
-            }
-            modified = true;
-          }
-        }
-        if (modified) chrome.storage.sync.set(items);
-      } else {
-        console.log('items.translateinto doesn\'t exist: let\'s create it.');
-        items['translateinto'] = {};
-        chrome.storage.sync.set(items);
-      }
-    }
-  });
-});
-
-chrome.storage.onChanged.addListener((changes, areaName) => {
-  if (areaName == 'sync') createmenus();
-});
-
-chrome.storage.sync.get(null, items => {
-  if (items.translateinto) {
-    createmenus();
-  } else {
-    chrome.contextMenus.removeAll();
-    var parent = chrome.contextMenus.create({
-      'id': 'tr_parent',
+  let parentEl;
+  if (!isSingleEntry) {
+    parentEl = chrome.contextMenus.create({
+      'id': 'parent',
       'title': chrome.i18n.getMessage('contextmenu_title'),
       'contexts': ['selection']
     });
-    var id = chrome.contextMenus.create({
+  }
+
+  for (let [index, language] of Object.entries(langs)) {
+    let languageDetails = isoLangs[language];
+    if (languageDetails === undefined) {
+      console.error(language + ' doesn\'t exist!');
+      continue;
+    }
+    let title;
+    if (isSingleEntry) {
+      title =
+          chrome.i18n.getMessage('contextmenu_title2', languageDetails.name);
+    } else {
+      title = languageDetails.name + ' (' + languageDetails.nativeName + ')';
+    }
+    let id = chrome.contextMenus.create({
+      'id': 'tr_language_' + language,
+      'title': title,
+      'parentId': parentEl,
+      'contexts': ['selection']
+    });
+    array_elements[id] = new Array();
+    array_elements[id]['langCode'] = language;
+  }
+
+  if (!isSingleEntry) {
+    chrome.contextMenus.create({
+      'id': 'tr_separator',
+      'type': 'separator',
+      'parentId': parentEl,
+      'contexts': ['selection']
+    });
+    chrome.contextMenus.create({
       'id': 'tr_options',
       'title': chrome.i18n.getMessage('contextmenu_edit'),
-      'parentId': parent,
+      'parentId': parentEl,
       'contexts': ['selection']
     });
   }
+}
+
+chrome.storage.onChanged.addListener((changes, areaName) => {
+  if (areaName == 'sync') {
+    Options.getOptions(/* readOnly = */ false)
+        .then(options => {
+          createMenus(options);
+        })
+        .catch(err => {
+          console.error(
+              'Error retrieving options to set up the extension after a change ' +
+                  'in the storage area.',
+              err);
+        });
+  }
 });
 
+Options.getOptions(/* readOnly = */ false)
+    .then(options => {
+      if (options.isFirstRun) {
+        chrome.notifications.create('install', {
+          type: 'basic',
+          iconUrl: 'icons/translate-128.png',
+          title: chrome.i18n.getMessage('notification_install_title'),
+          message: chrome.i18n.getMessage('notification_install_message'),
+          isClickable: true
+        });
+      }
+
+      createMenus(options);
+    })
+    .catch(err => {
+      console.error(
+          'Error retrieving options to initialize the extension.', err);
+    });
+
 chrome.notifications.onClicked.addListener(notification_id => {
   switch (notification_id) {
     case 'install':
-      openOptionsPage();
+      chrome.runtime.openOptionsPage();
       break;
   }
   chrome.notifications.clear(notification_id);
@@ -288,12 +167,12 @@
 
 chrome.contextMenus.onClicked.addListener((info, tab) => {
   if (info.menuItemId == 'tr_options') {
-    openOptionsPage();
+    chrome.runtime.openOptionsPage();
   } else {
     translationClick(info, tab);
   }
 });
 
-chrome.browserAction.onClicked.addListener(_ => {
-  openOptionsPage();
+chrome.browserAction.onClicked.addListener(() => {
+  chrome.runtime.openOptionsPage();
 });
diff --git a/src/common/options.js b/src/common/options.js
new file mode 100644
index 0000000..bfe668b
--- /dev/null
+++ b/src/common/options.js
@@ -0,0 +1,163 @@
+import {convertLanguages, isoLangs} from './consts.js';
+
+export const TAB_OPTIONS = [
+  // Open in new tab for each translation
+  {
+    value: '',
+    labelMsg: 'options_tabsoption_1',
+    deprecatedValues: [],
+  },
+  // Open in a unique tab
+  {
+    value: 'yep',
+    labelMsg: 'options_tabsoption_2',
+    deprecatedValues: [],
+  },
+  // Open in a popup
+  {
+    value: 'popup',
+    labelMsg: 'options_tabsoption_3',
+    deprecatedValues: ['panel'],
+  },
+];
+
+// Class which can be used to retrieve the user options in order to act
+// accordingly.
+export default class Options {
+  constructor(options, isFirstRun) {
+    this._options = options;
+    this.isFirstRun = isFirstRun;
+  }
+
+  get uniqueTab() {
+    return this._options.uniquetab;
+  }
+
+  get targetLangs() {
+    return this._options.translateinto;
+  }
+
+  // Returns a promise that resolves in an instance of the Object class with the
+  // current options.
+  static getOptions(readOnly = true) {
+    return Options.getOptionsRaw(readOnly).then(res => {
+      return new Options(res.options, res.isFirstRun);
+    });
+  }
+
+  // Returns a promise that resolves to an object containing:
+  // - |options|: normalized options object which can be used to initialize the
+  // Options class, and which contains the current options set up by the user.
+  // - |isFirstRun|: whether the extension is running for the first time and
+  // needs to be set up.
+  //
+  // If the options needed to be normalized/created, they are also saved in the
+  // sync storage area.
+  static getOptionsRaw(readOnly) {
+    return new Promise((res, rej) => {
+      chrome.storage.sync.get(null, items => {
+        if (chrome.runtime.lastError) {
+          return rej(chrome.runtime.lastError);
+        }
+
+        let didTranslateintoChange = false;
+        let didUniquetabChange = false;
+        let returnObject = {};
+
+        // If the extension sync storage area is blank, set this as being the
+        // first run.
+        returnObject.isFirstRun = Object.keys(items).length === 0;
+
+        // Create |translateinto| property if it doesn't exist.
+        if (items.translateinto === undefined) {
+          didTranslateintoChange = true;
+
+          // Upgrade from a version previous to v0.7 if applicable, otherwise
+          // create the property with the default values.
+          if (items.languages !== undefined) {
+            items.translateinto =
+                Object.assign({}, Object.values(items.languages));
+          } else {
+            let uiLocale = chrome.i18n.getMessage('@@ui_locale');
+            let defaultLang1 = uiLocale.replace('_', '-');
+            let defaultLang2 = uiLocale.split('_')[0];
+
+            items.translateinto = {};
+            if (isoLangs[default_language_1] != undefined)
+              items.translateinto['0'] = defaultLang1;
+            else if (isoLangs[default_language_2] != undefined)
+              items.translateinto['0'] = defaultLang2;
+          }
+        }
+
+        // Normalize |translateinto| property: remove non-existent languages or
+        // change them with the correct language code.
+        for (let [index, language] of Object.entries(items.translateinto)) {
+          if (isoLangs[language] === undefined) {
+            didTranslateintoChange = true;
+            if (convertLanguages[language] === undefined) {
+              // The language doesn't exist
+              console.log(
+                  'Deleting ' + language +
+                  ' from items.translateinto because it doesn\'t exist.');
+              delete items.translateinto[index];
+            } else {
+              // The language doesn't exist but a known replacement is known
+              let newLanguage = convertLanguages[language];
+              console.log('Replacing ' + language + ' with ' + newLanguage);
+
+              // If the converted language is already on the list, just remove
+              // the wrong language, otherwise convert the language
+              if (Object.values(items.translateinto).includes(newLanguage))
+                delete items.translateinto[index];
+              else
+                items.translateinto[index] = newLanguage;
+            }
+          }
+        }
+
+        // Normalize |uniquetab| property:
+        // - If it is set to a valid value, leave it alone.
+        // - If it is set to a deprecated value, change it to the corresponding
+        // value we use now.
+        // - If it is set to an incorrect value or it isn't set, change it to
+        // the default value.
+        let foundValue = false;
+        for (let opt of TAB_OPTIONS) {
+          if (opt.value == items?.uniquetab) {
+            foundValue = true;
+            break;
+          }
+          if (opt.deprecatedValues.includes(items?.uniquetab)) {
+            foundValue = true;
+            items.uniquetab = opt.value;
+            break;
+          }
+        }
+        if (!foundValue) {
+          items.uniquetab = 'popup';
+          didUniquetabChange = true;
+        }
+
+        // Clean up deprecated properties
+        if (items.languages !== undefined) {
+          delete items.languages;
+          chrome.storage.sync.remove('languages');
+        }
+
+        // Save properties that have changed if we're not in read-only mode
+        if (!readOnly) {
+          if (didTranslateintoChange || didUniquetabChange) {
+            chrome.storage.sync.set({
+              translateinto: items.translateinto,
+              uniquetab: items.uniquetab,
+            });
+          }
+        }
+
+        returnObject.options = items;
+        res(returnObject);
+      });
+    });
+  }
+}
diff --git a/src/options/elements/options-editor/options-editor.js b/src/options/elements/options-editor/options-editor.js
index 9e32dfa..b7eeb52 100644
--- a/src/options/elements/options-editor/options-editor.js
+++ b/src/options/elements/options-editor/options-editor.js
@@ -2,31 +2,11 @@
 import {map} from 'lit/directives/map.js';
 
 import {msg} from '../../../common/i18n.js';
+import {TAB_OPTIONS} from '../../../common/options.js';
 import {SHARED_STYLES} from '../../shared/shared-styles.js';
 
 import LanguagesEditor from './languages-editor.js';
 
-const TAB_OPTIONS = [
-  // Open in new tab for each translation
-  {
-    value: '',
-    labelMsg: 'options_tabsoption_1',
-    deprecatedValues: [],
-  },
-  // Open in a unique tab
-  {
-    value: 'yep',
-    labelMsg: 'options_tabsoption_2',
-    deprecatedValues: [],
-  },
-  // Open in a popup
-  {
-    value: 'popup',
-    labelMsg: 'options_tabsoption_3',
-    deprecatedValues: ['panel'],
-  },
-];
-
 export class OptionsEditor extends LitElement {
   static properties = {
     storageData: {type: Object},
@@ -58,7 +38,7 @@
       return html`
             <p>
               <input type="radio" name="uniquetab" id="uniquetab_${i}"
-                  value="${option?.value}" ?checked="${checked}"
+                  value="${option?.value}" .checked="${checked}"
                   @change="${() => this.changeTabOption(option.value)}">
               <label for="uniquetab_${i}">${msg(option.labelMsg)}</label></p>
           `;
diff --git a/src/options/options.js b/src/options/options.js
index ec50b6a..7a16ef9 100644
--- a/src/options/options.js
+++ b/src/options/options.js
@@ -1,10 +1,10 @@
 import {css, html, LitElement} from 'lit';
 
 import {msg} from '../common/i18n.js';
+import Options from '../common/options.js';
 
 import CreditsDialog from './elements/credits-dialog/credits-dialog.js';
 import OptionsEditor from './elements/options-editor/options-editor.js';
-
 import {SHARED_STYLES} from './shared/shared-styles.js';
 
 let bodyStyles = document.createElement('style');
@@ -87,17 +87,16 @@
   }
 
   updateStorageData() {
-    chrome.storage.sync.get(null, items => {
-      // If no settings are set
-      if (Object.keys(items).length === 0) {
-        items = {
-          translateinto: {},
-          uniquetab: 'popup',
-        };
-        chrome.storage.sync.set(items);
-      }
-      this._storageData = items;
-    });
+    Options.getOptions(/* readOnly = */ true)
+        .then(options => {
+          this._storageData = {
+            translateinto: options.targetLangs,
+            uniquetab: options.uniqueTab,
+          };
+        })
+        .catch(err => {
+          console.error('Error retrieving user options.', err);
+        });
   }
 
   showCredits() {