VSCodeに英語翻訳拡張機能を自作する

みんな大好きな高機能テキストエディタのVSCode。

これの利点のひとつに豊富な拡張機能が有ります。そしてこの拡張機能は自分でお手軽に作ることもできます。

なので、今回はこの拡張機能を自作しようとおもいます。

作る内容は選択範囲の英語を日本語に翻訳するものです。

 

用意するもの

  • VSCode
  • Node.js
  • MS Translator

ひとまず上記の3つです。

それぞれインストールします。

 

VSCode

そもそもこれは入っているかと思いますが、公式のサイトからインストーラを落として来て入れるだけです。

 

node.js

拡張機能はnode.jsで開発します。

環境は好みによってインストール方法がかわります。以下は参考例です。

※既に新しめのものがインストールされているなら別途インストールは不要です。

Windowsの場合

まずnvm-windowsをインストールします。

インストールが完了したら、コマンドラインで

nvm install latest
nvm use latest

で最新のnode.jsがインストールされます。

Macの場合

homebrewとnodebrewをインストールします。

homebrewは公式サイトにインストール方法が記述されていますので、その通りにします。

homebreaがインストールできたら、ターミナルで

brew install nodebrew
nodebrew install-binary latest
nodebrew use latest

で最新のnode.jsがインストールされます。

 

※ちなみに、既にnodebrewが入っておりそのままnodebrew install-binaryを行った結果、can not fetchというエラーが発生する場合はnodebrewのバージョンが古いため更新が必要です。

 

MS Translator

英語翻訳を行う為のシステムをMS Azureから拝借します。

Translator は、ニーズに合わせて簡単に使い始めることができます。無料のサブスクリプションに登録することで、月に 200 万字まで無料で利用できます。この上限を超える場合は、月次サブスクリプションを Windows Azure Marketplaceで購入するか、企業のお客様であればボリューム ライセンスを利用できます。

とある通り、個人利用であればお金はまずかかりません。

 

Translator APIを使い始める にある通りの手順で利用開始できます。

Azureのアカウントをつくる

とくに面倒は無かったとおもいます。

そもそもMSのアカウントを持っているのならとくに設定せずにAzureにログインするだけでおわります。

 

Tranlator APIを有効にする

左メニューの全てのサービスからCongnitive Servicesを探して選択します。

 

追加からTranslator Text APIを設定します。

※サブスクリプションどうのこうのが出た場合は次の項目を先に済ませてください。

 

適当に設定を埋めて作成します。

実際のコーディングはまだ少し先なのでStartupに表示された内容をメモしておきます。

アカウントのセットアップを行う

初めてAzureでサービスを有効化しようとすると途中でサブスクリプションの設定をやって下さいと言われるので言われるがままに登録します。

登録後に改めてTranslator APIの有効化をおこなって下さい。

2018年5月現在ではまだ、お金はかかりません。

が、規約などはちゃんと自身で確認して下さい。

※初月の一部無料が消費されますが……

 

登録には電話番号とクレジットカードが必要です。

 

拡張機能の開発環境を整える

npmをつかって環境を整えます。

VSCodeを起動しフォルダを設定します。

VSCodeのターミナルを開き必要なパッケージを入れます。

npm install -g yo generator-code

yo

yo (yoamen)は色んな開発環境のテンプレートの作成、テンプレートからの環境構築するもののようです。

generator-なんたら の名前で登録されているテンプレートをもとに開発環境を構築します。

 

yoを使ってVSCodeの開発環境を構築します。

yo code

これだけであとは質問に返答するだけで好みの開発環境が構築されます。

※一番最初には英語で「匿名で使用状況送りますか?」と聞かれるので好きな方を入力してください。(yかnを入力してエンター)

 

各質問

What type of extension do you want to create? (どの種類を開発したい?)

> New Extension (Type Script)

を選択します。

 

What’s the name of your extension? (作成する拡張機能の名前は?)

好みの名前を入力します。

ひとまず私はvscode-translationと名付けました。

 

