for every doc in our data model "docs". Each item
contains a file icon, link to open the file on the web, and last updatedDate.
Note: To make the template valid HTML, we're using data-*
attributes for Angular's
ngRepeat iterator, but you don't have to. You could easily write the repeater as
<li ng-repeat="doc in docs">
.
Next, we need to tell Angular which controller will oversee this template's rendering. For that, we
use the ngController directive to tell the DocsController
to have reign over the template
:
<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>
Keep in mind, what you don't see here is us hooking up event listeners or properties for data
binding. Angular is doing that heavy lifting for us!
The last step is to make Angular light up our templates. The typical way to do that is include the
ngApp directive all the way up on :
<html data-ng-app="gDriveApp">
You could also scope the app down to a smaller portion of the page if you wanted to. We only have
one controller in this app, but if we were to add more later, putting ngApp on the topmost
element makes the entire page Angular-ready.
The final product for main.html
looks something like this:
<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>
A word on Content Security Policy
Unlike many other JS MVC frameworks, Angular v1.1.0+ requires no tweaks to work within a strict
CSP . It just works, out of the box!
However, if you're using an older version of Angular between v1.0.1 and v1.1.0, you'll need tell
Angular to run in a "content security mode". This is done by including the ngCsp directive
alongside ngApp :
<html data-ng-app data-ng-csp>
Handling authorization
The data model isn't generated by the app itself. Instead, it's populated from an external API (the
Google Drive API). Thus, there's a bit of work necessary in order to populate the app's data.
Before we can make an API request, we need to fetch an OAuth token for the user's Google Account.
For that, we've created a method to wrap the call to chrome.identity.getAuthToken()
and store the
accessToken
, which we can reuse for future calls to the 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 );
}
};
Note: Passing the optional callback gives us the flexibility of knowing when the OAuth token is
ready.
Note: To simplify things a bit, we've created a library, gdocs.js to handle API tasks.
Once we have the token, it's time to make requests against the Drive API and populate the model.
Skeleton controller
The "model" for the Uploader is a simple array (called docs) of objects that will get rendered as
those
s in the template:
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 ();
});
}
Notice that gdocs.auth()
is called as part of the DocsController constructor. When Angular's
internals create the controller, we're insured to have a fresh OAuth token waiting for the user.
Fetching data
Template laid out. Controller scaffolded. OAuth token in hand. Now what?
It's time to define the main controller method, fetchDocs()
. It's the workhorse of the controller,
responsible for requesting the user's files and filing the docs array with data from API responses.
$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()
uses Angular's $http
service to retrieve the main feed over XHR. The oauth access
token is included in the Authorization
header along with other custom headers and parameters.
The successCallback
processes the API response and creates a new doc object for each entry in the
feed.
If you run fetchDocs()
right now, everything works and the list of files shows up:
Woot!
Wait,...we're missing those neat file icons. What gives? A quick check of the console shows a bunch
of CSP-related errors:
The reason is that we're trying to set the icons img.src
to external URLs. This violates CSP. For
example: https://ssl.gstatic.com/docs/doclist/images/icon_10_document_list.png
. To fix this, we
need to pull in these remote assets locally to the app.
Importing remote image assets
For CSP to stop yelling at us, we use XHR2 to "import" the file icons as Blobs, then set the
img.src
to a blob: URL
created by the app.
Here's the updated successCallback
with the added XHR code:
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 );
}
});
});
};
Now that CSP is happy with us again, we get nice file icons:
Going offline: caching external resources
The obvious optimization that needs to be made: not make 100s of XHR requests for each file icon on
every call to fetchDocs()
. Verify this in the Developer Tools console by pressing the "Refresh"
button several times. Every time, n images are fetched:
Let's modify successCallback
to add a caching layer. The additions are highlighted in bold:
$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 );
};
Notice that in the webkitResolveLocalFileSystemURL()
callback we're calling $scope.$apply()
when
the last entry is seen. Normally calling $apply()
isn't necessary. Angular detects changes to data
models automagically. However in our case, we have an addition layer of asynchronous callback that
Angular isn't aware of. We must explicitly tell Angular when our model has been updated.
On first run, the icons won't be in the HTML5 Filesystem and the calls to
window.webkitResolveLocalFileSystemURL()
will result in its error callback being invoked. For that
case, we can reuse the technique from before and fetch the images. The only difference this time is
that each blob is written to the filesystem (see writeFile() ). The console verifies this
behavior:
Upon next run (or press of the "Refresh" button), the URL passed to
webkitResolveLocalFileSystemURL()
exists because the file has been previously cached. The app sets
the doc.icon
to the file's filesystem: URL
and avoids making the costly XHR for the icon.
Drag and drop uploading
An uploader app is false advertising if it can't upload files!
app.js handles this feature by implementing a small library around HTML5 Drag and Drop called
DnDFileController
. It gives the ability to drag in files from the desktop and have them uploaded
to Google Drive.
Simply adding this to the gdocs service does the job:
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 ;
});