Angular は MVC フレームワークであるため、モデル、ビュー、コントローラが論理的に外れるようにアプリを定義する必要があります。幸いなことに、Angular を使用すれば、これは簡単です。
データモデル「docs」内のすべてのドキュメントに必要です。各項目には、ファイルアイコン、ウェブでファイルを開くリンク、最終更新日があります。
注: テンプレートを有効な HTML にするために、Angular の ngRepeat イテレータに data-*
属性を使用していますが、必須ではありません。リピーターは <li ng-repeat="doc in docs">
と簡単に記述できます。
次に、このテンプレートのレンダリングを監督するコントローラを Angular に指示する必要があります。そのためには、ngController ディレクティブを使用して、DocsController
にテンプレートの管理を指示します。
<body data-ng-controller="DocsController">
<section id="main">
<ul>
<li data-ng-repeat="doc in docs">
<img data-ng-src=""> <a href=""></a>
<span class="date"></span>
</li>
</ul>
</section>
</body>
ここでは、データ バインディング用にイベント リスナーまたはプロパティを接続しているわけではないことに留意してください。その負担は Angular に任せていただけます。
最後のステップでは、Angular でテンプレートをライトアップします。通常、これを行うには ngApp ディレクティブを最初に追加します。
<html data-ng-app="gDriveApp">
必要に応じて、アプリのスコープをページのより狭い範囲に限定することもできます。このアプリにはコントローラが 1 つしかありませんが、後でさらにコントローラを追加する場合は、最上位の要素に ngApp を配置するとページ全体が Angular に対応できます。
main.html
の最終的なプロダクトは次のようになります。
<html data-ng-app="gDriveApp">
<head>
…
<base target="_blank">
</head>
<body data-ng-controller="DocsController">
<section id="main">
<nav>
<h2>Google Drive Uploader</h2>
<button class="btn" data-ng-click="fetchDocs()">Refresh</button>
<button class="btn" id="close-button" title="Close"></button>
</nav>
<ul>
<li data-ng-repeat="doc in docs">
<img data-ng-src=""> <a href=""></a>
<span class="date"></span>
</li>
</ul>
</section>
コンテンツ セキュリティ ポリシーについて
他の多くの JS MVC フレームワークとは異なり、Angular v1.1.0 以降では、厳格な CSP 内で動作するための微調整は必要ありません。特別な設定なしですぐに使用できます。
ただし、v1.0.1 から v1.1.0 までの古いバージョンの Angular を使用する場合は、Angular に「コンテンツ セキュリティ モード」で実行するよう指示する必要があります。これを行うには、ngApp とともに ngCsp ディレクティブを含めます。
<html data-ng-app data-ng-csp>
認可の処理
データモデルがアプリ自体によって生成されるわけではありません。外部 API(Google Drive API)によって入力されます。そのため、アプリのデータを入力するには多少の作業が必要になります。
API リクエストを行う前に、ユーザーの Google アカウントの OAuth トークンを取得する必要があります。そこで、chrome.identity.getAuthToken()
の呼び出しをラップして accessToken
を保存するメソッドを作成しました。これは、Drive API の今後の呼び出しに再利用できます。
GDocs.prototype.auth = function(opt_callback) {
try {
chrome.identity.getAuthToken({interactive: false}, function(token) {
if (token) {
this.accessToken = token;
opt_callback && opt_callback();
}
}.bind(this));
} catch(e) {
console.log(e);
}
};
注: オプションのコールバックを渡すことで、OAuth トークンの準備ができたタイミングを柔軟に把握できます。
注: 便宜上、API タスクを処理するためのライブラリ gdocs.js を作成しました。
トークンを取得したら、Drive API に対してリクエストを行い、モデルにデータを入力します。
スケルトン コントローラ
アップローダの「モデル」は、オブジェクトとしてレンダリングされる単純な配列(ドキュメントと呼ばれます)です。
を使用します。
var gDriveApp = angular.module('gDriveApp', []);
gDriveApp.factory('gdocs', function() {
var gdocs = new GDocs();
return gdocs;
});
function DocsController($scope, $http, gdocs) {
$scope.docs = [];
$scope.fetchDocs = function() {
...
};
// Invoke on ctor call. Fetch docs after we have the oauth token.
gdocs.auth(function() {
$scope.fetchDocs();
});
}
gdocs.auth()
は、DocsController コンストラクタの一部として呼び出されています。Angular の内部でコントローラが作成されると、新しい OAuth トークンがユーザーを待機できます。
データを取得しています
テンプレートのレイアウト。コントローラはスキャフォールドです。手元にある OAuth トークン。次にすること。
それでは、メイン コントローラのメソッド fetchDocs()
を定義します。これはコントローラの主役であり、ユーザーのファイルをリクエストし、API レスポンスからのデータを使用してドキュメント配列を送信します。
$scope.fetchDocs = function() {
$scope.docs = []; // First, clear out any old results
// Response handler that doesn't cache file icons.
var successCallback = function(resp, status, headers, config) {
var docs = [];
var totalEntries = resp.feed.entry.length;
resp.feed.entry.forEach(function(entry, i) {
var doc = {
title: entry.title.$t,
updatedDate: Util.formatDate(entry.updated.$t),
updatedDateFull: entry.updated.$t,
icon: gdocs.getLink(entry.link,
'http://schemas.google.com/docs/2007#icon').href,
alternateLink: gdocs.getLink(entry.link, 'alternate').href,
size: entry.docs$size ? '( ' + entry.docs$size.$t + ' bytes)' : null
};
$scope.docs.push(doc);
// Only sort when last entry is seen.
if (totalEntries - 1 == i) {
$scope.docs.sort(Util.sortByDate);
}
});
};
var config = {
params: {'alt': 'json'},
headers: {
'Authorization': 'Bearer ' + gdocs.accessToken,
'GData-Version': '3.0'
}
};
$http.get(gdocs.DOCLIST_FEED, config).success(successCallback);
};
fetchDocs()
は、Angular の $http
サービスを使用して、XHR を介してメインフィードを取得します。OAuth アクセス トークンは、他のカスタム ヘッダーやパラメータとともに Authorization
ヘッダーに含まれます。
successCallback
は API レスポンスを処理し、フィード内のエントリごとに新しいドキュメント オブジェクトを作成します。
ここで fetchDocs()
を実行すると、すべてが動作し、ファイルのリストが表示されます。
やりました!
あれ、見事なファイル アイコンがない。なぜでしょうか?コンソールで簡単に確認すると、CSP 関連のエラーが多数表示されます。
これは、アイコン img.src
を外部 URL に設定しようとしているためです。これは CSP に違反しています。例: https://ssl.gstatic.com/docs/doclist/images/icon_10_document_list.png
この問題を解決するには、これらのリモート アセットをアプリにローカルに pull する必要があります。
リモートの画像アセットのインポート
CSP が怒鳴り合いを止めるために、XHR2 を使用してファイル アイコンを Blob として「インポート」し、img.src
をアプリによって作成された blob: URL
に設定します。
追加した XHR コードを含む更新された successCallback
は次のとおりです。
var successCallback = function(resp, status, headers, config) {
var docs = [];
var totalEntries = resp.feed.entry.length;
resp.feed.entry.forEach(function(entry, i) {
var doc = {
...
};
$http.get(doc.icon, {responseType: 'blob'}).success(function(blob) {
console.log('Fetched icon via XHR');
blob.name = doc.iconFilename; // Add icon filename to blob.
writeFile(blob); // Write is async, but that's ok.
doc.icon = window.URL.createObjectURL(blob);
$scope.docs.push(doc);
// Only sort when last entry is seen.
if (totalEntries - 1 == i) {
$scope.docs.sort(Util.sortByDate);
}
});
});
};
これで CSP に満足を得たので、次のようなファイルアイコンが表示されます。
オフラインへの移行: 外部リソースのキャッシュ
最適化が必要であることは明白です。fetchDocs()
を呼び出すたびに、ファイル アイコンごとに XHR リクエストを何百回も行うのではなく、デベロッパー ツールのコンソールで [更新] ボタンを数回押して、更新を確認します。毎回 n 枚の画像が取得されます。
successCallback
を変更して、キャッシュ レイヤを追加しましょう。追加された項目は太字でハイライト表示されています。
$scope.fetchDocs = function() {
...
// Response handler that caches file icons in the filesystem API.
var successCallbackWithFsCaching = function(resp, status, headers, config) {
var docs = [];
var totalEntries = resp.feed.entry.length;
resp.feed.entry.forEach(function(entry, i) {
var doc = {
...
};
// 'https://ssl.gstatic.com/doc_icon_128.png' -> 'doc_icon_128.png'
doc.iconFilename = doc.icon.substring(doc.icon.lastIndexOf('/') + 1);
// If file exists, it we'll get back a FileEntry for the filesystem URL.
// Otherwise, the error callback will fire and we need to XHR it in and
// write it to the FS.
var fsURL = fs.root.toURL() + FOLDERNAME + '/' + doc.iconFilename;
window.webkitResolveLocalFileSystemURL(fsURL, function(entry) {
doc.icon = entry.toURL(); // should be === to fsURL, but whatevs.
$scope.docs.push(doc); // add doc to model.
// Only want to sort and call $apply() when we have all entries.
if (totalEntries - 1 == i) {
$scope.docs.sort(Util.sortByDate);
$scope.$apply(function($scope) {}); // Inform angular that we made changes.
}
}, function(e) {
// Error: file doesn't exist yet. XHR it in and write it to the FS.
$http.get(doc.icon, {responseType: 'blob'}).success(function(blob) {
console.log('Fetched icon via XHR');
blob.name = doc.iconFilename; // Add icon filename to blob.
writeFile(blob); // Write is async, but that's ok.
doc.icon = window.URL.createObjectURL(blob);
$scope.docs.push(doc);
// Only sort when last entry is seen.
if (totalEntries - 1 == i) {
$scope.docs.sort(Util.sortByDate);
}
});
});
});
};
var config = {
...
};
$http.get(gdocs.DOCLIST_FEED, config).success(successCallbackWithFsCaching);
};
webkitResolveLocalFileSystemURL()
コールバックでは、最後のエントリが表示されたときに $scope.$apply()
を呼び出しています。通常は、$apply()
を呼び出す必要はありません。Angular はデータモデルの変更を
自動的に検出しますしかし今回の例では、Angular が認識していない非同期コールバックの追加レイヤがあります。モデルが更新されたことを Angular に明示的に伝える必要があります。
初回実行時には、HTML5 ファイル システムにアイコンは存在せず、window.webkitResolveLocalFileSystemURL()
を呼び出すとエラー コールバックが呼び出されます。その場合は、先ほどの手法を再利用して画像を取得できます。今回の唯一の違いは、各 blob がファイルシステムに書き込まれることです(writeFile() を参照)。この動作はコンソールで確認できます。
次の実行時(または [更新] ボタンを押したとき)は、ファイルがすでにキャッシュに保存されているため、webkitResolveLocalFileSystemURL()
に渡された URL が存在します。アプリは doc.icon
をファイルの filesystem: URL
に設定し、アイコンの負荷の高い XHR が発生しないようにします。
ドラッグ&ドロップによるアップロード
ファイルをアップロードできないアプリは、虚偽の広告です。
app.js は、HTML5 ドラッグ&ドロップに関連する DnDFileController
という小さなライブラリを実装することで、この機能に対処します。パソコンからファイルをドラッグして Google ドライブにアップロードできる。
これを gdocs サービスに追加するだけで、次のような処理が行われます。
gDriveApp.factory('gdocs', function() {
var gdocs = new GDocs();
var dnd = new DnDFileController('body', function(files) {
var $scope = angular.element(this).scope();
Util.toArray(files).forEach(function(file, i) {
gdocs.upload(file, function() {
$scope.fetchDocs();
});
});
});
return gdocs;
});