Add experimental feature to translate links

This CL adds support for translating links directly from the context
menu. This support is experimental, since many details of the
implementation might change after testing it for a while and finding
ways to improve it (hopefully also thanks to the feedback given by folks
who might also test this).

For this reason, the feature is only enabled in Canary builds of the
extension for now (or when the extension is being developed).

This CL also changes the Crowdin configuration file so strings that
change in the source file and are uploaded for translation don't remove
the existing translations. This is because although no strings are
changed in this CL, the description/context of 2 strings has changed,
and this is to be sure that the translations are not erased.

Bug: translateselectedtext:7
Change-Id: I862af18eee8830c901e7fe7d2b473cab43d9bbe7
diff --git a/src/background.ts b/src/background.ts
index 941e198..1bed978 100644
--- a/src/background.ts
+++ b/src/background.ts
@@ -2,55 +2,90 @@
 import {isoLangs} from './common/consts';
 import Options from './common/options';
 import ExtSessionStorage from './common/sessionStorage';
+import URLFactory from './common/urlFactory';
 
-interface ContextMenuLangs {
-  [id: string]: string;
+type NonEmptyArray<T> = [T, ...T[]];
+
+// Data types that the extension can translate.
+export enum DataType {
+  DataTypeText,
+  DataTypeURL,
 }
 
-function getTranslationUrl(lang: string, text: string): string {
-  const params = new URLSearchParams({
-    sl: 'auto',
-    tl: lang,
-    text: text,
-    op: 'translate',
-  });
-  return 'https://translate.google.com/?' + params.toString();
+// Information about a context menu item which we have added to the browser.
+interface MenuItemInfo {
+  language: string;    // Target language displayed in the item.
+  dataType: DataType;  // Data type handled by the context menu item.
 }
 
+// Object with the context menu items that have been added by the extension and
+// information about them.
+interface ContextMenuItems {
+  [id: string]: MenuItemInfo;
+}
+
+// Definition of the types of context menu items that the extension can inject.
+interface MenuItemType {
+  // Type of data which can be translated with this type of context menu items.
+  dataType: DataType;
+  // Contexts in which this type of context menu item will be shown.
+  contexts: NonEmptyArray<chrome.contextMenus.ContextType>;
+  // Prefix of the i18n messages for this type of context menu item, and used to
+  // generate the unique IDs of context menu items.
+  prefix: string;
+}
+type MenuItemTypes = MenuItemType[];
+
+const MENU_ITEM_TYPES: MenuItemTypes = [
+  {
+    dataType: DataType.DataTypeText,
+    contexts: ['selection'],
+    prefix: '',
+  },
+  /*
+   * @TODO(https://iavm.xyz/b/translateselectedtext/7): Delete this compile-time
+   * directive after the experimentation phase is done to launch the feature.
+   * #!if canary || !production
+   */
+  {
+    dataType: DataType.DataTypeURL,
+    contexts: ['link'],
+    prefix: 'link',
+  },
+  // #!endif
+];
+
 function translationClick(info: chrome.contextMenus.OnClickData): void {
   const optionsPromise = Options.getOptions();
   const ssPromise =
-      ExtSessionStorage.get(['contextMenuLangs', 'translatorTab']);
+      ExtSessionStorage.get(['contextMenuItems', 'translatorTab']);
   Promise.all([optionsPromise, ssPromise])
       .then(returnValues => {
         const [options, sessionStorageItems] = returnValues;
-        const url = getTranslationUrl(
-            sessionStorageItems.contextMenuLangs?.[info.menuItemId],
-            info.selectionText);
-        const settings_tab = {url};
-        if (sessionStorageItems.translatorTab && options.uniqueTab == 'yep') {
-          chrome.tabs.update(
-              sessionStorageItems.translatorTab, 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,
+        const contextMenuItems: ContextMenuItems =
+            sessionStorageItems.contextMenuItems;
+        const contextMenuItem = contextMenuItems?.[info.menuItemId];
+        const translatorTab: number = sessionStorageItems.translatorTab;
+
+        const url = URLFactory.getTranslationURL(
+            contextMenuItem?.language, info, contextMenuItem?.dataType);
+
+        if (contextMenuItem?.dataType !== DataType.DataTypeText) {
+          // Always create a simple new tab for data types other than text.
+          // @TODO(https://iavm.xyz/b/translateselectedtext/7): Review this
+          // behavior in the future.
+          chrome.tabs.create({url});
+        } else if (translatorTab && options.uniqueTab == 'yep') {
+          chrome.tabs.update(translatorTab, {url}, 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});
         } else {
-          chrome.tabs.create(settings_tab, function(tab) {
+          chrome.tabs.create({url}, tab => {
             ExtSessionStorage.set({translatorTab: tab.id});
           });
         }
@@ -63,60 +98,65 @@
 function createMenus(options: Options): Promise<void> {
   chrome.contextMenus.removeAll();
 
-  const contextMenuLangs: ContextMenuLangs = {};
+  const contextMenuItems: ContextMenuItems = {};
   const langs = options.targetLangs;
   const isSingleEntry = Object.values(langs).length == 1;
 
-  let parentEl;
-  if (!isSingleEntry) {
-    parentEl = chrome.contextMenus.create({
-      'id': 'parent',
-      'title': chrome.i18n.getMessage('contextmenu_title'),
-      'contexts': ['selection']
-    });
-  }
-
-  for (const language of Object.values(langs)) {
-    const languageDetails = isoLangs[language];
-    if (languageDetails === undefined) {
-      console.error(language + ' doesn\'t exist!');
-      continue;
+  for (const type of MENU_ITEM_TYPES) {
+    let parentEl;
+    if (!isSingleEntry) {
+      parentEl = chrome.contextMenus.create({
+        'id': `${type.prefix}parent`,
+        'title': chrome.i18n.getMessage(`contextmenu${type.prefix}_title`),
+        'contexts': type.contexts,
+      });
     }
-    let title;
-    if (isSingleEntry) {
-      title =
-          chrome.i18n.getMessage('contextmenu_title2', languageDetails.name);
-    } else {
-      title = languageDetails.name + ' (' + languageDetails.nativeName + ')';
+
+    for (const language of Object.values(langs)) {
+      const languageDetails = isoLangs[language];
+      if (languageDetails === undefined) {
+        console.error(language + ' doesn\'t exist!');
+        continue;
+      }
+      let title;
+      if (isSingleEntry) {
+        title = chrome.i18n.getMessage(
+            `contextmenu${type.prefix}_title2`, languageDetails.name);
+      } else {
+        title = languageDetails.name + ' (' + languageDetails.nativeName + ')';
+      }
+      const id = chrome.contextMenus.create({
+        'id': `${type.prefix}tr_language_${language}`,
+        'title': title,
+        'parentId': parentEl,
+        'contexts': type.contexts,
+      });
+      contextMenuItems[id] = {
+        language,
+        dataType: type.dataType,
+      };
     }
-    const id = chrome.contextMenus.create({
-      'id': 'tr_language_' + language,
-      'title': title,
-      'parentId': parentEl,
-      'contexts': ['selection']
-    });
-    contextMenuLangs[id] = language;
+
+    if (!isSingleEntry) {
+      chrome.contextMenus.create({
+        'id': `${type.prefix}tr_separator`,
+        'type': 'separator',
+        'parentId': parentEl,
+        'contexts': type.contexts,
+      });
+      chrome.contextMenus.create({
+        'id': `${type.prefix}tr_options`,
+        'title': chrome.i18n.getMessage('contextmenu_edit'),
+        'parentId': parentEl,
+        'contexts': type.contexts,
+      });
+    }
   }
 
-  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': parentEl,
-      'contexts': ['selection']
-    });
-  }
-
-  return ExtSessionStorage.set({contextMenuLangs});
+  return ExtSessionStorage.set({contextMenuItems});
 }
 
-chrome.storage.onChanged.addListener((changes, areaName) => {
+chrome.storage.onChanged.addListener((_changes, areaName) => {
   if (areaName == 'sync') {
     Options.getOptions(/* readOnly = */ false)
         .then(options => {
@@ -159,11 +199,14 @@
 });
 
 chrome.contextMenus.onClicked.addListener(info => {
-  if (info.menuItemId == 'tr_options') {
-    chrome.runtime.openOptionsPage();
-  } else {
-    translationClick(info);
+  for (const type of MENU_ITEM_TYPES) {
+    if (info.menuItemId == `${type.prefix}tr_options`) {
+      chrome.runtime.openOptionsPage();
+      return;
+    }
   }
+
+  translationClick(info);
 });
 
 chrome.tabs.onRemoved.addListener(tabId => {
diff --git a/src/common/urlFactory.ts b/src/common/urlFactory.ts
new file mode 100644
index 0000000..6e410af
--- /dev/null
+++ b/src/common/urlFactory.ts
@@ -0,0 +1,38 @@
+import {DataType} from '../background';
+
+export default class URLFactory {
+  static getTranslationURL(
+      lang: string, info: chrome.contextMenus.OnClickData,
+      dataType: DataType) {
+    switch (dataType) {
+      case DataType.DataTypeText:
+        return URLFactory.getTranslationURLForText(lang, info.selectionText);
+
+      case DataType.DataTypeURL:
+        return URLFactory.getTranslationURLForURL(lang, info.linkUrl);
+
+      default:
+        console.error('Can\'t return translation URL for unknown data type.');
+        return 'about:blank?translate_selected_text_error';
+    }
+  }
+
+  static getTranslationURLForText(lang: string, text: string): string {
+    const params = new URLSearchParams({
+      sl: 'auto',
+      tl: lang,
+      text: text,
+      op: 'translate',
+    });
+    return 'https://translate.google.com/?' + params.toString();
+  }
+
+  static getTranslationURLForURL(lang: string, url: string): string {
+    const params = new URLSearchParams({
+      sl: 'auto',
+      tl: lang,
+      u: url,
+    });
+    return 'https://translate.google.com/translate?' + params.toString();
+  }
+}
diff --git a/src/static/_locales/en/messages.json b/src/static/_locales/en/messages.json
index d8bd3cf..ac04853 100644
--- a/src/static/_locales/en/messages.json
+++ b/src/static/_locales/en/messages.json
@@ -1,98 +1,112 @@
 {
-	"appName": {
-		"message": "Translate Selected Text",
-		"description": "The app name"
-	},
-	"appBetaName": {
-		"message": "Translate Selected Text (Beta)",
-		"description": "The beta app name"
-	},
+  "appName": {
+    "message": "Translate Selected Text",
+    "description": "The app name"
+  },
+  "appBetaName": {
+    "message": "Translate Selected Text (Beta)",
+    "description": "The beta app name"
+  },
   "appCanaryName": {
-		"message": "Translate Selected Text (Canary)",
-		"description": "The beta app name"
-	},
-	"appDescription": {
-		"message": "Translate selected text with Google Translate",
-		"description": "The app description"
-	},
-	"contextmenu_title": {
-		"message": "Translate selection into...",
-		"description": "Title of the context menu that appears when a right click is done. Inside this parent item there are the target languages."
-	},
-	"contextmenu_title2": {
-		"message": "Translate selection into '$language$'",
-		"description": "Title of the context menu that appears when a right click is done and there's only one language available.",
-		"placeholders": {
-          "language": {
-            "content": "$1",
-            "example": "Language to translate into."
-          }
-        }
-	},
-	"contextmenu_edit": {
-		"message": "Edit languages...",
-		"description": "Title of the item inside the 'Translate section into...' context menu which is used to go to the options page."
-	},
-	"options_welcome": {
-		"message": "Welcome!",
-		"description": "Title of the options page which welcomes users"
-	},
-	"options_introduction": {
-		"message": "Please, select the languages you want to show up in the \"translate\" menu.",
-		"description": "Introduction paragraph in the options page preceding the language list."
-	},
-	"options_languageselectheader": {
-		"message": "Languages:",
-		"description": "Header of the selected languages list."
-	},
-	"options_otheroptionsheader": {
-		"message": "Other options:",
-		"description": "Subheader in the options page before the options to choose where should the translation be shown.."
-	},
-	"options_tabsoption_1": {
-		"message": "Open Google Translate in a new tab for each translation.",
-		"description": "Option which defines where should the translations be shown (in this case, each translation will be opened in a new tab)."
-	},
-	"options_tabsoption_2": {
-		"message": "Open Google Translate in a unique tab and override the last translation instead of opening several tabs.",
-		"description": "Option which defines where should the translations be shown (in this case, each translation will be opened in the same tab used for the previous translation, or will open a new tab if there aren't any translation tabs open)."
-	},
-	"options_tabsoption_3": {
-		"message": "Open Google Translate in a popup.",
-		"description": "Option which defines where should the translations be shown (in this case, each translation will be opened in a new popup)."
-	},
-	"options_savebutton": {
-		"message": "Save",
-		"description": "Save button in the settings page"
-	},
-	"options_addlanguage": {
-		"message": "Add language",
-		"description": "Title for the 'Add language' dialog"
-	},
-	"options_addlanguage_addbutton": {
-		"message": "Add",
-		"description": "'Add' button in the footer of the languages list which is used to show a dialog with a list of potential languages to add. In the dialog, this message is also shown inside a button which is used to confirm the action."
-	},
-	"options_language_label": {
-		"message": "Language:",
-		"description": "Label for the language selector in the 'Add language' dialog"
-	},
-	"options_credits": {
-		"message": "Credits",
-		"description": "Message for the link to the credits dialog, and also the title for the credits dialog, which shows a list of open source projects used inside the extension and a list of translators/contributors."
-	},
+    "message": "Translate Selected Text (Canary)",
+    "description": "The beta app name"
+  },
+  "appDescription": {
+    "message": "Translate selected text with Google Translate",
+    "description": "The app description"
+  },
+  "contextmenu_title": {
+    "message": "Translate selection into...",
+    "description": "Title of the context menu that appears when selecting text in a website and then right clicking it. Inside this parent item there are the target languages."
+  },
+  "contextmenu_title2": {
+    "message": "Translate selection into '$language$'",
+    "description": "Title of the context menu that appears after selecting text in a website and right clicking it, when only one target language has been configured.",
+    "placeholders": {
+      "language": {
+        "content": "$1",
+        "example": "Language to translate into."
+      }
+    }
+  },
+  "contextmenulink_title": {
+    "message": "Translate this link to...",
+    "description": "Title of the context menu that appears when right clicking a link. Inside this parent item there are the target languages."
+  },
+  "contextmenulink_title2": {
+    "message": "Translate this link to '$language$'",
+    "description": "Title of the context menu that appears after right clicking a link, when only one target language has been configured.",
+    "placeholders": {
+      "language": {
+        "content": "$1",
+        "example": "Language to translate into."
+      }
+    }
+  },
+  "contextmenu_edit": {
+    "message": "Edit languages...",
+    "description": "Title of the item inside the 'Translate section into...' context menu which is used to go to the options page."
+  },
+  "options_welcome": {
+    "message": "Welcome!",
+    "description": "Title of the options page which welcomes users"
+  },
+  "options_introduction": {
+    "message": "Please, select the languages you want to show up in the \"translate\" menu.",
+    "description": "Introduction paragraph in the options page preceding the language list."
+  },
+  "options_languageselectheader": {
+    "message": "Languages:",
+    "description": "Header of the selected languages list."
+  },
+  "options_otheroptionsheader": {
+    "message": "Other options:",
+    "description": "Subheader in the options page before the options to choose where should the translation be shown.."
+  },
+  "options_tabsoption_1": {
+    "message": "Open Google Translate in a new tab for each translation.",
+    "description": "Option which defines where should the translations be shown (in this case, each translation will be opened in a new tab)."
+  },
+  "options_tabsoption_2": {
+    "message": "Open Google Translate in a unique tab and override the last translation instead of opening several tabs.",
+    "description": "Option which defines where should the translations be shown (in this case, each translation will be opened in the same tab used for the previous translation, or will open a new tab if there aren't any translation tabs open)."
+  },
+  "options_tabsoption_3": {
+    "message": "Open Google Translate in a popup.",
+    "description": "Option which defines where should the translations be shown (in this case, each translation will be opened in a new popup)."
+  },
+  "options_savebutton": {
+    "message": "Save",
+    "description": "Save button in the settings page"
+  },
+  "options_addlanguage": {
+    "message": "Add language",
+    "description": "Title for the 'Add language' dialog"
+  },
+  "options_addlanguage_addbutton": {
+    "message": "Add",
+    "description": "'Add' button in the footer of the languages list which is used to show a dialog with a list of potential languages to add. In the dialog, this message is also shown inside a button which is used to confirm the action."
+  },
+  "options_language_label": {
+    "message": "Language:",
+    "description": "Label for the language selector in the 'Add language' dialog"
+  },
+  "options_credits": {
+    "message": "Credits",
+    "description": "Message for the link to the credits dialog, and also the title for the credits dialog, which shows a list of open source projects used inside the extension and a list of translators/contributors."
+  },
   "options_credits_createdby": {
     "message": "Extension created by: <a href=\"https://www.avm99963.com/\" target=\"_blank\">@avm99963</a> (Adrià Vilanova Martínez)",
     "description": "Text shown in the credits dialog, which shows that the extension was created by Adrià Vilanova Martínez (and their username is avm99963)."
   },
-	"options_credits_homepage": {
-		"message": "homepage",
-		"description": "Text shown for links to go to each homepage of open source projects in the credits. NOTE: put in in lowercase letters"
-	},
-	"options_credits_by": {
-		"message": "by",
-		"description": "Fragment of the author statement in an item of the credits. NOTE: put in in lowercase letters. EXAMPLE: '{{options_credits_by}} Adrià Vilanova Martínez'"
-	},
+  "options_credits_homepage": {
+    "message": "homepage",
+    "description": "Text shown for links to go to each homepage of open source projects in the credits. NOTE: put in in lowercase letters"
+  },
+  "options_credits_by": {
+    "message": "by",
+    "description": "Fragment of the author statement in an item of the credits. NOTE: put in in lowercase letters. EXAMPLE: '{{options_credits_by}} Adrià Vilanova Martínez'"
+  },
   "options_credits_translations": {
     "message": "Translations",
     "description": "Header for the section in the credits dialog which recognizes translators."
@@ -101,14 +115,18 @@
     "message": "I would like to give a very special thank you to the following contributors, who have selflessly translated the extension interface to many languages:",
     "description": "Paragraph in the 'Translations' section of the credits dialog, which recognizes translators. Following this paragraph there's a list with all the translators' names (if you've translated a string in Crowdin, you'll automatically be added to the list in the following extension update)."
   },
-	"options_ok": {
-		"message": "OK",
-		"description": "OK button in informative dialogs, which is used to close them (no action is done when pressing these buttons)"
-	},
-	"options_cancel": {
-		"message": "Cancel",
-		"description": "Cancel button in the dialogs, to reject an action."
-	},
-	"notification_install_title": { "message": "Thanks for installing 'Translate Selected Text'" },
-	"notification_install_message": { "message": "Click this notification to set it up." }
+  "options_ok": {
+    "message": "OK",
+    "description": "OK button in informative dialogs, which is used to close them (no action is done when pressing these buttons)"
+  },
+  "options_cancel": {
+    "message": "Cancel",
+    "description": "Cancel button in the dialogs, to reject an action."
+  },
+  "notification_install_title": {
+    "message": "Thanks for installing 'Translate Selected Text'"
+  },
+  "notification_install_message": {
+    "message": "Click this notification to set it up."
+  }
 }
diff --git a/tools/i18n/crowdin.template.yml b/tools/i18n/crowdin.template.yml
index 9186c2a..17aba16 100644
--- a/tools/i18n/crowdin.template.yml
+++ b/tools/i18n/crowdin.template.yml
@@ -35,5 +35,8 @@
       "zh-TW" : "zh_TW",
     }
   },
+
+  # This is so when editing a source string, the translations are preserved.
+  "update_option": "update_without_changes",
  }
 ]