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...
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>
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 |
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.
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.
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.
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.
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.
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.
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.
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.
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:
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 © 2009 O'Reilly Media, Inc.