Advertisement

Toying With the HTML5 File System API

by

HTML5 provides us with a whole crop of new possibilities, such as drawing with canvas, implementing multimedia with the audio and video APIs, and so on. One of these tools, which is still relatively new, is the File System API. It gives us access to a sandboxed section of the user's local file system, thus filling the gap between desktop and web applications even further! In today's tutorial, we'll go through the basics of this new and exciting API, exploring the most common filesystem tasks. Let's get started!


Introduction

No longer do we need to download and install a given piece of software in order to use it. Simply a web browser and an internet connection gives us the ability to use any web application, anytime, anywhere, and on any platform.

In short, web apps are cool; but, compared to desktop apps, they still have one significant weakness: they don't have a way to interact and organize data into a structured hierarchy of folders - a real filesystem. Fortunately, with the new Filesystem API, this can be changed. This API gives web applications controlled access to a private local filesystem "sandbox," in which they can write and read files, create and list directories, and so on. Although at the time of this writing only Google's Chrome browser supports the "full" implementation of the Filesystem API, it still deserves to be studied as a powerful and convenient form of local storage.

Can I Use Support}

The Filesystem API comes in two different versions. The asynchronous API, which is useful for normal applications, and the synchronous API, reserved for use with web workers. For the purposes of this tutorial, we will exclusively explore the asynchronous version of the API.


Step 1 - Getting Started

Your first step is to obtain access to the HTML5 Filesystem by requesting a LocalFile System object, using the window.requestFileSystem() global method:

window.requestFileSystem(type, size, successCallback, opt_errorCallback)

There's no way for a web application to "break out" beyond the local root directory.

As the first two parameters, you specify the lifetime and size of the filesystem you want. A PERSISTENT filesystem is suitable for web apps that want to store user data permanently. The browser won't delete it, except at the user's explicit request. A TEMPORARY filesystem is appropriate for web apps that want to cache data, but can still operate if the web browser deletes the filesystem. The size of the filesystem is specified in bytes and should be a reasonable upper bound on the amount of data you need to store.

The third parameter is a callback function that is triggered when the user agent successfully provides a filesystem. Its argument is a FileSystem object. And, lastly, we can add an optional callback function, which is called when an error occurs, or the request for a filesystem is denied. Its argument is a FileError object. Although this parameter is optional, it's always a good idea to catch errors for users, as there are a number of places where things can go wrong.

The filesystem obtained with these functions depends on the origin of the containing document. All documents or web apps from the same origin (host, port, and protocol) share a filesystem. Two documents or applications from different origins have completely distinct and disjoint filesystems. A filesystem is restricted to a single application and cannot access another application's stored data. It's also isolated from the rest of the files on the user's hard drive, which is a good thing: there's no way for a web application to "break out" beyond the local root directory or otherwise access arbitrary files.

Let's review an example:

window.requestFileSystem  = window.requestFileSystem || window.webkitRequestFileSystem;

window.requestFileSystem(window.TEMPORARY, 5*1024*1024, initFS, errorHandler);

function initFS(fs){
  alert("Welcome to Filesystem! It's showtime :)"); // Just to check if everything is OK :)
  // place the functions you will learn bellow here
}

function errorHandler(){
  console.log('An error occured');
}

This creates a temporary filesystem with 5MB of storage. It then provides a success callback function, which we will use to operate our filesystem. And, of course, an error handler is also added - just in case something goes wrong. Here, the errorHandler() function is too generic. So if you want, you can create a slightly optimized version, which gives the reader a more descriptive error message:

function errorHandler(err){
 var msg = 'An error occured: ';

  switch (err.code) { 
    case FileError.NOT_FOUND_ERR: 
      msg += 'File or directory not found'; 
      break;

    case FileError.NOT_READABLE_ERR: 
      msg += 'File or directory not readable'; 
      break;

    case FileError.PATH_EXISTS_ERR: 
      msg += 'File or directory already exists'; 
      break;

    case FileError.TYPE_MISMATCH_ERR: 
      msg += 'Invalid filetype'; 
      break;

    default:
      msg += 'Unknown Error'; 
      break;
  };

 console.log(msg);
};

