Google DocsやGoogle Spreadsheetを拡張できるadd-onを作ってみる (公式ドキュメントQuickstart日本語訳 + α)


大橋です。
最近GASの記事ばかり書いていますね。

本日(2014/03/12)GoogleよりGoogle DocsやSpreadsheetsを拡張でき、ユーザがStoreから好きにインストール可能な
"add-ons"が[公開](http://googledrive.blogspot.jp/2014/03/add-ons.html)されました。

![Sheetsにアドオンメニューが追加された.png](https://qiita-image-store.s3.amazonaws.com/0/2965/33b992b6-2e2b-9d0c-c2e2-5e923cc49dbc.png "Sheetsにアドオンメニューが追加された.png")
*Sheetsにアドオンメニューが追加されている*

![addons-store.png](https://qiita-image-store.s3.amazonaws.com/0/2965/fc9388c7-1904-3d87-4e70-55895297a77a.png "addons-store.png")
*Google DriveやChrome Web StoreのようなStoreから好きなadd-onsをインストールできる*

![add-ons管理.png](https://qiita-image-store.s3.amazonaws.com/0/2965/b5aa2990-3ac7-3199-01e7-a70add7bef57.png "add-ons管理.png")
*一度別のSheetsなどでインストールしたadd-onsは別のSheetでも「アドオンを管理」メニューから利用可能状態にできる*

add-onsの開発にはGoogle Apps Scriptを利用します。
今回はどのようにadd-onsを作るのか、何ができるかを書いていきたいと思います。

P.S.
文章中のadd-onsに関する表記が揺れていてます。
多分正式名称はadd-onなのですが公式文章中でもAdd-onだったりadd-onsだったりAdd-onsだったりするので細かいところは不明です。( ｰ`дｰ´)ｷﾘｯ

# はじめに

この記事では基本的に公式ドキュメントにある「[Quickstart: Add-on for Google Docs](https://developers.google.com/apps-script/quickstart/docs)」を日本語訳っぽい感じで書いていきますが、
大事そうな話は訳以外にも書いていこうと思います。

またもっとも重要な点を先にいくつか書いておきます。

## 重要なポイント

### Sheets向けのAdd-onsは新しいスプレッドシートでのみ利用可能です。

Sheets向けのAdd-onsは先日公開された新しいスプレッドシートでのみ利用可能です。



### 2014/03/12現在 Storeへの公開はGoogleへの申請が必要です。申請が通った場合のみStoreへ公開が可能です

~~現状個人的に試すことは可能ですが、StoreへはGoogleの申請が通った後に可能になります。
申請は以下のフォームから行います。~~

~~https://docs.google.com/forms/d/1bqpfpEhiiKMqD206LBF4cL6-YdCBKfIKDQaaap8SO-g/viewform~~

2015/10/09現在 Storeへの公開は誰でも可能になりました。


### 課金の仕組は用意されていません。

公開してもインストール時に課金みたいな仕組は現状用意されていません。
課金を行いたい場合は[Google Wallet API](https://developers.google.com/wallet/)のようなAPIを利用したり、何かしらアプリケーション内で仕組を提供する必要があります。

また規約上、広告を載せることもできません。

### GAS以外での開発はできません

GASの制約に色々縛られます。縛られます。縛ら(ry

### 各ボタンなどの見た目がGoogleに近づくようなcssファイルが提供されています。

こまい部分は[こちら](https://developers.google.com/apps-script/add-ons/css)を見て下さい。
もちろん任意のCSSを利用することも可能ですがSpreadsheetなどに埋め込まれるため、 Look & Feelがそろっていたほうが良いでしょう。

---

なお翻訳元ドキュメントは [Creative Commons Attribution 3.0 License](http://creativecommons.org/licenses/by/3.0/) のライセンスです。
以下特に記載がなければ同様のライセンスとなります。
またコードについては[Apache 2.0 License](http://www.apache.org/licenses/LICENSE-2.0)となります。
以下特に記載がなければ同様のライセンスとなります。

---

# Quickstart: Add-on for Google Docs(日本語訳)

>Complete the steps described in the rest of this page, and in about five minutes you'll have created a Google Docs [add-on](https://developers.google.com/apps-script/add-ons) that translates text in a sidebar. To see what the add-on looks like when it's finished, just [install Translate](https://chrome.google.com/webstore/detail/translate/nejfnokhglmkjgpkjooijcddfecjggan) from the Google Docs add-ons store.

このページでは約5分でサイドバー中で翻訳を行う、Google Docs [add-on](https://developers.google.com/apps-script/add-ons) を作成します。
add-onを作成し終わった際の見た目や、インストール時の挙動はアドオンストアより[Translate](https://chrome.google.com/webstore/detail/translate/nejfnokhglmkjgpkjooijcddfecjggan)をインストールしてみてください。


![](https://developers.google.com/apps-script/images/quickstart-translate.png)

## 目次

* Set it up
* Try it out
* Publish
* Lean more

## Set it up(セットアップ)

>1. Create a [new Google Doc](https://drive.google.com/document).
2. From within your new document, select the menu item **Tools** > **Script editor**. If you are presented with a welcome screen, click **Blank Project**.
3. Delete any code in the script editor and paste in the code below.
4. Create a new file by selecting the menu item **File** > **New** > **Html** file. Name the file "Sidebar".
5. Delete any code in the new editor tab and paste in the code below.
6. Select the menu item File > Save all. Name your new script "Translate Quickstart" and click OK (The script's name is shown to end users in several places, including the authorization dialog.)


1. 新しい[Google Document](https://drive.google.com/document)を作ります。※リンクをクリックすると新しいGoogle Documentができます
2. 新しく作成したDocumentで **「メニュー」** > **「スクリプト エディタ…」** を選択します。もし「Welcome」画面が出た場合は **Blank Project**を選択します。
3. 既にスクリプトエディタに記載されているコードを削除し、下記コード(`code.gs`)を貼り付けます。
4. スクリプトエディタのメニューから **「ファイル」** > **「新規作成」** > **「HTML ファイル」**を選択し、新しいHTMLファイルを`Sidebar`というファイル名で作成します
5. 記載済みのHTMLを削除し、 下記HTML(`Sidebar.html`)をコピペします。
6. メニューから **「ファイル」** > **「すべてを保存」** を選択します。スクリプトの名前の設定を求められるので、「Translate Quickstart」と名づけます。この名前はユーザの至る所で目に触れます。


```js:code.gs
/**
 * Creates a menu entry in the Google Docs UI when the document is opened.
 */
function onOpen() {
  DocumentApp.getUi().createAddonMenu()
      .addItem('Start', 'showSidebar')
      .addToUi();
}

/**
 * Runs when the add-on is installed.
 */
function onInstall() {
  onOpen();
}

/**
 * Opens a sidebar in the document containing the add-on's user interface.
 */
function showSidebar() {
  var ui = HtmlService.createHtmlOutputFromFile('Sidebar')
      .setTitle('Translate');
  DocumentApp.getUi().showSidebar(ui);
}

/**
 * Gets the text the user has selected. If there is no selection,
 * this function displays an error message.
 *
 * @return {Array.<string>} The selected text.
 */
function getSelectedText() {
  var selection = DocumentApp.getActiveDocument().getSelection();
  if (selection) {
    var text = [];
    var elements = selection.getSelectedElements();
    for (var i = 0; i < elements.length; i++) {
      if (elements[i].isPartial()) {
        var element = elements[i].getElement().asText();
        var startIndex = elements[i].getStartOffset();
        var endIndex = elements[i].getEndOffsetInclusive();

        text.push(element.getText().substring(startIndex, endIndex + 1));
      } else {
        var element = elements[i].getElement();
        // Only translate elements that can be edited as text; skip images and
        // other non-text elements.
        if (element.editAsText) {
          var elementText = element.asText().getText();
          // This check is necessary to exclude images, which return a blank
          // text element.
          if (elementText != '') {
            text.push(elementText);
          }
        }
      }
    }
    if (text.length == 0) {
      throw 'Please select some text.';
    }
    return text;
  } else {
    throw 'Please select some text.';
  }
}

/**
 * Gets the stored user preferences for the origin and destination languages,
 * if they exist.
 *
 * @return {Object} The user's origin and destination language preferences, if
 *     they exist.
 */
function getPreferences() {
  var userProperties = PropertiesService.getUserProperties();
  var languagePrefs = {
    originLang: userProperties.getProperty('originLang'),
    destLang: userProperties.getProperty('destLang')
  };
  return languagePrefs;
}

/**
 * Gets the user-selected text and translates it from the origin language to the
 * destination language. The languages are notated by their two-letter short
 * form. For example, English is 'en', and Spanish is 'es'. The origin language
 * may be specified as an empty string to indicate that Google Translate should
 * auto-detect the language.
 *
 * @param {string} origin The two-letter short form for the origin language.
 * @param {string} dest The two-letter short form for the destination language.
 * @param {boolean} savePrefs Whether to save the origin and destination
 *     language preferences.
 * @return {string} The result of the translation.
 */
function runTranslation(origin, dest, savePrefs) {
  var text = getSelectedText();
  if (savePrefs == true) {
    var userProperties = PropertiesService.getUserProperties();
    userProperties.setProperty('originLang', origin);
    userProperties.setProperty('destLang', dest);
  }

  var translated = [];
  for (var i = 0; i < text.length; i++) {
    translated.push(LanguageApp.translate(text[i], origin, dest));
  }

  return translated.join('\n');
}

/**
 * Replaces the text of the current selection with the provided text, or
 * inserts text at the current cursor location. (There will always be either
 * a selection or a cursor.) If multiple elements are selected, only inserts the
 * translated text in the first element that can contain text and removes the
 * other elements.
 *
 * @param {string} newText The text with which to replace the current selection.
 */
function insertText(newText) {
  var selection = DocumentApp.getActiveDocument().getSelection();
  if (selection) {
    var replaced = false;
    var elements = selection.getSelectedElements();
    if (elements.length == 1 &&
        elements[0].getElement().getType() ==
        DocumentApp.ElementType.INLINE_IMAGE) {
      throw "Can't insert text into an image.";
    }
    for (var i = 0; i < elements.length; i++) {
      if (elements[i].isPartial()) {
        var element = elements[i].getElement().asText();
        var startIndex = elements[i].getStartOffset();
        var endIndex = elements[i].getEndOffsetInclusive();

        var remainingText = element.getText().substring(endIndex + 1);
        element.deleteText(startIndex, endIndex);
        if (!replaced) {
          element.insertText(startIndex, newText);
          replaced = true;
        } else {
          // This block handles a selection that ends with a partial element. We
          // want to copy this partial text to the previous element so we don't
          // have a line-break before the last partial.
          var parent = element.getParent();
          parent.getPreviousSibling().asText().appendText(remainingText);
          // We cannot remove the last paragraph of a doc. If this is the case,
          // just remove the text within the last paragraph instead.
          if (parent.getNextSibling()) {
            parent.removeFromParent();
          } else {
            element.removeFromParent();
          }
        }
      } else {
        var element = elements[i].getElement();
        if (!replaced && element.editAsText) {
          // Only translate elements that can be edited as text, removing other
          // elements.
          element.clear();
          element.asText().setText(newText);
          replaced = true;
        } else {
          // We cannot remove the last paragraph of a doc. If this is the case,
          // just clear the element.
          if (element.getNextSibling()) {
            element.removeFromParent();
          } else {
            element.clear();
          }
        }
      }
    }
  } else {
    var cursor = DocumentApp.getActiveDocument().getCursor();
    var surroundingText = cursor.getSurroundingText().getText();
    var surroundingTextOffset = cursor.getSurroundingTextOffset();

    // If the cursor follows or preceds a non-space character, insert a space
    // between the character and the translation. Otherwise, just insert the
    // translation.
    if (surroundingTextOffset > 0) {
      if (surroundingText.charAt(surroundingTextOffset - 1) != ' ') {
        newText = ' ' + newText;
      }
    }
    if (surroundingTextOffset < surroundingText.length) {
      if (surroundingText.charAt(surroundingTextOffset) != ' ') {
        newText += ' ';
      }
    }
    cursor.insertText(newText);
  }
}

```

```html:Sidebar.html
<link rel="stylesheet" href="https://ssl.gstatic.com/docs/script/css/add-ons.css">
<!-- The CSS package above applies Google styling to buttons and other elements. -->

<style>
.branding-below {
  bottom: 56px;
  top: 0;
}

.branding-text {
  left: 7px;
  position: relative;
  top: 3px;
}

.col-contain {
  overflow: hidden;
}

.col-one {
  float: left;
  width: 50%;
}

.logo {
  vertical-align: middle;
}

.radio-spacer {
  height: 20px;
}

.width-100 {
  width: 100%;
}
</style>

<div class="sidebar branding-below">
  <form>
    <div class="block col-contain">
      <div class="col-one">
        <b>Selected text</b>
        <div>
          <input type="radio" name="origin" id="radio-origin-auto" value="" checked="checked">
          <label for="radio-origin-auto">Auto-detect</label>
        </div>
        <div>
          <input type="radio" name="origin" id="radio-origin-en" value="en">
          <label for="radio-origin-en">English</label>
        </div>
        <div>
          <input type="radio" name="origin" id="radio-origin-fr" value="fr">
          <label for="radio-origin-fr">French</label>
        </div>
        <div>
          <input type="radio" name="origin" id="radio-origin-de" value="de">
          <label for="radio-origin-de">German</label>
        </div>
        <div>
          <input type="radio" name="origin" id="radio-origin-ja" value="ja">
          <label for="radio-origin-ja">Japanese</label>
        </div>
        <div>
          <input type="radio" name="origin" id="radio-origin-es" value="es">
          <label for="radio-origin-es">Spanish</label>
        </div>
      </div>
      <div>
        <b>Translate into</b>
        <div class="radio-spacer">
        </div>
        <div>
          <input type="radio" name="dest" id="radio-dest-en" value="en">
          <label for="radio-dest-en">English</label>
        </div>
        <div>
          <input type="radio" name="dest" id="radio-dest-fr" value="fr">
          <label for="radio-dest-fr">French</label>
        </div>
        <div>
          <input type="radio" name="dest" id="radio-dest-de" value="de">
          <label for="radio-dest-de">German</label>
        </div>
        <div>
          <input type="radio" name="dest" id="radio-dest-ja" value="ja" checked="checked">
          <label for="radio-dest-ja">Japanese</label>
        </div>
        <div>
          <input type="radio" name="dest" id="radio-dest-es" value="es">
          <label for="radio-dest-es">Spanish</label>
        </div>
      </div>
    </div>

    <div class="block form-group">
      <label for="translated-text"><b>Translation</b></label>
      <textarea class="width-100" id="translated-text" rows="10"></textarea>
    </div>

    <div class="block">
      <input type="checkbox" id="save-prefs">
      <label for="save-prefs">Use these languages by default</label>
    </div>

   <div class="block" id="button-bar">
      <button class="blue" id="run-translation">Translate</button>
      <button id="insert-text">Insert</button>
    </div>
  </form>
</div>

<div class="sidebar bottom">
  <img alt="Add-on logo" class="logo" width="27" height="27"
      src="https://googledrive.com/host/0B0G1UdyJGrY6XzdjQWF4a1JYY1k/translate-logo-small.png">
  <span class="gray branding-text">Translate sample by Google</span>
</div>

<script src="//ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js">
</script>
<script>
  /**
   * On document load, assign click handlers to each button and try to load the
   * user's origin and destination language preferences if previously set.
   */
  $(function() {
    $('#run-translation').click(runTranslation);
    $('#insert-text').click(insertText);
    google.script.run.withSuccessHandler(loadPreferences)
        .withFailureHandler(showError).getPreferences();
  });

  /**
   * Callback function that populates the origin and destination selection
   * boxes with user preferences from the server.
   *
   * @param {Object} languagePrefs The saved origin and destination languages.
   */
  function loadPreferences(languagePrefs) {
    $('input:radio[name="origin"]')
        .filter('[value=' + languagePrefs.originLang + ']')
        .attr('checked', true);
    $('input:radio[name="dest"]')
        .filter('[value=' + languagePrefs.destLang + ']')
        .attr('checked', true);
  }

  /**
   * Runs a server-side function to translate the user-selected text and update
   * the sidebar UI with the resulting translation.
   */
  function runTranslation() {
    this.disabled = true;
    $('#error').remove();
    var origin = $('input[name=origin]:checked').val();
    var dest = $('input[name=dest]:checked').val();
    var savePrefs = $('#save-prefs').is(':checked');
    google.script.run
        .withSuccessHandler(
          function(translatedText, element) {
            $('#translated-text').val(translatedText);
            element.disabled = false;
          })
        .withFailureHandler(
          function(msg, element) {
            showError(msg, $('#button-bar'));
            element.disabled = false;
          })
        .withUserObject(this)
        .runTranslation(origin, dest, savePrefs);
  }

  /**
   * Runs a server-side function to insert the translated text into the document
   * at the user's cursor or selection.
   */
  function insertText() {
    this.disabled = true;
    $('#error').remove();
    google.script.run
        .withSuccessHandler(
          function(returnSuccess, element) {
            element.disabled = false;
          })
        .withFailureHandler(
          function(msg, element) {
            showError(msg, $('#button-bar'));
            element.disabled = false;
          })
        .withUserObject(this)
        .insertText($('#translated-text').val());
  }

  /**
   * Inserts a div that contains an error message after a given element.
   *
   * @param msg The error message to display.
   * @param element The element after which to display the error.
   */
  function showError(msg, element) {
    var div = $('<div id="error" class="error">' + msg + '</div>');
    $(element).after(div);
  }
</script>
```

## Try it out (試してみる)

>1. Switch back to your document and reload the page.
2. After a few seconds, a **Translate Quickstart** sub-menu will appear under the **Add-ons** menu. (If you chose a different name for your script, that name will appear instead.) Click **Add-ons > Translate Quickstart > Start**.
3. A dialog box will appear and tell you that the script requires authorization. Click **Continue**. A second dialog box will then request authorization for specific Google services. Read the notice carefully, then click **Accept**.
4. A sidebar will appear. To test it, type some text into your document, then select it and click the blue **Translate** button. To replace the text in the document, click **Insert**.

1. Documentに戻ってリロードします。
2. リロード後数秒待つと **「アドオン」** メニュー内に **Translate Quickstart** （他のスクリプト名にしていた場合はその名前です。）が現れます。 メニューより **「アドオン」** > **「Translate Quickstart」** > **「Start」** をクリックします。
3. 認可（「認証が必要」）ダイアログが表示されたら 「続行」ボタンをクリックします。 するとGoogleへの許可リクエストダイアログが表示されるので、「承認する」をクリックします。
4. するとDocument中にサイドバーが表示されます。テストを行うためには、任意のテキストをDocumentに記載し、選択、サイドバーの「Translate」ボタンをクリックします。置換をするためには「Insert」ボタンをクリックします。

## Publish (公開する)

> Since this is an example add-on, our tutorial ends here. If you were developing a real add-on, the last step would be to [publish](https://developers.google.com/apps-script/add-ons/publish) it for other people to find and install. To learn more, check out our [add-ons guide](https://developers.google.com/add-ons).

ここでこのサンプルアプリケーションを利用したAdd-onsのチュートリアルは完了です。
もし本当のadd-onsを作成したい場合は、最終ステップとして他の人に探して、使ってもらうために[公開](https://developers.google.com/apps-script/add-ons/publish)したいと考えると思います。  これを学ぶには[Guide](https://developers.google.com/add-ons)を御覧ください。

## Lean more

> To continue learning about how to extend Google Docs with Apps Script, take a look at the following resources:


Apps Scriptを利用しGoogle Docsを拡張する方法をより学びたい場合は、下記の資料が参考になります。

* [Guide to Add-ons](https://developers.google.com/apps-script/add-ons)
* [Extending Google Docs](https://developers.google.com/apps-script/guides/docs)
* [Document Service Reference](Document Service Reference)

# まとめ

いかがでしたでしょうか？
~~今のところ申請が必要なのでちょっと敷居が高いように感じるかもしれませんが、~~
ユーザが自由にインストール可能なadd-onは今までのGASで面倒臭かった部分を非常によく解消し、
またより高度な機能も作ることができる非常に魅力的なプラットフォームになっていると思います。

もちろん今回が初回リリースなので今後も色々機能が追加されていくと思います。
期待値大ですね！