MacDevCenter    
 Published on MacDevCenter (http://www.macdevcenter.com/)
 http://www.macdevcenter.com/pub/a/mac/developer/2003/04/01/sherlock_two.html
 See this if you're having trouble printing code examples


Creating Sherlock Channels, Part 2

by Harold Martin
04/01/2003

Editor's note -- In part one, Harold Martin showed you how to get started creating Sherlock channels. Now it's time to dig into the code...

Getting to the Code

Just as you spend time thinking about your interface before you make it, you should also spend time thinking about your code, what you want it to do, and how you want it to react to the UI. There is a tremendous difference between code that just works and code that is fun to work on.

In PB, open Channel/Channel.xml. Here's the code that will be powering your channel. You can delete the green text near the top (it just contains the default Apple copyright information). Next you'll see the <initialize> tag. It contains the code that will be run when your channel is first loaded by Sherlock. Here's where you'll want to put all the code that will modify any behavior of the channel before Sherlock. The code that is there now is just what we need, so we'll move on. Next you'll see <triggers>. This is where most of your code will go. Everything inside here will be a tag of the form:

<trigger path="path" language="XQuery | JavaScript">
code here
</trigger>

Getting and Displaying the Latest Hints

The first thing we want to do when the channel starts is to load the newest hints and display them in the table. There are two ways we can go about getting these:

Thankfully, a search shows that the site does have an RSS file (at http://www.macosxhints.com/backend/geeklog.rdf) that we can use. But don't worry if you wanted to parse HTML, we'll do that in a little while. Since we want to open the newest hints when the channel starts up, we'll put the code for parsing the RSS file in the Internet.didInstall trigger, which executes right after <initialize>. But since we use XQuery for parsing, we'll want to change the language attribute of this trigger from JavaScript to XQuery. The first step in parsing the file is to get it from the Web. To do that, we'll put this code inside the Internet.didInstall trigger:

let $httpRequest := http-request("http://www.macosxhints.com/backend/geeklog.rdf")
let $rss := http-request-value($httpRequest, "DATA")

http-request is how the request is set up. It can have additional options, but we won't need them here. They are documented in the Apple Sherlock reference. Here, http-request-value gets the value of the request, i.e. the data, in this case the file, at a particular URL.

So now that we have the RSS data, we have to parse it. To do that, we turn to the next major tool in out toolbox, the XPath Finder channel included with the Apple Development Channels.

Related Reading

JavaScript: The Definitive Guide

JavaScript: The Definitive Guide
By David Flanagan

Table of Contents
Index
Sample Chapter

Read Online--Safari Search this book on Safari:
 

Code Fragments only

What we are trying to do is find a pattern of the names hints and their links, so that we can present them to the user. Finding this in a page will often take a bit of work and if you don't know HTML/XHTML you're certainly disadvantaged here (you should probably look at HTML & XHTML: The Definitive Guide, 5th Ed.). This channel looks through the nested tags in the document it is given and then presents them to you in a column view. You navigate through the columns until you find the pattern of names/URLs (and any other information that you might need in a different channel) you are looking for, and the channel will show the XPath for the item in the text box along the bottom.

Enter http://www.macosxhints.com/backend/geeklog.rdf in the field where http://www.apple.com is and uncheck Render in the lower right corner. The reason we uncheck render is because it is designed to help when we're parsing HTML, which we're not. Explore a bit and see if you can find the pattern of names/URLs. In the first two columns there's only one choice, so we choose them. In the third column however, we see quite a few elements. Searching through this column, we find that pattern that we're looking for. Every hint is inside an <item> and inside each of those is a <title> and a <link>. This code extracts the title and link from each item:

let $goods := for $item in $rss/rss/channel/item
return dictionary(
        ("description", $item/title/text()/convert-html(.)),
        ("doubleClickURL", $item/link/text()/convert-html(.))
)

let $goods := for $item in $rss/rss/channel/item gets each item out of $rss and stores in it the variable $item. It then extracts the title out of each $item and stores it in description (which is the default identifier of the name column) and extracts the link and stores in it doubleClickURL which, as we'll see later, is a special key when used with a table. The /text()/convert-html(.) at the end of each path extracts the text from each of the url and then converts it to a format Sherlock can understand.

A dictionary is special type of data structure that you access via keys. In the example above, the keys are description and doubleClickURL. Every dictionary will always be of the form dictionary(("key", "value"), ("key", "value")). An important thing to remember is that a comma should come after every key/value pair in the dictionary except the last one. Each dictionary that is returned is stored as a row in $goods. Now to put $goods in the table, we say:

return dictionary(
    ("Internet.SearchResultsTable.selectedRows", null()),
    ("Internet.DetailHTMLView.HTMLData", ""),
    ("Internet.SearchResultsTable.dataValue", $goods)
)

This is a dictionary being returned at the end of a trigger, and because this dictionary is being returned within the trigger and not any other statements, the keys are mapped to paths. First we make sure that no row is selected, then that the HTMLView is cleared, then we give the $goods to the results table. When a dictionary is return'd to a table's dataValue, each row in the dictionary matches to a row in the table and each dictionary key matches to a cell in the table (doubleClickURL matches to a hidden cell).

You can now open up your channel in Sherlock (be sure to reload it if Sherlock is already open) and see a list of the newest hints. You can click on a hint to watch it load in the HTMLView or double click one to open it in your browser.

Displaying the Selected Hint

One thing that we'd really like to have to set our channel apart from a regular browser is to display only the hint in the HTMLView and not the whole page. To do that, we first need to find out what the path is for showing the page. The trigger path is Internet.SearchResultsTable. Right now, it's just grabbing the URL from the selected row's doubleClickURL, but we want it to take the URL, use XPath to extract the hint itself and put that in the HTMLView. Once again, we start in the XPath Finder channel.

Double click one of the hints to open it in your browser and copy the URL to the XPath Finder channel. Since we're parsing HTML, we'll want to leave Render on. You can see several choices in the first column, so it'll take some searching to find the right path. You may wonder why you can select tags like <img>, <br>, and <hr>. Remember that XPath was designed for parsing XML, and in XML every tag must be explicitly closed, otherwise subsequent elements show up as children of that tag. We find the path that will extract the hint from a page is: /img/table/tr/td[1]/table/tr/td/table/tr[4]/td[2]/span

In the Internet.SearchResultsTable trigger, we'll first want to delete it's return. You might notice that it doesn't return a dictionary. This is possible because output="Internet.DetailHTMLView.url" is specified in the trigger tag itself. The problem with this, of course, is that we can't use it to return to more that one path. But since we only need to modify HTMLView, we can just change Internet.DetailHTMLView.url to Internet.DetailHTMLView.htmlData . But we also need to get data into the trigger, which we can do via the input attribute. You can specify multiple comma separated variable assignments of the form variable=path, but we won't need to change any of the inputs. To get the page and extract the hint, we'll add:

let $httpPageRequest := http-request($selectedItem/doubleClickURL)
let $page := http-request-value($httpPageRequest, "DATA")
let $hint :=
  data($page/img/table/tr/td[1]/table/tr/td/table/tr[4]/td[2]/span)
return $hint

The first two lines should look familiar from earlier code. The third line (which should appear as one line, but is broken into two here for the sake of space) extracts the hint from the page, but since we don't need to repeatedly find something, we don't need any for loops. We then return the hint, and since it's not a dictionary, Sherlock uses the output attribute of the trigger (make sure to have that set to Internet.DetailHTMLView.htmlData). Now open up the channel in Sherlock (this is the last time I'm going to remind you to refresh it!), and you can click on one of the hints, wait a few seconds for it to load, and you will see the hint in the HTMLView.

Searching

The last major piece we want to add is the ability to search MacOSXHints.com. The default channel's structure is to call Internet.SearchButton.action which clears the table, starts the network arrows spinning, and then calls DATA.action.performSearch to actually get and display the results. This is a good structure (for reasons you'll see in a moment) and we'll use it.