The filesystem object you obtain has a name (a unique name for the filesystem, assigned by the browser) and root property that refers to the root directory of the filesystem. This is a DirectoryEntry object, and it may have nested directories that are themselves represented by DirectoryEntry objects. Each directory in the file system may contain files, represented by FileEntry objects. The DirectoryEntry object defines methods for obtaining DirectoryEntry and FileEntry objects by pathname (they will optionally create new directories or files if you specify a name that doesn't exist). DirectoryEntry also defines a createReader() factory method that returns a DirectoryReader object for listing the contents of a directory. The FileEntry class defines a method for obtaining the File object (a Blob) that represents the contents of a file. You can then use a FileReader object to read the file. FileEntry defines another method to return a FileWriter object that you can use to write content into a file.

Phhew...sounds complicated? Don't worry. Everything will become clearer as we progress through the examples below.


Step 2 - Working With Directories

Obviously, the first thing you need to create in a filesystem is some buckets, or directories. Although the root directory already exists, you don't want to place all of your files there. Directories are created by the DirectoryEntry object. In the following example, we create a directory, called Documents, within the root directory:

fs.root.getDirectory('Documents', {create: true}, function(dirEntry) {
  alert('You have just created the ' + dirEntry.name + ' directory.');
}, errorHandler);

The getDirectory() method is used both to read and create directories. As the first parameter, you can pass either a name or path as the directory to look up or create. We set the second argument to true, because we're attempting to create a directory - not read an existing one. And at the end, we add an error callback.

So far, so good. We have a directory; let's now add a subdirectory. The function is exactly the same with one difference: we change the first argument from 'Documents' to 'Documents/Music'. Easy enough; but what if you want to create a subfolder, Sky, with two parent folders, Images and Nature, inside the Documents folder? If you type 'Documents/Images/Nature/Sky' for the path argument, you will receive an error, because you can't create a directory, when its immediate parent does not exist. A solution for this is to create each folder one by one: Images inside Documents, Nature inside Images, and then Sky inside Nature. But this is a very slow and inconvenient process. There is a better solution: to create a function which will create all necessary folders automatically.

function createDir(rootDir, folders) {
  rootDir.getDirectory(folders[0], {create: true}, function(dirEntry) {
    if (folders.length) {
      createDir(dirEntry, folders.slice(1));
    }
  }, errorHandler);
};

createDir(fs.root, 'Documents/Images/Nature/Sky/'.split('/'));

With this little trick, all we need to do is provide a full path representing the folders which we want to create. Now, the Sky directory is successfully created, and you can create other files or directories within it.

Now it's time to check what we have in our filesystem. We'll create a DirectoryReader object, and use the readEntries() method to read the content of the directory.

fs.root.getDirectory('Documents', {}, function(dirEntry){<br>
  var dirReader = dirEntry.createReader();
  dirReader.readEntries(function(entries) {<br>
    for(var i = 0; i < entries.length; i++) {
      var entry = entries[i];
      if (entry.isDirectory){
        console.log('Directory: ' + entry.fullPath);
      }
      else if (entry.isFile){
        console.log('File: ' + entry.fullPath);
      }
    }

  }, errorHandler);
}, errorHandler);

In the code above, the isDirectory and isFile properties are used in order to obtain a different output for directories and files, respectively. Additionally, we use the fullPath property in order to get the full path of the entry, instead of its name only.

There are two ways to remove a DirectoryEntry from the filesystem: remove() and removeRecursively(). The first one removes a given directory only if it is empty. Otherwise, you'll receive an error.

fs.root.getDirectory('Documents/Music', {}, function(dirEntry) {
  dirEntry.remove(function(){
    console.log('Directory successfully removed.');
  }, errorHandler);
}, errorHandler);

If the Music folder has files within it, then you need to use the second method, which recursively deletes the directory and all of its contents.

fs.root.getDirectory('Documents/Music', {}, function(dirEntry) {
  dirEntry.removeRecursively(function(){
    console.log('Directory successufully removed.');
  }, errorHandler);
}, errorHandler);

Step 3 - Working With Files

Now that we know how to create directories, it's time to populate them with files!

The following example creates an empty test.txt in the root directory:

fs.root.getFile('test.txt', {create: true, exclusive: true}, function(fileEntry) {
  alert('A file ' + fileEntry.name + ' was created successfully.');
}, errorHandler);

The first argument to getFile() can be an absolute or relative path, but it must be valid. For instance, it is an error to attempt to create a file, when its immediate parent does not exist. The second argument is an object literal, describing the function's behavior if the file does not exist. In this example, create: true creates the file if it doesn't exist and throws an error if it does (exclusive: true). Otherwise, if create: false, the file is simply fetched and returned.

Having an empty file is not very useful, though; so let's add some content inside. We can use the FileWriter object for this.

fs.root.getFile('test.txt', {create: false}, function(fileEntry) {
  fileEntry.createWriter(function(fileWriter) {
    window.BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder; 
    var bb = new BlobBuilder();
    bb.append('Filesystem API is awesome!');
    fileWriter.write(bb.getBlob('text/plain')); 
  }, errorHandler);
}, errorHandler);

Above, we retrieve the test.txt file, and create a FileWriter object for it. We then append content to it by creating a new BlobBuilder object and using the write() method of FileWriter.

Calling getFile() only retrieves a FileEntry. It does not return the contents of the file. So, if we want to read the content of the file, we need to use the File object and the FileReader object.

fs.root.getFile('test.txt', {}, function(fileEntry) {
  fileEntry.file(function(file) {
    var reader = new FileReader();
    reader.onloadend = function(e) {
      alert(this.result);          
    };
    reader.readAsText(file);     
  }, errorHandler);
}, errorHandler);

We have written some content to our file, but what if desire to add more at a later date? To append data to an existing file, the FileWriter is used once again. We can reposition the writer to the end of the file, using the seek() method. seek accepts a byte offset as an argument, and sets the file writer's position to that offset.

fs.root.getFile('test.txt', {create: false}, function(fileEntry) {
  fileEntry.createWriter(function(fileWriter) {
    fileWriter.seek(fileWriter.length); 
    window.BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder;
    var bb = new BlobBuilder();
    bb.append('Yes, it is!');
    fileWriter.write(bb.getBlob('text/plain'));
  }, errorHandler);
}, errorHandler);

To remove a file from the filesystem, simply call entry.remove(). The first argument to this method is a zero-parameter callback function, which is called when the file is successfully deleted. The second is an optional error callback if any errors occur.

fs.root.getFile('test.txt', {create: false}, function(fileEntry) {
  fileEntry.remove(function() {
    console.log('File successufully removed.');
  }, errorHandler);
}, errorHandler);

Step 4 - Manipulating Files and Directories

FileEntry and DirectoryEntry share the same API methods for copying, moving and renaming entries. There are two methods you can use for these operations: copyTo() and moveTo(). They both accept the exact same parameters:

copyTo(parentDirEntry, opt_newName, opt_successCallback, opt_errorCallback);

moveTo(parentDirEntry, opt_newName, opt_successCallback, opt_errorCallback);

The first parameter is the parent folder to move/copy the entry into. The second is an optional new name to give the moved/copied entry, which is actually required when you copy an entry in the same folder; otherwise you will get an error. The third and fourth parameters were explained previously.

Let's review some simple examples. In the following one, we copy the file test.txt from the root to the Documents directory.

function copy(currDir, srcEntry, destDir) {   
  currDir.getFile(srcEntry, {}, function(fileEntry) {     
    currDir.getDirectory(destDir, {}, function(dirEntry) {       
      fileEntry.copyTo(dirEntry);     
    }, errorHandler);   
  }, errorHandler); 
}

copy(fs.root, 'test.txt', 'Documents/');

This next example moves test.txt to Documents, instead of copying it:

function move(currDir, srcEntry, dirName) {   
  currDir.getFile(srcEntry, {}, function(fileEntry) {     
    currDir.getDirectory(dirName, {}, function(dirEntry) {       
      fileEntry.moveTo(dirEntry);     
    }, errorHandler);   
  }, errorHandler); 
}

move(fs.root, 'test.txt', 'Documents/');

The following example renames test.txt to text.txt:

function rename(currDir, srcEntry, newName) {   
  currDir.getFile(srcEntry, {}, function(fileEntry) {     
    fileEntry.moveTo(currDir, newName);   
  }, errorHandler); 
}

rename(fs.root, 'test.txt', 'text.txt');

Learn More

In this introductory tutorial, we've only scratched the surface of the different filesystem interfaces. If you want to learn more and dig deeper into Filesystem API, you should refer to the W3C specifications specifications:

Now that you have a basic understanding of what the Filesystem API is, and how it can be used, it should be considerably easier to understand the API documentation, which can be a bit confusing at first sight.


Conclusion

The Filesystem API is a powerful and easy to use technology, which provides web developers with a whole crop of new possibilities when building web applications. Admittedly, it's still quite new and not widely supported by all major browsers, but this will certainly change in the future. You might as well get a head start!