Saving Opera Unite service data using File I/O
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:
- The first is the location to mount. There are three valid values for the location -
application
,storage
, andshared
. Thestorage
value is what we will be using for this service; it provides us access to a private directory only available to our service. Theshared
value will give you access to the folder the user chooses, and theapplication
value gives you access to the directory where the actual files that make up the service are stored. - 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 ByteArray
s combined with the readBytes
/writeBytes
function. 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 http://dev.opera.com/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.