Saving Opera Unite service data using File I/O

By Keith Johnson

24th April 2012: Please note

Starting with Opera 12, Opera Unite will be turned off for new users and completely removed in a later release. If you're interested in building addons for Opera, we recommend going with our extensions platform — check out our extensions documentation to get started.

Introduction

The File I/O API provides functions that allow you to manipulate files and folders from your code, allowing you to provide persistent storage for your application's state, or share files from a folder the user chooses on their local filesystem. In the context of Unite services, this is very useful for persistent content created by not only you, but also by your users. This article will cover the basics of the File I/O API by modifying the simple blog example from the Opera Unite developer's primer. We will store the blog entries in files and load them whenever the service restarts.

Table of contents:

Get the source

If you have not already read the Opera Unite developer's primer and familiarised yourself with Unite basics, we suggest you to read them first before diving into the specifics of File I/O.

You can download the original Opera Unite blog source as well as the modified blog source created throughout the course of this article. It is recommended that you download the original source and follow along with this article, making the changes as you go so you can get a better understanding of what is happening.

Modifying the example blog service

We will now begin by modifying the blog service. If something is unclear or you wish to know more about specific functions the File I/O API documentation is an excellent resource.

Enable access to the File I/O API

Unite Services do not have access to the filesystem by default; they must explicitly request it. To do this, add the following <feature> tag to the config.xml file.


…
<feature name="http://xmlns.opera.com/fileio">
</feature>
…

This code alone will give us access to two locations on the disk. The first is the Application directory, which is the location where the service files are stored. The second is a private storage directory, which is what we will be using in this service for persistent storage of the blog entries. If you wish to have access to some other location, you will need to add the folderhint parameter to this feature, which will add a folder selection box to the services configuration page.

The following is an example of how to use the folderhint parameter to ask the user to choose their home folder.


<feature name="http://xmlns.opera.com/fileio">
    <param name="folderhint" value="home" />
</feature>

The value of this parameter specifies how the question of which folder to share is presented to the user. For example, if the value is desktop, the service settings page asks the user to "Select the Desktop folder or another location from which you want to share content." There are a few other predefined values you can use and they are listed on the File I/O API Overview. Regardless of the value used, the user is presented with the option to choose a folder, and the folder will be made available to the service.

A brief overview of mount points

What we have done so far doesn't allow us to immediately access the files in the chosen location. The files will be unavailable until they are mounted to a mount point by our service. Once they are mounted to a mount point, all of the files and folders will be available at the path to that mount point. The function to do this is the mountSystemDirectory function. This function takes two arguments:

  1. The first is the location to mount. There are three valid values for the location - application, storage, and shared. The storage value is what we will be using for this service; it provides us access to a private directory only available to our service. The shared value will give you access to the folder the user chooses, and the application value gives you access to the directory where the actual files that make up the service are stored.
  2. The second argument to the function is the name you want to use for the mountpoint. This can be empty, in which case it will be mounted under the same name as the first argument. The important thing is that this function returns a File object we can use to reference the folder we mounted, and perform actions on it.

Modifying the main script.js

We will now move on to the main code driving the blog - the script.js file inside the script folder. This file is rather short, and it is recommended that you familiarize yourself with how this service works before moving on to tinker with File I/O.

Mounting our storage directory

The first thing we need to do is to mount the private storage directory so we have somewhere to store our blog entries. We will do this by adding a global variable named storage_dir to the top of the file, and then mount the directory right after the last call to addEventListener.

var storage_dir;
…
webserver.addEventListener('save', saveEntry, false);
storage_dir = opera.io.filesystem.mountSystemDirectory('storage');
…

Saving blog entries to a file

Now that we have our directory mounted and a File object referring to it, we will modify the saveEntry function to write the blog entry to a file. Let me take a moment to explain how we are going to store the blog entries. All of the blog entries will be stored in a folder called "entries". The "entries" variable was created earlier in the previous tutorial and will be used as an array to store 'title', 'text' and 'date' of blog posts.

