Customer Demands
Recently, I have been spending some weekend and late night hours on extending the
QuickHoney web system. The artists want to see the system modernized with RSS feeds and more interaction, and we'll also be adding a shop where both digital and real warez can be bought.
Peter in particular wants to get more feedback, so he asked me to add a "quick feedback" mechanism that visitors can use to send a short textual comment on any of the pictures. In this post, I'll describe how I added this feature using Javascript,
BKNR,
CL-SMTP and
CL-MIME.
First off, here is a little overview of the QuickHoney web application. It is a early AJAX style application with one HTML page consisting of a number of layers which are controlled by a Javascript application. Communication between the Javascript application and the backend server is done through Javascript code snippets that are generated on the server and evaluated inside the client application using IFRAMES. The user navigates through categories and subcategories to individual pictures.
The backend for the QuickHoney application is written in Common Lisp using the BKNR framework. It uses the datastore extensively as well as the
cl-gd image processing library written by Edi Weitz.
Frontend Functionality
The feedback functionality will work on a per-picture basis. When a picture is displayed, a small "provide feedback" icon will be displayed. When the user clicks it, a form will be displayed in a layered window. The form will consist of "From" and "Text" fields and a "Send" button. The
onclick action of the button is connected to a function that packs the contents of the "From" and "Text" fields into a urlencoded form data string and send it to the server using a POST request:
function digg_send()
{
var d = doXHR("/digg-image/" + current_image.id,
{ method: 'POST',
headers: {"Content-Type":"application/x-www-form-urlencoded"},
sendContent: queryString({ from: $('digg_from').value,
text: $('digg_text').value }) })
.addCallback(function () { alert('sent'); });
return false;
}
doXHR and
$ are functions from the
MochiKit Javascript library which I like to use for non-GUI-related things for its conciseness and conceptional soundness.
As can be seen in the Javascript snippet above, there is a
/digg-image/ handler on the web server that is used to send the feedback. The URL that is used also consists of the object ID of the image currently displayed. Every persistent object in the BKNR datastore has a unique object ID, and the
FIND-STORE-OBJECT function can be used to find an object with a certain object ID in the store.
Implementing a Backend Handler
The BKNR web framework makes it easy for applications to declare new handlers which relate to a certain store object. It provides for a set of handler classes that application handlers can inherit from. These handler base classes implement request URL parsing and automatically call handler methods with with relevant information from the URL and the request body parsed into Lisp objects.
For the feedback feature, we need to subclass the
OBJECT-HANDLER base class which extracts the object ID out of the URL, uses
FIND-STORE-OBJECT to find the relevant object in the store and then calls the
HANDLE-OBJECT method of the handler to actually handle the request. The declaration for this handler class looks like this:
(defclass digg-image-handler (object-handler)
()
(:default-initargs :object-class 'quickhoney-image))
The
:OBJECT-CLASS initarg can optionally be specified when creating an
OBJECT-HANDLER object to make sure that
HANDLE-OBJECT is only called for objects of that class. If the object ID in the URL references an object from a different class, an error page is displayed to the user.
The
HANDLE-OBJECT method for the
DIGG-IMAGE-HANDLER class is specialized on both the
DIGG-IMAGE-HANDLER handler class and on the
QUICKHONEY-IMAGE class. It extracts the form parameters from the request and opens a connection to the SMTP server to send a mail to the owner or owners of the
QUICKHONEY-IMAGE. The mail itself is a simple HTML mail with a table containing the name of the image, hyperlinked to the online page with the image and the feedback information entered by the user. Also, a thumbnail of the image is included with the email so that the artist immediately sees what picture the user is raving about. Modern mailers (like Apple Mail or Google Mail) display images attached in a multipart/mixed MIME mail inline, so there is no need to come up with a fancy HTML mail that references elements included in the same mail body.
This is the source code of the handler:
(defmethod handle-object ((handler digg-image-handler) (image quickhoney-image))
(with-query-params (from text)
(cl-smtp:with-smtp-mail (smtp "localhost"
"webserver@quickhoney.com"
(remove-duplicates (mapcar #'user-email
(or (owned-object-owners image)
(list (find-user "n")
(find-user "p"))))))
(cl-mime:print-mime
smtp
(make-instance
'cl-mime:multipart-mime
:subtype "mixed"
:content (list
(make-instance
'cl-mime:mime
:type "text" :subtype "html"
:content (with-output-to-string (s)
(html-stream s
(:html
(:head
(:title "Picture comment"))
(:body
(:table
(:tbody
(:tr
((:td :colspan "2")
"Comment on picture "
((:a :href (make-image-link image))
(:princ-safe (store-image-name image)))))
(:tr
(:td (:b "From"))
(:td (:princ-safe from))))
(:tr
((:td :valign "top") (:b "Text"))
(:td (:princ-safe text)))))))))
(make-instance
'cl-mime:mime
:type "image"
:subtype (string-downcase (symbol-name (blob-type image)))
:encoding :base64
:content (flexi-streams:with-output-to-sequence (s)
(blob-to-stream image s)))))
t t))))
For completeness, let me also show you how the handler is entered into the list of handlers of the Quickhoney backend server:
(defun publish-quickhoney ()
(unpublish)
(make-instance 'website
:name "Quickhoney CMS"
:handler-definitions `(("/random-image" random-image-handler)
("/animation" animation-handler)
("/image-query-js" image-query-js-handler)
("/login-js" login-js-handler)
("/clients-js" clients-js-handler)
("/buttons-js" buttons-js-handler)
("/edit-image-js" edit-image-js-handler)
("/upload-image" upload-image-handler)
("/upload-animation" upload-animation-handler)
("/upload-button" upload-button-handler)
("/rss" rss-handler)
("/admin" admin-handler)
("/upload-news" upload-news-handler)
("/digg-image" digg-image-handler)
("/" template-handler
:default-template "frontpage"
:destination ,(namestring (merge-pathnames "templates/" *website-directory*))
:command-packages (("http://quickhoney.com/" . :quickhoney.tags)
("http://bknr.net/" . :bknr.web)))
user
images
("/static" directory-handler
:destination ,(merge-pathnames #p"static/" *website-directory*))
("/MochiKit" directory-handler
:destination ,(merge-pathnames #p"static/MochiKit/" *website-directory*))
("/favicon.ico" file-handler
:destination ,(merge-pathnames #p"static/favicon.ico" *website-directory*)
:content-type "application/x-icon"))
:admin-navigation '(("user" . "/user/")
("images" . "/edit-images")
("import" . "/import")
("logout" . "/logout"))
:authorizer (make-instance 'bknr-authorizer)
:site-logo-url "/image/quickhoney/color,000000,33ff00"
:login-logo-url "/image/quickhoney/color,000000,33ff00/double,3"
:style-sheet-urls '("/static/styles.css")
:javascript-urls '("/static/javascript.js")))
Getting Carried Away on Common Lisp
As you can see, I had to write little code to implement this functionality. In fact, the whole thing should have taken no longer than one or two hours, but here is how I found myself getting carried away:
For one, I spent substantial time on refactoring CL-SMTP, as you can read in my last blog entry. That took a few hours. For another, I really don't like how mime emails are constructed with
MAKE-INSTANCE in the
HANDLE-OBJECT method above. I thought that I'd be nice to have a
DEFINE-CONSTRUCTOR macro that created a function to create objects of arbitary classes, but that allows for one or more positional arguments in addition to any of the keyword arguments accepted by
MAKE-INSTANCE for a particular class.
Thus, instead of just coping with the slight ugliness, I
tried myself on a macro. Simple as it looked, it surely became more complicated as I had to deal with defaulted arguments, and I was stopped from investing any more time into this when I was reminded by cmm that
INITIALIZE-INSTANCE and
SHARED-INITIALIZE can define additional keyword arguments that are accepted by
MAKE-INSTANCE. Sure, it would be possible to find all applicable methods and extract more acceptable arguments from their lambda lists. Yet, I felt that I had enough fun for this last weekend and wrapped up the feedback functionality for Quickhoney.