I’m building a basic project management system, and one of the requirements is that I want all my project’s documents to be stored and available online, exportable into multiple formats, shared among multiple users who have the ability to work collaboratively on the same document if need be. Normally, this would take a lot of work to implement – its a hefty list of requirements. But, this is exactly the functionality that Google Documents offers…wouldn’t it be great to be able to tap into the work done by Google and leverage it for my own site?
The answer is an obvious yes, and now its possible and quite easy thanks to the Google Documents API. I’ve created a full featured Ruby Gem that implements all of the features available through the Documents API: GDocs4Ruby.
This tutorial will walk through the basic steps of creating a document management system that uses the Google Documents system as the data storage back-end. I’ll be coding the front-end in Ruby on Rails.
The source for all the examples discussed below is available here.
Basic Requirements
Here’s a list of the basic requirements for the application:
- Users can view a list of documents and folders stored in the system.
- Each project has a documents folder where all related documents are stored.
- Users can upload documents from their computer.
- Users can create new documents.
- Users can delete documents.
- Users can edit the content of the document.
- Multiple users can collaboratively edit documents.
It seems like a big list, but luckily this will all be pretty easy to do.
Application Setup
First step is to create a new rails structure, I’ll be calling it ‘GDocs4Ruby_Example’.
> rails gdocs4ruby_example
Next, we’ll create the controller for the application, called Documents.
> script/generate controller Documents
So, if we were creating a normal application, we would create our models at this point. But in this case, because we’ll be storing everything on the Google servers, we’ll use the classes included in the GDocs4Ruby library as our models. They contain similar methods to ActiveRecord classes, so they integrate nicely with Ruby on Rails code.
Setting up the Session
The first thing we need to do is setup the GDocs4Ruby#Session class. This will handle all of the communication with the Google servers, and provide the authentication interface for our user.
To enable everyone to see the same list of files, we’ll want to have a base user that is used to access the Google Documents API. We could put a local user authentication system on top of this to ensure that only our project staff have access to the system, but the backend interactions will all happen under one Google account.
What we will do is create a ApplicationController method that sets up the Service object every time a request is made, called @account in the examples. Then we’ll add a before_filter to the Documents controller that ensures this setup method is called when we need to work with the Google servers.
require 'GDocs4Ruby'
class ApplicationController < ActionController::Base
include GDocs4Ruby
helper :all # include all helpers, all the time
protect_from_forgery # See ActionController::RequestForgeryProtection for details
# Scrub sensitive parameters from your log
# filter_parameter_logging :password
def setup
@account = Service.new()
@account.debug = true
@account.authenticate(USERNAME, PASSWORD)
end
end
USERNAME and PASSWORD should be constants containing the credentials for the shared account.
Here’s the code for the Documents controller.
class DocumentsController < ApplicationController before_filter :setup end
Now we have an @account object available whenever we need to interact with the Google servers.
Getting a List of Folders and Documents
The first page we want our users to see is a list of folders and documents, so they can select where to go next. I’ll use a basic 2 column layout that is familiar to everyone, with the folders on the left and the documents on the right.
First, we’ll get a list of the documents and folders in the controller, then pass that onto the view to display. I’ll also want to be able to reuse this same code for viewing the contents of folders, so I’ll add an if statement that checks to see if a folder_id parameter has been passed, and load the variables accordingly. In the Documents controller, add:
def index
if not params[:folder_id]
#Display all files and root folders
@documents = @account.files
@folders = @account.folders.select{|f| !f.parent } #display only root folders
else
#Display only files and folders contained by folder_id
@folder = Folder.find(@account, {:id => params[:folder_id]})
@documents = @folder.files
@folders = @folder.folders
end
end
If no folder is specified we grab the root files and folders from the @account Service object. Otherwise, we find the specified folder and load the child folders and files.
In the corresponding .erb file:
<% @title = 'Documents' %>
<table>
<tr>
<td style='width: 40%; vertical-align: top;'>
<strong>Folders</strong>
<ul>
<% @folders.each do |f| %>
<li>
<%= link_to f.title, :action => :index, :folder_id => f.id %> (<%= link_to 'X', {:action => :delete, :folder_id => f.id}, :confirm => 'Are you sure you want to delete this Folder?' %>)
<%= render :partial => 'folders', :locals => {:folder => f} %>
</li>
<% end %>
</ul>
</td>
<td style='vertical-align: top;'>
<strong>All Documents</strong>
<%= render :partial => 'file_list' %>
</td>
</tr>
</table>
Following DRY practices, I’ve seperated the common display code for folders and files into two partials: ‘folders’ and ‘file_list’.
Here’s the ‘folders’ partial:
<ul>
<% folder.sub_folders.each do |f| %>
<li>
<%= link_to f.title, :action => :index, :folder_id => f.id %> (<%= link_to 'X', {:action => :delete, :folder_id => f.id}, :confirm => 'Are you sure you want to delete this Folder?' %>)
<%= render :partial => 'folders', :locals => {:folder => f} %>
</li>
<% end %>
</ul>
and the ‘file_list’ partial:
<ul>
<% @documents.each do |doc| %>
<li>
<% if not doc.viewed %>
<strong>
<% end %>
<%= link_to doc.title, :action => :view, :doc_id => doc.id %> <em><%= doc.type.capitalize %></em>(<%= link_to 'X', {:action => :delete, :doc_id => doc.id}, :confirm => 'Are you sure you want to delete this file?' %>)
<% if not doc.viewed %>
</strong>
<% end %>
</li>
<% end %>
</ul>
Basically, these display the folders and files within <ul> tags, and add links to drill down into folders and view the details for files.
Now, if you run the code and view ‘/documents/’ in your browser, you should see a list of files and folders.
Viewing File Details
The next step is to implement a basic file view. When a user clicks on a file, we want them to be able to view the metadata for the file (i.e. title and author and updated date), download a copy of the file in different formats, and manage the file’s folders and user permissions. This will all be done through the ‘view’ page.
First, add a new method to the Documents controller:
def view
@document = BaseObject.find(@account, {:id => params[:doc_id]})
end
This takes the passed parameter ‘doc_id’ and loads the Document. Because we don’t know which type of file this is necessarily (i.e. Document or Spreadsheet) we’ll use the BaseObject#find method.
For the view.html.erb file, add:
<% @title = 'View Document Info' %>
<p><%= button_to_function 'Edit', "window.location = '#{url_for(:action => :edit, :doc_id => @document.id)}'" %> <%= button_to_function 'Edit in iFrame', "window.location = '#{url_for(:action => :edit_iframe, :doc_id => @document.id)}'" %>
<p><strong>Title:</strong> <%= @document.title %></p>
<p><strong>Author:</strong> <%= @document.author_name %> (<%= @document.author_email %>)</p>
<p><strong>Type:</strong> <%= @document.type %></p>
<p><strong>Published: </strong><%= Time.parse(@document.published).strftime("%m/%d/%Y") %></p>
<p><strong>Updated: </strong><%= Time.parse(@document.updated).strftime("%m/%d/%Y") %></p>
<h3>Folders</h3>
<% form_for '', :url => {:action => :update_doc_folder, :doc_id => @document.id, :do => 'add'} do %>
<p><%= select_tag :folder, options_for_select(@account.folders.select{|f| !@document.folders.include?(f.title) }.collect{|f| [f.title, f.id]}) %> <%= submit_tag 'Add to Folder' %></p>
<% end %>
<ul>
<% @document.folders.each do |f| %>
<li><%= f %> (<%= link_to 'X', {:action => :update_doc_folder, :doc_id => @document.id, :do => 'delete', :folder => f} %>)</li>
<% end %>
</ul>
<h3>Download As:</h3>
<%= render :partial => @document.type+"_download" %>
<h3>Permissions</h3>
<strong>Add User</strong>
<% form_for '', :url => {:action => :add_user, :doc_id => @document.id} do %>
<p>Email: <%= text_field_tag :user %> Role: <%= select_tag :role, options_for_select(['writer', 'reader']) %> <%= submit_tag 'Save' %>
<% end %>
<ul>
<% @document.access_rules.each do |a| %>
<li><%= a.user %> - <%= a.role %> <% if a.role != 'owner' %>(<%= link_to 'X', {:action => :remove_user, :user => a.user, :doc_id => @document.id}, :confirm => 'Are you sure you want to remove this user?' %>)<% end %></li>
<% end %>
</ul>
There are four main sections of the view: file metadata, download links, folders and user permissions.
For the file metadata, we display the title, type, author and published/updated dates. This is all loaded automatically by the Document object.
For the download links, we need to provide different download types depending on the document type. In other words, Google provides different types of file exports for each Document, Presentation or Spreadsheet. So a document can be exported as RTF, while a spreadsheet can be exported as XLS. So we’ll use a partial to display the allowed download types for each file, for example ‘document_download’. Here’s the example of the document partial:
<%= link_to 'Doc', :action => :download, :doc_id => @document.id, :type => 'doc' %> <%= link_to 'HTML', :action => :download, :doc_id => @document.id, :type => 'html' %> <%= link_to 'ODT', :action => :download, :doc_id => @document.id, :type => 'odt' %> <%= link_to 'PDF', :action => :download, :doc_id => @document.id, :type => 'pdf' %> <%= link_to 'PNG', :action => :download, :doc_id => @document.id, :type => 'png' %> <%= link_to 'RTF', :action => :download, :doc_id => @document.id, :type => 'rtf' %> <%= link_to 'TXT', :action => :download, :doc_id => @document.id, :type => 'txt' %> <%= link_to 'ZIP', :action => :download, :doc_id => @document.id, :type => 'zip' %>
and the download method in the controller looks like this:
def download
@document = BaseObject.find(@account, {:id => CGI::unescape(params[:doc_id])})
send_data @document.get_content(params[:type]), :disposition => 'inline', :filename => "#{@document.title}.#{params[:type]}"
end
To display the folders the document is contained in, we simply access the folders attribute of the document object and iterate through the returned array.
Its a similar procedure to display the users who have access to the document; we iterate through the array returned by the access_rules method. Each object in the array is a GData4Ruby#AccessRule that contains a username and role. The roles for Google Documents may either be ‘writer’ or ‘reader’ depending on whether user can edit the file.
Editing a File
There are two ways you can edit a Google Documents file:
- Download the raw HTML and use a WISYWIG text editor, like TinyMCE, to edit the HTML, then save the edited HTML back to Google. This is a great solution for documents, however it won’t work for presentations or Google Spreadsheets.
- For spreadsheets and presentations, display the file as an embedded iframe, allowing you to use Google’s UI for editing the file. The upside of this approach is that you get all the great benefits of using the Google interface, and are able to edit collaboratively with other users. The downside is that all users (at least with version 2.0 of the API) have to be specifically added as ‘writers’ to the file.
There are two links at the top of the view page that provide access to these two editing methods. There is a great helper method for generating the iframe, BaseObject#to_iframe, that you can use to create the editing window. Here is the view code for grabbing the Document content to edit (as HTML):
<% form_for '', :url => {:action => :save_content, :doc_id => @document.id} do %>
<p><%= text_area_tag 'content', @document.get_content('html'), :size => '50x10' %></p>
<%= submit_tag 'Save Content' %>
<% end %>
and for saving the metadata and content back to Google:
def save
@document = BaseObject.find(@account, {:id => CGI::unescape(params[:doc_id])})
@document.title = params[:title]
@document.save
redirect_to :action => :view, :doc_id => @document.id
end
def save_content
@document = BaseObject.find(@account, {:id => CGI::unescape(params[:doc_id])})
@document.put_content(params[:content])
redirect_to :action => :view, :doc_id => @document.id
end
Uploading New Content
Now that the file interface is done, we want to be able to existing content to our Google account, i.e. upload a file. There are two cases where this can be used:
- Uploading a new file
- Uploading new content to an existing file
Since these are essentially the same functions, we’ll combine it into a single method in the Document controller.
def send_upload
if params[:doc_id]
doc = BaseObject.find(@account, {:id => params[:doc_id]})
doc.content = params[:upload_file].read
doc.content_type = File.extname(params[:upload_file].original_filename).gsub(".", "")
if doc.save
flash[:notice] = 'File successfully uploaded'
else
flash[:warning] = 'Could not upload file!'
end
redirect_to :action => :view, :doc_id => doc.id and return
else
file = BaseObject.new(@account)
file.title = params[:upload_file].original_filename.gsub(/\.\w.*/, "")
file.content = params[:upload_file].read
file.content_type = File.extname(params[:upload_file].original_filename).gsub(".", "")
if file.save
flash[:notice] = 'File successfully uploaded'
else
flash[:warning] = 'Could not upload file!'
end
redirect_to :action => :index and return
end
end
First, we want to check to see if a document ID is passed, in which case we’ll know we want to pass the new content to an existing file. We do this by setting the ‘content’ and ‘content_type’ attributes for the Document with the passed params.
Otherwise, we create a new Document and set the ‘content’ and ‘content_type’ attributes, and save the file.
Deleting a File or Folder
Deleting a file is simple and should be familiar now:
def delete
obj = nil
if params[:doc_id]
obj = BaseObject.find(@account, {:id => params[:doc_id]})
elsif params[:folder_id]
obj = Folder.find(@account, {:id => params[:folder_id]})
end
if obj and obj.delete
flash[:notice] = 'Successfully deleted!'
else
flash[:notice] = "Error deleting!"
end
redirect_to request.referer
end
Here, we check for either a file or folder id, grab the referenced file/folder and delete it if it exists.
Access Rules
To enable a different user to access a document, we need to add an Access Rule. This requires a user id, usually an email address, and a role, either ‘writer’ or ‘viewer’. We’ll need three methods to handle adding, updating and removing access rules:
def add_user
@document = BaseObject.find(@account, {:id => CGI::unescape(params[:doc_id])})
@document.add_access_rule(params[:user], params[:role])
redirect_to :action => :view, :doc_id => @document.id
end
def update_user
@document = BaseObject.find(@account, {:id => CGI::unescape(params[:doc_id])})
@document.update_access_rule(params[:user], params[:role])
redirect_to :action => :view, :doc_id => @document.id
end
def remove_user
@document = BaseObject.find(@account, {:id => CGI::unescape(params[:doc_id])})
@document.remove_access_rule(params[:user])
redirect_to :action => :view, :doc_id => @document.id
end
These are pretty straightforward and make use of the related BaseObject functions for managing access rules.
Conclusion
And that’s it! Once you get all of it tied together, you’ll have a working document management system that stores content on Google servers, but is completely managed through your own website.