Start by deleting all the code in DATA.action.performSearch. We now need to find a search page to parse. This URL is from a search from the front page of the site for "sherlock": http://www.macosxhints.com/search.php?query=sherlock&type=stories&mode=search

We could jump right in to parse this, but there's a problem. We want the user to be able to search for anything, not just Sherlock. We also need to properly escape any space characters (with a "%20") and we want to make sure all URLs are absolute since the search might give us relative URLs. We can accomplish all that with these three lines:

let $base := http-request("http://www.macosxhints.com/")
let $httpSearchRequest :=
  http-request(string-combine(("http://www.macosxhints.com/search.php?
  type=stories&mode=search&query=
  ",string-combine(string-separate($query, " "),"%20")), ""))
let $htmlSearch := http-request-value($httpSearchRequest, "DATA")

(Line 2 has been broken into four lines and indented here, but should appear on all one line).

The first line specifies the base URL to use later on when we're parsing the links. The second line, starting with the innermost function working outward, grabs the query (with the variable specified with the trigger's input attribute) and splits it with a " " as the separator, combines the split query with "%20", then combines it with the rest of the URL and then readies the http-request. The third line should look familiar by now.

Back to the parsing now. Open up the XPath Finder and use the URL I gave earlier http://www.macosxhints.com/search.php?query=sherlock&type=stories&mode=search.

You would look through it trying to find a path like earlier and eventually get to the path /img/table/tr/td/table/tr/td/table/tr/td/table/tr[2]/td/br/table only to find that there are lots of trs, some of which contain the information we're looking for (links to the hints and the titles of the hints) and some of which don't. The solution to this is to use the XPath /img/table/tr/td/table/tr/td/table/tr/td/table/tr[2]/td/br/table//a which selects all of the links within the path. So putting this together we get:

let $searchGoods := for $searchItem in $htmlSearch/img/table/tr/td/table/tr/td/table/tr/td/table/tr[2]/td/br/table//a
return dictionary(
    ("description", $searchItem/b/text()/convert-html(.)),
    ("doubleClickURL", url-with-base($searchItem/@href, http-request-value($base, "ACTUAL_URL")))
)

You should be able to understand these. The only new thing here, other than the // in the XPath, is the url-with-base function, which takes a URL and a base URL and ensures that you get an absolute URL. If the first URL is already absolute, it throws out the second URL. If not, it gets the URL from $base and puts them together. The only thing left to do here is to return the values:

return dictionary(
("Internet.SearchResultsTable.selectedRows", null()),
("Internet.SearchResultsTable.dataValue", $searchGoods),
("Internet.DetailHTMLView.HTMLData", ""),
("Internet.NetworkArrows.animating", false())
)

By now you should be able to understand all of that. The only other thing to remember is that we have to stop the network busy spinner that was started earlier. Open up your channel in Sherlock and try searching for different keywords. Happily, we don't need to set up another parser to extract the hints from the pages, Sherlock will use the one we've already written.

Getting the Newest Hints button Working

One problem our users will have now is that they can't get to the newest hints after they've done a search. This is where the Newest Hints button comes into play, and I hope you now see the value of thinking through the interface. We need to put some code at the Internet.NewestHints.action path.

We could create the trigger and then paste the code from the Internet.didInstall trigger, but that would break a fundamental programming rule: "Never repeat two significant sized pieces of code". So what we'll do is change the retrieving of the newest hints to work nearly much the same way as searching does.

JavaScript code will handle the button action and Internet.didInstall and then call some XQuery code to do the parsing and displaying of the hints. Remember that any trigger that wants to be called by other triggers instead of the UI must start with DATA.

Change the path of the Internet.didInstall trigger to DATA.action.newestHints . Then copy and paste the Internet.SearchButton.action trigger and set the copy's path to Internet.didInstall . On the last line of the new Internet.didInstall trigger change DATA.action.performSearch to DATA.action.newestHints. Copy and paste our new Internet.didInstall trigger and change its path to Internet.NewestHints.action. The last change we need to make is to add these lines:

, 
("Internet.NetworkArrows.animating", false())

to the DATA.action.newestHints trigger's return statement. The reason we add the comma is because the line that used to be the last one in the return statement is now the second to last. The whole DATA.action.newestHints trigger should now look like:

let $httpRequest := http-request("http://www.macosxhints.com/backend/geeklog.rdf") 
let $rss := http-request-value($httpRequest, "DATA")
let $goods := for $item in $rss/rss/channel/item 
return dictionary( 
                ("description", $item/title/text()/convert-html(.)), 
                ("doubleClickURL", $item/link/text()/convert-html(.)) 
) 
return dictionary( 
        ("Internet.SearchResultsTable.selectedRows", null()), 
        ("Internet.DetailHTMLView.HTMLData", ""), 
        ("Internet.SearchResultsTable.dataValue", $goods),
        ("Internet.NetworkArrows.animating", false())
)

Go ahead now and test our changes in Sherlock.

Get more Sherlock Channels

We want the user to be able to go to a site to find more Sherlock Channels instantly, to do that, we'll add this trigger and code:

<trigger path="Internet.GetMore.action" language="JavaScript">
    System.OpenURL("http://sherlock3.homeunix.com");
    </trigger>

This simply opens the URL in whatever application or browser the user has chosen.

The Enlarge and Shrink Font buttons

One problem users with limited eye site or high resolution monitors might have is the small size of the font in the HTMLView. To help them, we want to allow the font size to changed with either of our two buttons. Amazingly, we don't have to write a single line of code to do this. Open up IB, hold down the control key, and drag the Enlarge Font button towards the HTMLView. The button won't actually move, but you'll see a line being drawn between the two. After you release the mouse button, the Connections pane for the button will open. In the column on the right side, choose "makeFontBigger:" and click the Connect button in the lower right corner. Repeat these steps for the Shrink Font button, except choose "makeFontSmaller:" in the Connections pane.

The About Button

We want users to find out more about the channel and its developer, so we put a button where they can click and have a sheet/panel come down and tell give them that information. But first we need to create the sheet in IB.

In the Windows pane of the widgets palette drag the Panel into the Channel.nib window. You need to set a path for the panel, so open it up (by double clicking on it) and open up it's Sherlock pane. For the name enter "about", you'll notice that in the Data store path it only shows about and not Internet. This is because you have a entirely separate window. Drag a button into the lower right corner, name it "OK" and give a path of ok. I would also suggest making it equivalent to return. You can now add whatever else you want, but be sure to add some text for the user to read to find out how to reach you. Add these triggers to your code:

<trigger path="Internet.about.action" language="JavaScript">
DataStore.Notify("about.beginSheet");
</trigger>

<trigger path="about.ok.action" language="JavaScript">
DataStore.Notify("about.endSheet");
</trigger>

These triggers open up the sheet when the About button and close it when the sheet's OK button is clicked.

Calling the search from a URL

One neat feature of a Sherlock is that you can have an installed channel perform a specific action from a URL. Try sherlock://com.apple.yellowPages?query=sushi. This URL calls the Yellow Pages channel with a search for sushi. We want users and Web developers to be able to offer a similar URL to search with our channel.

To do that, we need to put some code in a trigger at the URL.complete path. Remember that we should never repeat two significant pieces of code. But, thankfully, the main search code is already in a trigger prefixed with DATA, so all we need to do is write code to grab the query from the URL and call the search. Here's how:

<trigger language="JavaScript" path="URL.complete">
    query = DataStore.Get("URL.query");
    DataStore.Set("Internet.MainQueryField.objectValue", query);
      DataStore.Set("Internet.NetworkArrows.animating", true);
    DataStore.Notify("DATA.action.performSearch");
</trigger>

This code is called when someone uses a URL like sherlock://com.mac.stevej.macosxhints?query=sherlock (be sure to use your identifier). The first line in the trigger grabs the query from the URL, the second sets the search text field to it, the third starts the network arrows spinning, the fourth calls the search. Sherlock automatically un-escapes any escaped characters for you.

Getting Help

Though creating the above channel step-by-step will work fine, you'll undoubtedly want to create your own, unique channel. You'll also (most likely) run into a few problems along the way. First read through the official documentation. Though a large part of it will be review of this article, you'll probably learn some things that will be useful to you. Remember that the reference part of the documentation will be your best friend when you're trying to figure things out. In case the documentation doesn't help you, there's plenty of places to ask other developers for help:

Getting your Channel to the World

After you've created your channel you need to share it with the world. The first step is to post it online. You ISP usually includes web space with your account, you can use your iDisk/.mac to host it, or you can use the Sherlock Channels free channel hosting. One thing to consider is that you want to your channel to be on a site you will own for the foreseeable future. Your don't want your users to have to deal with a channel that doesn't work, resubscribing, etc., just because you moved to a different ISP or web host.

After you post it, you'll also want to have it listed in a channel directory. I would recommend Sherlock Channels, a site devoted to listing Sherlock 3 channels.

Happy hacking!

Harold Martin is a freelance software developer and author. Visit him at his blog.


Return to the Mac Innovators Contest.


Return to the Mac DevCenter.


Copyright © 2007 O'Reilly Media, Inc.