What’s the identifier of your extension? (作成する拡張機能のマーケットでのIDは?)

とくに入力せずにエンターです。

 

What’s the description of your extension? (作成する拡張機能の説明文は?)

お好みで入力します。(マーケットに入れなければ何でも良いので)

 

What’s your publisher name? (あなたの開発者名は?)

お好みで入力します。(マーケットに入れなければ何でも良いので)

 

Enable stricter TypeScript checking in ‘tsconfig.json’? (TypeScriptの厳密な構文チェッカーを有効化する?)

Yを入力します。

 

Setup linting using ‘tslint’? (Listing:構文チェッカーを有効化する?)

Yを入力します。

 

Initialize a git repository? (gitでのバージョン管理する?)

好きな方を入力します。

※Yの場合はgitのインストールが事前に必要です。

 

実装

ここまで実装できる環境がおそらくできあがっています。

以下実際に作っていきます。

 

ファイルはsrc¥extension.tsを編集します。※ひとまずテストは実装しません。

 

以下の工程で実装します。

  • 右クリックに項目を足す
  • メニュー選択されたらプレビューウインドウを表示する
  • プレビューウインドウに選択されたテキストを表示する
  • MS Translator APIを呼び出して翻訳してプレビューウインドウに表示する

右クリックに項目を足す

'use strict';

import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {
    const kCommandName: string = 'extension.translation';

    let disposable = vscode.commands.registerCommand(kCommandName, () => {
        vscode.window.showInformationMessage('Hello World!');
    });
    context.subscriptions.push(disposable);
}

export function deactivate() {
}

まず余計なものを消します。

コマンド名もサンプルのものなので変更しています。

次に設定ファイルに右クリックの設定を付加します。

package.jsonのcontributesの中身を以下の通りに変更します。

    "activationEvents": [
        "onCommand:extension.translation"
    ],
    "contributes": {
      "menus": {
        "editor/context": [
          {
            "when": "editorHasSelection",
            "command": "extension.translattion",
            "group": "gtranslate@0"
          }
        ]
      },
      "commands": [
        {
          "command": "extension.translation",
          "title": "翻訳"
        }
      ]
    },

commandとonCommandの名前はソースで指定しているものと一致するように気をつけて下さい。

これでテキスト選択中の右クリックで翻訳が出現するようになります。

 

F5を押すと新しくVSCodeが開き動作確認が出来ます。

 

メニュー選択されたらプレビューウインドウを表示する

今はHello Worldなのでここをプレビューウインドウ表示に変更します。

少し面倒な手続きが必要です。

TextDocumentContentProviderを作成する

まず、TextDocumentContentProviderを作成する必要があります。

これはウインドウにどういう表示を行うかを指定するものです。

 

    const previewUriId = 'translate-preview';
    const previewUri = vscode.Uri.parse(`${previewUriId}://art/${previewUriId}`);
    class TextDocumentContentProvider implements vscode.TextDocumentContentProvider {
        private onDidChangeEmitter_ = new vscode.EventEmitter<vscode.Uri>();

        onDidChange?: vscode.Event<vscode.Uri> = this.onDidChangeEmitter_.event;

        private html_: string = '<div>helo</div>';

        provideTextDocumentContent(uri: vscode.Uri, token: vscode.CancellationToken): vscode.ProviderResult<string> {
            return this.html_;
        }
    };

 

必須の項目はprovideTextDocumentContentとonDidChangeの2つです。どちらもVSCodeのシステムから利用されます。

provideTextDocumentContentメソッドはVSCodeから自動的に呼びだされるもので、これの返り値が画面に表示されます。

onDidChangeもVSCodeから自動的に利用されるもので、テキストに変更があった際にシステムに通知を行うためのものです。

 