Each blog entry will be stored in a file with an id for the filename, which is simply a number. Inside each file, the first line contains the title of the blog post, the second line contains the date of the post, and the rest of the file contains the content of the blog post itself. Let's take a look at the entire modified saveEntry function, and then go over each line.

function saveEntry(e)
{
    var request = e.connection.request;
    var response = e.connection.response;

    //Get POST data
    var title = request.bodyItems['title'][0];
    var text = request.bodyItems['text'][0];

    entries.push({
        'title' : title,
        'text'  : text,
        'date'  : new Date()
    });

    // Write this entry to a file
    var id = entries.length-1;
    var filestream = storage_dir.open('entries/' + id, 'w');
    filestream.writeLine(entries[id].title);
    filestream.writeLine(entries[id].date);
    filestream.write(entries[id].text);
    filestream.close();

    //Redirect back to the index of the service
    response.setStatusCode(302);
    response.setResponseHeader( 'Location', webserver.currentServicePath );
    response.close();
}

We begin by getting the id of this entry, which we will use as the filename.

var id = entries.length-1;

Next we need to open a file to write to. This is as simple as calling the open function of our global storage_dir object. We pass in the first argument as the path we want to open, relative to the File object we are calling open on. So if our storage_dir object was actually referring to the entries folder itself, we would call open(id, 'w'); instead, and not need to specify the full path. The second argument is the file mode we want to open the file as. The values for this are very similar to other programming languages, so you can use values such as 'r' for reading, or 'a' for appending to a file. The possible values and their meanings are covered in the API docs for open. This function returns a FileStream object that we can then use for reading or writing.

var filestream = storage_dir.open('entries/' + id, 'w');

We can now write the actual blog entry to the file we have opened. There are a few different functions for writing data to a file, depending on what you wish to write. We use two different ones here, writeLine, and write. The write function simply writes a string to a file, while the writeLine function does that and then appends a newline. Looking at the code we can see that we are writing the blog entries' title on the first line, the date on the second, and the contents of the blog into the rest of the file.

filestream.writeLine(entries[id].title);
filestream.writeLine(entries[id].date);
filestream.write(entries[id].text);

We have told the system to write our entry to the file, and now we just have one last thing to do. We no longer need to reference this filestream, so we close it with the close function.

filestream.close();

Loading the blog entries

Now that we have taken care of saving our files, we need to add some codes to load them when the service starts. The most logical place to add this code is in the window.onload function, right below where we mounted our directory. The following code block shows the modified function, containing everything we are adding below the mount function we added earlier.

…
// Make the private storage directory available to us
storage_dir = opera.io.filesystem.mountSystemDirectory('storage');

var entries_dir = storage_dir.resolve('entries');
if(!entries_dir.exists)
    storage_dir.createDirectory('entries');

// Load entries
entries_dir.refresh();
for(var i=0; i<entries_dir.length; i++)
{
    var entry = entries_dir[i];
    var id = parseInt(entry.name);

    var filestream = entry.open(null,'r');
    var title = filestream.readLine();
    var date = filestream.readLine();
    var text = filestream.read(filestream.bytesAvailable);
    filestream.close();

    entries[id] = {
        'title' : title,
        'text'  : text,
        'date'  : date
    };
}
…

Since we are going to be storing the blog entries in a folder named "entries", we need to get a File object that refers to that folder so we can list the files in it. (Note that File objects can refer to either files or directories.) To do this we are going to use the resolve function, which returns a File object for a given path. It is important to note that you can resolve non-existent files. To check if the File object that you resolved exists, you can check the exists property.

var entries_dir = storage_dir.resolve('entries');

As previously mentioned, we can check the exists property to see if the directory exists. If it doesn't, we make a call to createDirectory to create it. The createDirectory function is very simple; it tries to create a directory with the name passed as the first argument.

if(!entries_dir.exists)
    storage_dir.createDirectory('entries');