Could use out of the box, getting exceptions when requiring ‘gdata4ruby’, failing looking for base.rb – put a bogus file there – loaded fine..
features work ok but only supports a specific set of files. Google docs now supports uploading of any file , jpg, png dmg, zip etc
Tried to upload using BaseObject but no luck, couldnt find the right header content type I think.. any updates planned? thanks a bunch
Hey Chris, thanks for the note. Which version of the gem are you using? Thanks,
-Mike
Hi Chris,
Thanks again for this awesome gem!
I made it through most of the implementation, but am having trouble getting get_content to actually work.
gdoc.get_content(‘html’)
Returns:
GData4Ruby::HTTPRequestFailed:
Not Found
Not Found
Error 404
from /Users/chuboy/.gem/ruby/1.8/gems/gdata4ruby-0.1.5/lib/gdata4ruby/base.rb:126:in `do_request’
from /Users/chuboy/.gem/ruby/1.8/gems/gdata4ruby-0.1.5/lib/gdata4ruby/base.rb:94:in `send_request’
from /Users/chuboy/.gem/ruby/1.8/gems/gdocs4ruby-0.1.2/lib/gdocs4ruby/document.rb:81:in `get_content’
from (irb):33
from :0
Any ideas why this would be the case?
I realized the problem:
In gdocs4ruby-0.1.2/lib/gdocs4ruby/document.rb:81
ret = service.send_request(GData4Ruby::Request.new(:get, EXPORT_URI, nil, nil, {“docId” => @id,”exportFormat” => type}))
@id in this case actually needs to be:
1hCi8Xs4ECEn0Pc_kR8ftRc9NnZtvDO0rioQP2c-11QA
Right now, @id has a “document:” prefix to it:
document:1hCi8Xs4ECEn0Pc_kR8ftRc9NnZtvDO0rioQP2c-11QA
I think you will need to parse this out to make it work.
Thanks for the bug report Conrad. I’ll add the fix to the next release. Thanks!
Mike,
Thanks for a great gem! I’m really enjoying all of the work you put into it. I am having a problem with getting folders to list though. This is being run in a script, not a rails application. I am requiring ‘rubygems’ and ‘gdocs4ruby’ at the beginning.
I am using ruby 1.8.7 (2009-06-12 patchlevel 174) [universal-darwin10.0]
Gem version 1.3.5
My error begins with
/System/Library/Frameworks/Ruby.framework/Versions/1.8/usr/lib/ruby/1.8/net/protocol.rb:135:in `sysread’: Connection reset by peer (Errno::ECONNRESET)
Files list fine, but if I run
folders = service.folders
I get an error… bizarre.
I noticed in the base_object.rb file in your hash declaration for FEEDS you declare the feed for folder listings to be
:folder => “http://docs.google.com/feeds/folders/private/full/” (line 85ish)
Google’s protocal guide at http://code.google.com/apis/documents/docs/3.0/developers_guide_protocol.html#RetrievingFolderList
indicates the url might be
http://docs.google.com/feeds/default/private/full/-/folder
I’m not sure if this could be a contributing factor to my problem.
Thank you
Whit