作成できたらこれをVSCodeに登録します。

    const provider = new TextDocumentContentProvider();
    const registration = vscode.workspace.registerTextDocumentContentProvider(previewUriId, provider);

    let disposable = vscode.commands.registerCommand(kCommandName, () => {
        vscode.window.showInformationMessage('Hello World!');
        vscode.commands.executeCommand('vscode.previewHtml', previewUri, vscode.ViewColumn.Two, '翻訳');
    });
    
    context.subscriptions.push(disposable, registration);

もともとはcontext.subscriptions.push(disposable)だけだったものに、第二引数を追加しています。

 

画面の表示はvscode.command.executeCommandを利用します。

vscode.previewHtmlでプレビューの表示を指示します。

vscoe.ViewColumn.Twoは画面分割の2番目に出すような指示です。

最後の「翻訳」はタブ名です。

 

実行して、Ctrl+n (Command+n)で新規タブを作り、適当に文字を入れて選択→右クリック→翻訳で新しいタブが開くと思います。

選択された文字列を翻訳APIへ

つぎは選択された文字列を取得し、MSの翻訳APIへ送信、その結果を取得するという流れをつくります。

 

選択範囲の取得

むずかしくありません。

    let disposable = vscode.commands.registerCommand(kCommandName, () => {
        // 選択範囲取得
        const active = vscode.window.activeTextEditor;
        if (!active) { return; }    // nullチェック
        const selection = active.selection;
        if (!selection) { return; } // nullチェック
        const text = active.document
                .getText(new vscode.Range(selection.start, selection.end))
                .replace(/(\s|\n)+/g, ' ')       // 空白の連続を消す
                .replace(/(^\s+)|(\s+$)/g, ''); // 前後の空白を消す

        vscode.window.showInformationMessage(text);
        vscode.commands.executeCommand('vscode.previewHtml', previewUri, vscode.ViewColumn.Two, '翻訳');
    });

vscode.window.activeTextEditorで現在操作中のタブが取得出来ます。

そこから選択中のテキストも取得できます。

 

翻訳API呼び出し

上の方で作成したAzureの翻訳APIのKeysページを開きます。

ここにある、API名とkey2つがありますので、そのうちkey1をソースに記述します。