Now that we have ensured there is a directory in existence to contain our entries, we can begin going through the files in the directory and loading them into the blog. To do this we first need to call the refresh function on the directory. This is because File objects pointing to directories don't have their contents filled until this is called, and any changes made to the filesystem are not immediately present in the directory list until refresh is called. Once we have refreshed the contents of the directory, we can loop through all of the items in the directory.

entries_dir.refresh();
for(var i=0; i<entries_dir.length; i++)
{

We can get a File object of the entry we are currently looking at by accessing the directory like an array, at the index we are currently on.

var entry = entries_dir[i];

The next thing we need to do is determine where in the entries list we need to insert this entry. If we just pushed the entry onto the list, any external links to the blog post could point to the wrong post if the files were loaded in a different order the next time the service was started. One might think that since our filenames are all numbers, they would come out in some sort of order, so we wouldn't need to do this. But this isn't the case; the order of files in a directory are seemingly random. We can get the correct id by using the file's name, which we can get by accessing the name property of a File object. We use the parseInt function so that we can use this value later as an index into the entries array.

var id = parseInt(entry.name);

We can now read the contents of the file into the global entries structure. As before in the saveEntry function we modified earlier, we just use the open function to get a filestream. You will notice that we are passing null as the first argument, instead of the path as we did before. When you pass null as the first argument, you are indicating that you want to open the file pointed to by the File object you are calling open on.

var filestream = entry.open(null,'r');

Now that we have a valid filestream, we can use the read equivalents of the write functions we used earlier. We can get an entry's title with a call to readLine, which as you probably guessed, reads in one line from the file. We use the same function for getting the date from the second line. For reading the content of the blog post itself (which could span multiple lines) we use a different function - read - which takes the number of characters to read as the first argument. To read the rest of the file, we use the bytesAvailable property to indicate that we want to read the entire rest of the file.

var title = filestream.readLine();
    var date = filestream.readLine();
    var text = filestream.read(filestream.bytesAvailable);

Again, we close the file when we are done working with it.

filestream.close();

Now that we have all of the blog post content read, we can add it to the global entries variable. To do this we simply index into the entries array and set it equal to a new object with the values we just read in.

entries[id] = {
        'title' : title,
        'text'  : text,
        'date'  : date
    };
}// End for loop

The loop repeats this for every file in the entries folder; at the end of this process we have our blog back, right where we left it when we stopped the service.

A note about binary data

If you need to read/write binary data to a file, you should use ByteArrays combined with the readBytes /writeBytesfunction. This is because the normal write/writeLine functions write the content according to a given character set, which by default is UTF-8. A quick example and explanation of how to do this follows:

function writeBinary(file)
{
    var bytes = new ByteArray(2);
    // In UTF-8 these values would take two bytes each to write, resulting in 4 bytes
    // being written if you tried to write them using the standard functions.
    bytes[0] = 204;
    bytes[1] = 185;
    var filestream = file.open(null, 'w');
    filestream.writeBytes(bytes,2);
    filestream.close();
}

This is a simple function that takes a File argument as its only argument, and writes two bytes to a file. The constructor for a ByteArray takes one argument, the default length, though you can expand them just as you would normal arrays. The writeBytes function takes two arguments, the ByteArray to write, and how many bytes you wish to write from the array. The second argument can be omitted, in which case it will write the entire ByteArray to the file. Reading bytes is pretty much the same, except readBytes takes a length to read as its only argument, and returns a ByteArrray.

Additional Information

If you go through the API Documentation, you may notice some functions named browseFor*. These functions do not work with Unite; you will not be able to use them. The reason for this is that those functions can only be called as the result of a user's action, and with Opera Unite the user is unable to perform any actions directly.

The most useful link for learning more about functions not covered in this guide is the official API docs, found at ../../../libraries/fileio/

This article is licensed under a Creative Commons Attribution, Non Commercial - Share Alike 2.5 license.

Comments

The forum archive of this article is still available on My Opera.

No new comments accepted.