export function activate(context: vscode.ExtensionContext) {
    const kCommandName: string = 'extension.translation';
    const previewUriId = 'translate-preview';
    const previewUri = vscode.Uri.parse(`${previewUriId}://art/${previewUriId}`);

    const kAPIKey = '<API key1>';

    // AzureのURL
    const kAuthUrl = 'https://api.cognitive.microsoft.com/sts/v1.0/issueToken';
    const kTranslateUrl = 'https://api.microsofttranslator.com/V2/Http.svc/Translate';

 

翻訳するには2ステップ必要です。

まず、keyをもとに認証トークンを取得します。

次にトークンをつけてAPIを呼び出して翻訳結果を取得します。

認証トークンの取得

httpsによる取得が必要なので、ライブラリを入れておきます。

npm install -D request @types/request

ソースの先頭で利用できるようにimportします。

'use strict';

import * as vscode from 'vscode';
import * as request from 'request';

export function activate(context: vscode.ExtensionContext) {

 

まずトークン取得処理です。

    /** API用トークンを取得 */
    function authorize(): Promise<string> {
        const promise = new Promise<string>((resolve, reject) => {
            const options = {
                'url': kAuthUrl,
                'method': 'POST',
                'headers': {
                    'Ocp-Apim-Subscription-Key': kAPIKey,
                    'Content-Type': 'application/jwt'
                }
            };
            request(options, (error, response, body) => {
                if (error) {
                    reject(error);
                } else {
                    resolve(body);
                }
            });
        });
        return promise;
    };

トークン取得用のURLへkeyをつけてhttps通信をするだけです。

Ocp-Apim-Subscription-Keyという独自のヘッダーに鍵を指定する必要があるようです。

 

翻訳API呼び出し
    /** 翻訳API呼び出し */
    function postTranslate(text: string, token: string): Promise<string> {
        const promise = new Promise<string>((resolve, reject) => {
            const options = {
                'url': kTranslateUrl,
                'method': 'GET',
                'qs': {
                    'text': text,
                    'to': 'ja',
                    'appid': 'Bearer ' + token
                },
                'json': false
            };
            request.get(options, (error, response, body) => {
                if (error) {
                    reject(error);
                } else {
                    resolve(body);
                }
            });
        });
        return promise;
    }

翻訳したい文言と先ほど取得したトークンをセットで送ります。

トークンはappidBearer トークンの形で指定します。

翻訳したい文章はtextに、翻訳した言語はtoに指定します。日本語にしたいので、jaを指定します。

 

ついでに一つにまとめます。

    /** トークンを取得して翻訳する */
    async function translate(text: string): Promise<string> {
        let token = await authorize();
        let result = await postTranslate(text, token);
        return result;
    };
translate(text).then((result) => {
    vscode.window.showInformationMessage(result);
});

利用する際はこのような形になります。

 

現状の全文

'use strict';

import * as vscode from 'vscode';
import * as request from 'request';

export function activate(context: vscode.ExtensionContext) {
    const kCommandName: string = 'extension.translation';
    const previewUriId = 'translate-preview';
    const previewUri = vscode.Uri.parse(`${previewUriId}://art/${previewUriId}`);

    const kAPIKey = '<Key1>';

    // AzureのURL
    const kAuthUrl = 'https://api.cognitive.microsoft.com/sts/v1.0/issueToken';
    const kTranslateUrl = 'https://api.microsofttranslator.com/V2/Http.svc/Translate';

    /** API用トークンを取得 */
    function authorize(): Promise<string> {
        const promise = new Promise<string>((resolve, reject) => {
            const options = {
                'url': kAuthUrl,
                'method': 'POST',
                'headers': {
                    'Ocp-Apim-Subscription-Key': kAPIKey,
                    'Content-Type': 'application/jwt'
                }
            };
            request(options, (error, response, body) => {
                if (error) {
                    reject(error);
                } else {
                    resolve(body);
                }
            });
        });
        return promise;
    };

    /** 翻訳API呼び出し */
    function postTranslate(text: string, token: string): Promise<string> {
        const promise = new Promise<string>((resolve, reject) => {
            const options = {
                'url': kTranslateUrl,
                'method': 'GET',
                'qs': {
                    'text': text,
                    'to': 'ja',
                    'appid': 'Bearer ' + token
                },
                'json': false
            };
            request.get(options, (error, response, body) => {
                if (error) {
                    reject(error);
                } else {
                    resolve(body);
                }
            });
        });
        return promise;
    }

    /** トークンを取得して翻訳する */
    async function translate(text: string): Promise<string> {
        let token = await authorize();
        let result = await postTranslate(text, token);
        return result;
    };

    /** プレビュー用のHTML生成 */
    class TextDocumentContentProvider implements vscode.TextDocumentContentProvider {
        private onDidChangeEmitter_ = new vscode.EventEmitter<vscode.Uri>();

        onDidChange?: vscode.Event<vscode.Uri> = this.onDidChangeEmitter_.event;

        keyword: string = '';
        private html_: string = '<div>helo</div>';

        provideTextDocumentContent(uri: vscode.Uri, token: vscode.CancellationToken): vscode.ProviderResult<string> {
            return this.html_;
        }

    };

    const provider = new TextDocumentContentProvider();
    const registration = vscode.workspace.registerTextDocumentContentProvider(previewUriId, provider);

    let disposable = vscode.commands.registerCommand(kCommandName, () => {
        // 選択範囲取得
        const active = vscode.window.activeTextEditor;
        if (!active) { return; }    // nullチェック
        const selection = active.selection;
        if (!selection) { return; } // nullチェック
        const text = active.document
                .getText(new vscode.Range(selection.start, selection.end))
                .replace(/(\s|\n)+/g, ' ')       // 空白の連続を消す
                .replace(/(^\s+)|(\s+$)/g, ''); // 前後の空白を消す

        vscode.commands.executeCommand('vscode.previewHtml', previewUri, vscode.ViewColumn.Two, '翻訳');

        translate(text).then((result) => {
            vscode.window.showInformationMessage(result);
        });
    });

    context.subscriptions.push(disposable, registration);
}

export function deactivate() {
}

 

翻訳結果をプレビューへ表示する

翻訳結果をプレビューへ表示するためにはTextDocumentContentProviderを拡張する必要があります。

    class TextDocumentContentProvider implements vscode.TextDocumentContentProvider {
        private onDidChangeEmitter_ = new vscode.EventEmitter<vscode.Uri>();

        onDidChange?: vscode.Event<vscode.Uri> = this.onDidChangeEmitter_.event;

        keyword: string = '';
        private html_: string = '<div>helo</div>';

        provideTextDocumentContent(uri: vscode.Uri, token: vscode.CancellationToken): vscode.ProviderResult<string> {
            return this.html_;
        }

        public setHtml(html: string) {
            this.html_ = html;
            this.onDidChangeEmitter_.fire(previewUri);
        }
    };

provideTextDocumentContentメソッドが返却する文言(html_)を更新し、更新したことを通知するためにイベントエミッタのfireメソッドを呼び出します。

 

翻訳APIが成功したらこのメソッドを呼び出すだけでプレビューが更新されます。

        translate(text).then((result) => {
            const translatedMatch = result.match(new RegExp('<string[^>]+>(.+)</string>'));
            const translated = (translatedMatch && translatedMatch.length > 1) ? translatedMatch[1] : '翻訳失敗';
            const html = `<h2>${text}</h2><div>${translated}</div>`;
            provider.setHtml(html);
        });

翻訳結果はxmlなのでいい加減な正規表現で切り出して設定しています。

これでひとまず完成です。
'use strict';

import * as vscode from 'vscode';
import * as request from 'request';

export function activate(context: vscode.ExtensionContext) {
    const kCommandName: string = 'extension.translation';
    const previewUriId = 'translate-preview';
    const previewUri = vscode.Uri.parse(`${previewUriId}://art/${previewUriId}`);

    const kAPIKey = '<key>';

    // AzureのURL
    const kAuthUrl = 'https://api.cognitive.microsoft.com/sts/v1.0/issueToken';
    const kTranslateUrl = 'https://api.microsofttranslator.com/V2/Http.svc/Translate';

    /** API用トークンを取得 */
    function authorize(): Promise<string> {
        const promise = new Promise<string>((resolve, reject) => {
            const options = {
                'url': kAuthUrl,
                'method': 'POST',
                'headers': {
                    'Ocp-Apim-Subscription-Key': kAPIKey,
                    'Content-Type': 'application/jwt'
                }
            };
            request(options, (error, response, body) => {
                if (error) {
                    reject(error);
                } else {
                    resolve(body);
                }
            });
        });
        return promise;
    };

    /** 翻訳API呼び出し */
    function postTranslate(text: string, token: string): Promise<string> {
        const promise = new Promise<string>((resolve, reject) => {
            const options = {
                'url': kTranslateUrl,
                'method': 'GET',
                'qs': {
                    'text': text,
                    'to': 'ja',
                    'appid': 'Bearer ' + token
                },
                'json': false
            };
            request.get(options, (error, response, body) => {
                if (error) {
                    reject(error);
                } else {
                    resolve(body);
                }
            });
        });
        return promise;
    }

    /** トークンを取得して翻訳する */
    async function translate(text: string): Promise<string> {
        let token = await authorize();
        let result = await postTranslate(text, token);
        return result;
    };

    /** プレビュー用のHTML生成 */
    class TextDocumentContentProvider implements vscode.TextDocumentContentProvider {
        private onDidChangeEmitter_ = new vscode.EventEmitter<vscode.Uri>();

        onDidChange?: vscode.Event<vscode.Uri> = this.onDidChangeEmitter_.event;

        keyword: string = '';
        private html_: string = '<div>翻訳中...</div>';

        provideTextDocumentContent(uri: vscode.Uri, token: vscode.CancellationToken): vscode.ProviderResult<string> {
            return this.html_;
        }

        public setHtml(html: string) {
            this.html_ = html;
            this.onDidChangeEmitter_.fire(previewUri);
        }
    };

    const provider = new TextDocumentContentProvider();
    const registration = vscode.workspace.registerTextDocumentContentProvider(previewUriId, provider);

    let disposable = vscode.commands.registerCommand(kCommandName, () => {
        // 選択範囲取得
        const active = vscode.window.activeTextEditor;
        if (!active) { return; }    // nullチェック
        const selection = active.selection;
        if (!selection) { return; } // nullチェック
        const text = active.document
                .getText(new vscode.Range(selection.start, selection.end))
                .replace(/(\s|\n)+/g, ' ')       // 空白の連続を消す
                .replace(/(^\s+)|(\s+$)/g, ''); // 前後の空白を消す

        vscode.commands.executeCommand('vscode.previewHtml', previewUri, vscode.ViewColumn.Two, '翻訳');

        translate(text).then((result) => {
            const translatedMatch = result.match(new RegExp('<string[^>]+>(.+)</string>'));
            const translated = (translatedMatch && translatedMatch.length > 1) ? translatedMatch[1] : '翻訳失敗';
            const html = `<h2>${text}</h2><div>${translated}</div>`;
            provider.setHtml(html);
        });
    });

    context.subscriptions.push(disposable, registration);
}

export function deactivate() {
}
{
    "name": "vscode-translation",
    "displayName": "vscode-translation",
    "description": "tranlation",
    "version": "0.0.1",
    "publisher": "cfm-art",
    "engines": {
        "vscode": "^1.23.0"
    },
    "categories": [
        "Other"
    ],
    "activationEvents": [
        "onCommand:extension.translation"
    ],
    "main": "./out/extension",
    "contributes": {
        "menus": {
            "editor/context": [
                {
                    "when": "editorHasSelection",
                    "command": "extension.translation",
                    "group": "gtranslate@0"
                }
            ]
        },
        "commands": [
            {
                "command": "extension.translation",
                "title": "翻訳"
            }
        ]
    },
    "scripts": {
        "vscode:prepublish": "npm run compile",
        "compile": "tsc -p ./",
        "watch": "tsc -watch -p ./",
        "postinstall": "node ./node_modules/vscode/bin/install",
        "test": "npm run compile && node ./node_modules/vscode/bin/test"
    },
    "devDependencies": {
        "@types/mocha": "^2.2.42",
        "@types/node": "^7.0.43",
        "@types/request": "^2.47.0",
        "tslint": "^5.8.0",
        "typescript": "^2.6.1",
        "vscode": "^1.1.6",
        "request": "^2.87.0"
    }
}

私の環境ではこのようになっています。

インストールする

現状はデバッグでしか利用できないのでインストールします。

インストールは必要なファイルをVSCodeの拡張機能用のフォルダに置くだけです。

必要なファイル・フォルダは以下の通りです。

  • node_modules
  • out
  • README.md
  • package.json

です。

置く場所は、ユーザーフォルダー/.vscode/extenstions/です。

隠しフォルダなので、注意して下さい。

ここにフォルダを作成し、上記4つのファイルとフォルダをコピーすれば完了です。

ちなみにフォルダ名はなんでもよさげです。(私はart-translation-0.0.1としています。)

VSCodeを再起動して、拡張機能の項目を見るとこっそり増えています。

 

改善点

キャッシュ

API呼び出しは件数ごとにお金がかかる上に通信が入る分多少時間がかかるので、今までに翻訳したものはキャッシュしておくと良いと思います。

 

翻訳結果を複数出す

見辛くない範囲で、翻訳した結果は画面に残し続けた方が使い勝手が良さそうです。

 

装飾

h2とdivで出しているだけなので、装飾した方が見やすいかもしれません。

タイトルとURLをコピーしました